From 08b87431c33f643f22fd2f1be3678dce9d444032 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 5 Jan 2026 11:28:00 +1100 Subject: [PATCH 001/177] Safely handle panic in scan queue goroutine (#6431) --- pkg/file/scan.go | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/pkg/file/scan.go b/pkg/file/scan.go index 4018913b0..803457665 100644 --- a/pkg/file/scan.go +++ b/pkg/file/scan.go @@ -7,6 +7,7 @@ import ( "io/fs" "os" "path/filepath" + "runtime/debug" "strings" "sync" "time" @@ -178,7 +179,16 @@ func (s *scanJob) execute(ctx context.Context) { wg.Add(1) go func() { - defer wg.Done() + defer func() { + wg.Done() + + // handle panics in goroutine + if p := recover(); p != nil { + logger.Errorf("panic while queuing files for scan: %v", p) + logger.Errorf(string(debug.Stack())) + } + }() + if err := s.queueFiles(ctx, paths); err != nil { if errors.Is(err, context.Canceled) { return @@ -204,6 +214,15 @@ func (s *scanJob) execute(ctx context.Context) { } func (s *scanJob) queueFiles(ctx context.Context, paths []string) error { + defer func() { + close(s.fileQueue) + + if s.ProgressReports != nil { + s.ProgressReports.AddTotal(s.count) + s.ProgressReports.Definite() + } + }() + var err error s.ProgressReports.ExecuteTask("Walking directory tree", func() { for _, p := range paths { @@ -214,13 +233,6 @@ func (s *scanJob) queueFiles(ctx context.Context, paths []string) error { } }) - close(s.fileQueue) - - if s.ProgressReports != nil { - s.ProgressReports.AddTotal(s.count) - s.ProgressReports.Definite() - } - return err } From d9622470160dd9784458a7e371282cbf394316de Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 5 Jan 2026 11:30:31 +1100 Subject: [PATCH 002/177] Custom favicon and title (#6366) * Load favicon if provided * Add custom title setting --- internal/api/server.go | 29 +++++++++++++++++++ ui/v2.5/src/App.tsx | 3 +- .../SettingsInterfacePanel.tsx | 8 +++++ ui/v2.5/src/core/config.ts | 2 ++ ui/v2.5/src/docs/en/Manual/Configuration.md | 6 ++++ ui/v2.5/src/hooks/title.ts | 13 +++++---- ui/v2.5/src/locales/en-GB.json | 4 +++ 7 files changed, 59 insertions(+), 6 deletions(-) diff --git a/internal/api/server.go b/internal/api/server.go index 9290c6512..ed11a99a5 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "path" + "path/filepath" "runtime/debug" "strconv" "strings" @@ -255,6 +256,9 @@ func Initialize() (*Server, error) { staticUI = statigz.FileServer(ui.UIBox.(fs.ReadDirFS)) } + // handle favicon override + r.HandleFunc("/favicon.ico", handleFavicon(staticUI)) + // Serve the web app r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { ext := path.Ext(r.URL.Path) @@ -295,6 +299,31 @@ func Initialize() (*Server, error) { return server, nil } +func handleFavicon(staticUI *statigz.Server) func(w http.ResponseWriter, r *http.Request) { + mgr := manager.GetInstance() + cfg := mgr.Config + + // check if favicon.ico exists in the config directory + // if so, use that + // otherwise, use the embedded one + iconPath := filepath.Join(cfg.GetConfigPath(), "favicon.ico") + exists, _ := fsutil.FileExists(iconPath) + + if exists { + logger.Debugf("Using custom favicon at %s", iconPath) + } + + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-cache") + + if exists { + http.ServeFile(w, r, iconPath) + } else { + staticUI.ServeHTTP(w, r) + } + } +} + // Start starts the server. It listens on the configured address and port. // It calls ListenAndServeTLS if TLS is configured, otherwise it calls ListenAndServe. // Calls to Start are blocked until the server is shutdown. diff --git a/ui/v2.5/src/App.tsx b/ui/v2.5/src/App.tsx index a8b92ecc3..761352373 100644 --- a/ui/v2.5/src/App.tsx +++ b/ui/v2.5/src/App.tsx @@ -307,7 +307,8 @@ export const App: React.FC = () => { ); } - const titleProps = makeTitleProps(); + const title = config.data?.configuration.ui.title || "Stash"; + const titleProps = makeTitleProps(title); if (!messages) { return null; diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index 7b3f936d3..0ebe3f736 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -248,6 +248,14 @@ export const SettingsInterfacePanel: React.FC = PatchComponent( onChange={(v) => saveInterface({ sfwContentMode: v })} /> + saveUI({ title: v })} + /> +
diff --git a/ui/v2.5/src/core/config.ts b/ui/v2.5/src/core/config.ts index 36d915eeb..b0dc15c9d 100644 --- a/ui/v2.5/src/core/config.ts +++ b/ui/v2.5/src/core/config.ts @@ -103,6 +103,8 @@ export interface IUIConfig { defaultFilters?: DefaultFilters; taggerConfig?: ITaggerConfig; + + title?: string; } export function getFrontPageContent( diff --git a/ui/v2.5/src/docs/en/Manual/Configuration.md b/ui/v2.5/src/docs/en/Manual/Configuration.md index d7c1b4804..76464facf 100644 --- a/ui/v2.5/src/docs/en/Manual/Configuration.md +++ b/ui/v2.5/src/docs/en/Manual/Configuration.md @@ -165,6 +165,12 @@ The following environment variables are also supported: |----------------------|---------| | `STASH_SQLITE_CACHE_SIZE` | Sets the SQLite cache size. See https://www.sqlite.org/pragma.html#pragma_cache_size. Default is `-2000` which is 2MB. | +### Custom favicon + +You can provide a custom favicon by placing a `favicon.ico` file in the configuration directory. The configuration directory is located alongside the `config.yml` file. + +When a custom favicon is provided, it will be served instead of the default embedded favicon. + ### Custom served folders Custom served folders are served when the server handles a request with the `/custom` URL prefix. The following is an example configuration: diff --git a/ui/v2.5/src/hooks/title.ts b/ui/v2.5/src/hooks/title.ts index 8dd311e47..193a3f920 100644 --- a/ui/v2.5/src/hooks/title.ts +++ b/ui/v2.5/src/hooks/title.ts @@ -1,10 +1,13 @@ import { MessageDescriptor, useIntl } from "react-intl"; +import { useConfigurationContext } from "./Config"; export const TITLE = "Stash"; export const TITLE_SEPARATOR = " | "; export function useTitleProps(...messages: (string | MessageDescriptor)[]) { const intl = useIntl(); + const config = useConfigurationContext(); + const title = config.configuration.ui.title || TITLE; const parts = messages.map((msg) => { if (typeof msg === "object") { @@ -14,13 +17,13 @@ export function useTitleProps(...messages: (string | MessageDescriptor)[]) { } }); - return makeTitleProps(...parts); + return makeTitleProps(title, ...parts); } -export function makeTitleProps(...parts: string[]) { - const title = [...parts, TITLE].join(TITLE_SEPARATOR); +export function makeTitleProps(title: string, ...parts: string[]) { + const fullTitle = [...parts, title].join(TITLE_SEPARATOR); return { - titleTemplate: `%s | ${title}`, - defaultTitle: title, + titleTemplate: `%s | ${fullTitle}`, + defaultTitle: fullTitle, }; } diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 21d3f6a24..158164c8d 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -625,6 +625,10 @@ "heading": "Custom localisation", "option_label": "Custom localisation enabled" }, + "custom_title": { + "description": "Custom text to append to the page title. If empty, defaults to 'Stash'.", + "heading": "Custom Title" + }, "delete_options": { "description": "Default settings when deleting images, galleries, and scenes.", "heading": "Delete Options", From 65e82a0cf6e10ebeb171feecc2bc608dc3907a4d Mon Sep 17 00:00:00 2001 From: sezzim <174854242+sezzim@users.noreply.github.com> Date: Sun, 4 Jan 2026 20:54:19 -0800 Subject: [PATCH 003/177] Performer merge (#5910) * Implement merging of performers * Make the tag merge UI consistent with other types of merges * Add merge action in scene menu --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/schema/schema.graphql | 1 + graphql/schema/types/performer.graphql | 7 + internal/api/resolver_mutation_performer.go | 180 +++- internal/api/resolver_mutation_studio.go | 4 +- pkg/models/mocks/PerformerReaderWriter.go | 14 + pkg/models/repository_performer.go | 2 + pkg/sqlite/performer.go | 55 ++ pkg/sqlite/performer_test.go | 140 +++ pkg/sqlite/tag.go | 2 + ui/v2.5/graphql/data/tag.graphql | 5 + ui/v2.5/graphql/mutations/performer.graphql | 6 + .../Performers/PerformerDetails/Performer.tsx | 43 +- .../PerformerScrapeDialog.tsx | 4 +- .../components/Performers/PerformerList.tsx | 39 + .../Performers/PerformerMergeDialog.tsx | 876 ++++++++++++++++++ ui/v2.5/src/components/Performers/styles.scss | 8 + .../components/Scenes/SceneDetails/Scene.tsx | 32 +- .../components/Scenes/SceneMergeDialog.tsx | 3 +- .../Shared/ScrapeDialog/ScrapeDialog.tsx | 5 +- .../src/components/Tags/TagDetails/Tag.tsx | 57 +- .../Tags/TagDetails/TagMergeDialog.tsx | 142 --- ui/v2.5/src/components/Tags/TagList.tsx | 36 + .../src/components/Tags/TagMergeDialog.tsx | 157 ++++ ui/v2.5/src/core/StashService.ts | 74 +- ui/v2.5/src/locales/en-GB.json | 7 +- 25 files changed, 1657 insertions(+), 242 deletions(-) create mode 100644 ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx delete mode 100644 ui/v2.5/src/components/Tags/TagDetails/TagMergeDialog.tsx create mode 100644 ui/v2.5/src/components/Tags/TagMergeDialog.tsx diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 8936b8a34..edfdecaac 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -373,6 +373,7 @@ type Mutation { performerDestroy(input: PerformerDestroyInput!): Boolean! performersDestroy(ids: [ID!]!): Boolean! bulkPerformerUpdate(input: BulkPerformerUpdateInput!): [Performer!] + performerMerge(input: PerformerMergeInput!): Performer! studioCreate(input: StudioCreateInput!): Studio studioUpdate(input: StudioUpdateInput!): Studio diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index fbb67ce8f..e788b91a8 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -185,3 +185,10 @@ type FindPerformersResultType { count: Int! performers: [Performer!]! } + +input PerformerMergeInput { + source: [ID!]! + destination: ID! + # values defined here will override values in the destination + values: PerformerUpdateInput +} diff --git a/internal/api/resolver_mutation_performer.go b/internal/api/resolver_mutation_performer.go index c54e3ca93..ab9abf6cf 100644 --- a/internal/api/resolver_mutation_performer.go +++ b/internal/api/resolver_mutation_performer.go @@ -2,13 +2,16 @@ package api import ( "context" + "errors" "fmt" + "slices" "strconv" "strings" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/performer" "github.com/stashapp/stash/pkg/plugin/hook" + "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/utils" ) @@ -136,7 +139,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per return r.getPerformer(ctx, newPerformer.ID) } -func (r *mutationResolver) validateNoLegacyURLs(translator changesetTranslator) error { +func validateNoLegacyURLs(translator changesetTranslator) error { // ensure url/twitter/instagram are not included in the input if translator.hasField("url") { return fmt.Errorf("url field must not be included if urls is included") @@ -151,7 +154,7 @@ func (r *mutationResolver) validateNoLegacyURLs(translator changesetTranslator) return nil } -func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int, legacyURL, legacyTwitter, legacyInstagram models.OptionalString, updatedPerformer *models.PerformerPartial) error { +func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int, legacyURLs legacyPerformerURLs, updatedPerformer *models.PerformerPartial) error { qb := r.repository.Performer // we need to be careful with URL/Twitter/Instagram @@ -170,23 +173,23 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int existingURLs := p.URLs.List() // performer partial URLs should be empty - if legacyURL.Set { + if legacyURLs.URL.Set { replaced := false for i, url := range existingURLs { if !performer.IsTwitterURL(url) && !performer.IsInstagramURL(url) { - existingURLs[i] = legacyURL.Value + existingURLs[i] = legacyURLs.URL.Value replaced = true break } } if !replaced { - existingURLs = append(existingURLs, legacyURL.Value) + existingURLs = append(existingURLs, legacyURLs.URL.Value) } } - if legacyTwitter.Set { - value := utils.URLFromHandle(legacyTwitter.Value, twitterURL) + if legacyURLs.Twitter.Set { + value := utils.URLFromHandle(legacyURLs.Twitter.Value, twitterURL) found := false // find and replace the first twitter URL for i, url := range existingURLs { @@ -201,9 +204,9 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int existingURLs = append(existingURLs, value) } } - if legacyInstagram.Set { + if legacyURLs.Instagram.Set { found := false - value := utils.URLFromHandle(legacyInstagram.Value, instagramURL) + value := utils.URLFromHandle(legacyURLs.Instagram.Value, instagramURL) // find and replace the first instagram URL for i, url := range existingURLs { if performer.IsInstagramURL(url) { @@ -226,16 +229,25 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int return nil } -func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.PerformerUpdateInput) (*models.Performer, error) { - performerID, err := strconv.Atoi(input.ID) - if err != nil { - return nil, fmt.Errorf("converting id: %w", err) - } +type legacyPerformerURLs struct { + URL models.OptionalString + Twitter models.OptionalString + Instagram models.OptionalString +} - translator := changesetTranslator{ - inputMap: getUpdateInputMap(ctx), - } +func (u *legacyPerformerURLs) AnySet() bool { + return u.URL.Set || u.Twitter.Set || u.Instagram.Set +} +func legacyPerformerURLsFromInput(input models.PerformerUpdateInput, translator changesetTranslator) legacyPerformerURLs { + return legacyPerformerURLs{ + URL: translator.optionalString(input.URL, "url"), + Twitter: translator.optionalString(input.Twitter, "twitter"), + Instagram: translator.optionalString(input.Instagram, "instagram"), + } +} + +func performerPartialFromInput(input models.PerformerUpdateInput, translator changesetTranslator) (*models.PerformerPartial, error) { // Populate performer from the input updatedPerformer := models.NewPerformerPartial() @@ -260,19 +272,17 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per updatedPerformer.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") updatedPerformer.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids") + var err error + if translator.hasField("urls") { // ensure url/twitter/instagram are not included in the input - if err := r.validateNoLegacyURLs(translator); err != nil { + if err := validateNoLegacyURLs(translator); err != nil { return nil, err } updatedPerformer.URLs = translator.updateStrings(input.Urls, "urls") } - legacyURL := translator.optionalString(input.URL, "url") - legacyTwitter := translator.optionalString(input.Twitter, "twitter") - legacyInstagram := translator.optionalString(input.Instagram, "instagram") - updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate") if err != nil { return nil, fmt.Errorf("converting birthdate: %w", err) @@ -299,6 +309,26 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per updatedPerformer.CustomFields = handleUpdateCustomFields(input.CustomFields) + return &updatedPerformer, nil +} + +func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.PerformerUpdateInput) (*models.Performer, error) { + performerID, err := strconv.Atoi(input.ID) + if err != nil { + return nil, fmt.Errorf("converting id: %w", err) + } + + translator := changesetTranslator{ + inputMap: getUpdateInputMap(ctx), + } + + updatedPerformer, err := performerPartialFromInput(input, translator) + if err != nil { + return nil, err + } + + legacyURLs := legacyPerformerURLsFromInput(input, translator) + var imageData []byte imageIncluded := translator.hasField("image") if input.Image != nil { @@ -312,17 +342,17 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Performer - if legacyURL.Set || legacyTwitter.Set || legacyInstagram.Set { - if err := r.handleLegacyURLs(ctx, performerID, legacyURL, legacyTwitter, legacyInstagram, &updatedPerformer); err != nil { + if legacyURLs.AnySet() { + if err := r.handleLegacyURLs(ctx, performerID, legacyURLs, updatedPerformer); err != nil { return err } } - if err := performer.ValidateUpdate(ctx, performerID, updatedPerformer, qb); err != nil { + if err := performer.ValidateUpdate(ctx, performerID, *updatedPerformer, qb); err != nil { return err } - _, err = qb.UpdatePartial(ctx, performerID, updatedPerformer) + _, err = qb.UpdatePartial(ctx, performerID, *updatedPerformer) if err != nil { return err } @@ -379,16 +409,18 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe if translator.hasField("urls") { // ensure url/twitter/instagram are not included in the input - if err := r.validateNoLegacyURLs(translator); err != nil { + if err := validateNoLegacyURLs(translator); err != nil { return nil, err } updatedPerformer.URLs = translator.updateStringsBulk(input.Urls, "urls") } - legacyURL := translator.optionalString(input.URL, "url") - legacyTwitter := translator.optionalString(input.Twitter, "twitter") - legacyInstagram := translator.optionalString(input.Instagram, "instagram") + legacyURLs := legacyPerformerURLs{ + URL: translator.optionalString(input.URL, "url"), + Twitter: translator.optionalString(input.Twitter, "twitter"), + Instagram: translator.optionalString(input.Instagram, "instagram"), + } updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate") if err != nil { @@ -425,8 +457,8 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe qb := r.repository.Performer for _, performerID := range performerIDs { - if legacyURL.Set || legacyTwitter.Set || legacyInstagram.Set { - if err := r.handleLegacyURLs(ctx, performerID, legacyURL, legacyTwitter, legacyInstagram, &updatedPerformer); err != nil { + if legacyURLs.AnySet() { + if err := r.handleLegacyURLs(ctx, performerID, legacyURLs, &updatedPerformer); err != nil { return err } } @@ -506,3 +538,87 @@ func (r *mutationResolver) PerformersDestroy(ctx context.Context, performerIDs [ return true, nil } + +func (r *mutationResolver) PerformerMerge(ctx context.Context, input PerformerMergeInput) (*models.Performer, error) { + srcIDs, err := stringslice.StringSliceToIntSlice(input.Source) + if err != nil { + return nil, fmt.Errorf("converting source ids: %w", err) + } + + // ensure source ids are unique + srcIDs = sliceutil.AppendUniques(nil, srcIDs) + + destID, err := strconv.Atoi(input.Destination) + if err != nil { + return nil, fmt.Errorf("converting destination id: %w", err) + } + + // ensure destination is not in source list + if slices.Contains(srcIDs, destID) { + return nil, errors.New("destination performer cannot be in source list") + } + + var values *models.PerformerPartial + var imageData []byte + + if input.Values != nil { + translator := changesetTranslator{ + inputMap: getNamedUpdateInputMap(ctx, "input.values"), + } + + values, err = performerPartialFromInput(*input.Values, translator) + if err != nil { + return nil, err + } + legacyURLs := legacyPerformerURLsFromInput(*input.Values, translator) + if legacyURLs.AnySet() { + return nil, errors.New("Merging legacy performer URLs is not supported") + } + + if input.Values.Image != nil { + var err error + imageData, err = utils.ProcessImageInput(ctx, *input.Values.Image) + if err != nil { + return nil, fmt.Errorf("processing cover image: %w", err) + } + } + } else { + v := models.NewPerformerPartial() + values = &v + } + + var dest *models.Performer + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Performer + + dest, err = qb.Find(ctx, destID) + if err != nil { + return fmt.Errorf("finding destination performer ID %d: %w", destID, err) + } + + // ensure source performers exist + if _, err := qb.FindMany(ctx, srcIDs); err != nil { + return fmt.Errorf("finding source performers: %w", err) + } + + if _, err := qb.UpdatePartial(ctx, destID, *values); err != nil { + return fmt.Errorf("updating performer: %w", err) + } + + if err := qb.Merge(ctx, srcIDs, destID); err != nil { + return fmt.Errorf("merging performers: %w", err) + } + + if len(imageData) > 0 { + if err := qb.UpdateImage(ctx, destID, imageData); err != nil { + return err + } + } + + return nil + }); err != nil { + return nil, err + } + + return dest, nil +} diff --git a/internal/api/resolver_mutation_studio.go b/internal/api/resolver_mutation_studio.go index 4b3316111..da3aa1983 100644 --- a/internal/api/resolver_mutation_studio.go +++ b/internal/api/resolver_mutation_studio.go @@ -134,7 +134,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio if translator.hasField("urls") { // ensure url not included in the input - if err := r.validateNoLegacyURLs(translator); err != nil { + if err := validateNoLegacyURLs(translator); err != nil { return nil, err } @@ -211,7 +211,7 @@ func (r *mutationResolver) BulkStudioUpdate(ctx context.Context, input BulkStudi if translator.hasField("urls") { // ensure url/twitter/instagram are not included in the input - if err := r.validateNoLegacyURLs(translator); err != nil { + if err := validateNoLegacyURLs(translator); err != nil { return nil, err } diff --git a/pkg/models/mocks/PerformerReaderWriter.go b/pkg/models/mocks/PerformerReaderWriter.go index dbf19a3cd..6487bc5a5 100644 --- a/pkg/models/mocks/PerformerReaderWriter.go +++ b/pkg/models/mocks/PerformerReaderWriter.go @@ -473,6 +473,20 @@ func (_m *PerformerReaderWriter) HasImage(ctx context.Context, performerID int) return r0, r1 } +// Merge provides a mock function with given fields: ctx, source, destination +func (_m *PerformerReaderWriter) Merge(ctx context.Context, source []int, destination int) error { + ret := _m.Called(ctx, source, destination) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, []int, int) error); ok { + r0 = rf(ctx, source, destination) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Query provides a mock function with given fields: ctx, performerFilter, findFilter func (_m *PerformerReaderWriter) Query(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) ([]*models.Performer, int, error) { ret := _m.Called(ctx, performerFilter, findFilter) diff --git a/pkg/models/repository_performer.go b/pkg/models/repository_performer.go index ad0b61da0..175208c9d 100644 --- a/pkg/models/repository_performer.go +++ b/pkg/models/repository_performer.go @@ -92,6 +92,8 @@ type PerformerWriter interface { PerformerCreator PerformerUpdater PerformerDestroyer + + Merge(ctx context.Context, source []int, destination int) error } // PerformerReaderWriter provides all performer methods. diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index bf6b780b2..4e06b5b29 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -893,3 +893,58 @@ func (qb *PerformerStore) FindByStashIDStatus(ctx context.Context, hasStashID bo return ret, nil } + +func (qb *PerformerStore) Merge(ctx context.Context, source []int, destination int) error { + if len(source) == 0 { + return nil + } + + inBinding := getInBinding(len(source)) + + args := []interface{}{destination} + srcArgs := make([]interface{}, len(source)) + for i, id := range source { + if id == destination { + return errors.New("cannot merge where source == destination") + } + srcArgs[i] = id + } + + args = append(args, srcArgs...) + + performerTables := map[string]string{ + performersScenesTable: sceneIDColumn, + performersGalleriesTable: galleryIDColumn, + performersImagesTable: imageIDColumn, + performersTagsTable: tagIDColumn, + } + + args = append(args, destination) + + // for each table, update source performer ids to destination performer id, ignoring duplicates + for table, idColumn := range performerTables { + _, err := dbWrapper.Exec(ctx, `UPDATE OR IGNORE `+table+` +SET performer_id = ? +WHERE performer_id IN `+inBinding+` +AND NOT EXISTS(SELECT 1 FROM `+table+` o WHERE o.`+idColumn+` = `+table+`.`+idColumn+` AND o.performer_id = ?)`, + args..., + ) + if err != nil { + return err + } + + // delete source performer ids from the table where they couldn't be set + if _, err := dbWrapper.Exec(ctx, `DELETE FROM `+table+` WHERE performer_id IN `+inBinding, srcArgs...); err != nil { + return err + } + } + + for _, id := range source { + err := qb.Destroy(ctx, id) + if err != nil { + return err + } + } + + return nil +} diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index 190d80e31..a88166657 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -2524,6 +2524,146 @@ func TestPerformerStore_FindByStashIDStatus(t *testing.T) { } } +func TestPerformerMerge(t *testing.T) { + tests := []struct { + name string + srcIdxs []int + destIdx int + wantErr bool + }{ + { + name: "merge into self", + srcIdxs: []int{performerIdx1WithDupName}, + destIdx: performerIdx1WithDupName, + wantErr: true, + }, + { + name: "merge multiple", + srcIdxs: []int{ + performerIdx2WithScene, + performerIdxWithTwoScenes, + performerIdx1WithImage, + performerIdxWithTwoImages, + performerIdxWithGallery, + performerIdxWithTwoGalleries, + performerIdxWithTag, + performerIdxWithTwoTags, + }, + destIdx: tagIdxWithPerformer, + wantErr: false, + }, + } + + qb := db.Performer + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + // load src tag ids to compare after merge + performerTagIds := make(map[int][]int) + for _, srcIdx := range tt.srcIdxs { + srcPerformer, err := qb.Find(ctx, performerIDs[srcIdx]) + if err != nil { + t.Errorf("Error finding performer: %s", err.Error()) + } + if err := srcPerformer.LoadTagIDs(ctx, qb); err != nil { + t.Errorf("Error loading performer tag IDs: %s", err.Error()) + } + srcTagIDs := srcPerformer.TagIDs.List() + performerTagIds[srcIdx] = srcTagIDs + } + + err := qb.Merge(ctx, indexesToIDs(tagIDs, tt.srcIdxs), tagIDs[tt.destIdx]) + + if (err != nil) != tt.wantErr { + t.Errorf("PerformerStore.Merge() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if err != nil { + return + } + + // ensure source performers are destroyed + for _, srcIdx := range tt.srcIdxs { + p, err := qb.Find(ctx, performerIDs[srcIdx]) + + // not found returns nil performer and nil error + if err != nil { + t.Errorf("Error finding performer: %s", err.Error()) + continue + } + assert.Nil(p) + } + + // ensure items point to new performer + for _, srcIdx := range tt.srcIdxs { + sceneIdxs := scenePerformers.reverseLookup(srcIdx) + for _, sceneIdx := range sceneIdxs { + s, err := db.Scene.Find(ctx, sceneIDs[sceneIdx]) + if err != nil { + t.Errorf("Error finding scene: %s", err.Error()) + } + if err := s.LoadPerformerIDs(ctx, db.Scene); err != nil { + t.Errorf("Error loading scene performer IDs: %s", err.Error()) + } + scenePerformerIDs := s.PerformerIDs.List() + + assert.Contains(scenePerformerIDs, performerIDs[tt.destIdx]) + assert.NotContains(scenePerformerIDs, performerIDs[srcIdx]) + } + + imageIdxs := imagePerformers.reverseLookup(srcIdx) + for _, imageIdx := range imageIdxs { + i, err := db.Image.Find(ctx, imageIDs[imageIdx]) + if err != nil { + t.Errorf("Error finding image: %s", err.Error()) + } + if err := i.LoadPerformerIDs(ctx, db.Image); err != nil { + t.Errorf("Error loading image performer IDs: %s", err.Error()) + } + imagePerformerIDs := i.PerformerIDs.List() + + assert.Contains(imagePerformerIDs, performerIDs[tt.destIdx]) + assert.NotContains(imagePerformerIDs, performerIDs[srcIdx]) + } + + galleryIdxs := galleryPerformers.reverseLookup(srcIdx) + for _, galleryIdx := range galleryIdxs { + g, err := db.Gallery.Find(ctx, galleryIDs[galleryIdx]) + if err != nil { + t.Errorf("Error finding gallery: %s", err.Error()) + } + if err := g.LoadPerformerIDs(ctx, db.Gallery); err != nil { + t.Errorf("Error loading gallery performer IDs: %s", err.Error()) + } + galleryPerformerIDs := g.PerformerIDs.List() + + assert.Contains(galleryPerformerIDs, performerIDs[tt.destIdx]) + assert.NotContains(galleryPerformerIDs, performerIDs[srcIdx]) + } + } + + // ensure tags were merged + destPerformer, err := qb.Find(ctx, performerIDs[tt.destIdx]) + if err != nil { + t.Errorf("Error finding performer: %s", err.Error()) + } + if err := destPerformer.LoadTagIDs(ctx, qb); err != nil { + t.Errorf("Error loading performer tag IDs: %s", err.Error()) + } + destTagIDs := destPerformer.TagIDs.List() + + for _, srcIdx := range tt.srcIdxs { + for _, tagID := range performerTagIds[srcIdx] { + assert.Contains(destTagIDs, tagID) + } + } + }) + } +} + // TODO Update // TODO Destroy // TODO Find diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 977ac0433..dd730c62c 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -859,6 +859,8 @@ func (qb *TagStore) Merge(ctx context.Context, source []int, destination int) er } args = append(args, destination) + + // for each table, update source tag ids to destination tag id, ignoring duplicates for table, idColumn := range tagTables { _, err := dbWrapper.Exec(ctx, `UPDATE OR IGNORE `+table+` SET tag_id = ? diff --git a/ui/v2.5/graphql/data/tag.graphql b/ui/v2.5/graphql/data/tag.graphql index 4b0c0aef9..e640af0c9 100644 --- a/ui/v2.5/graphql/data/tag.graphql +++ b/ui/v2.5/graphql/data/tag.graphql @@ -67,6 +67,11 @@ fragment TagListData on Tag { aliases ignore_auto_tag favorite + stash_ids { + endpoint + stash_id + updated_at + } image_path # Direct counts only - no recursive depth queries scene_count diff --git a/ui/v2.5/graphql/mutations/performer.graphql b/ui/v2.5/graphql/mutations/performer.graphql index a4fa341ed..2082281fc 100644 --- a/ui/v2.5/graphql/mutations/performer.graphql +++ b/ui/v2.5/graphql/mutations/performer.graphql @@ -23,3 +23,9 @@ mutation PerformerDestroy($id: ID!) { mutation PerformersDestroy($ids: [ID!]!) { performersDestroy(ids: $ids) } + +mutation PerformerMerge($input: PerformerMergeInput!) { + performerMerge(input: $input) { + id + } +} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index dd72d0025..92a563a81 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useState } from "react"; -import { Tabs, Tab, Col, Row } from "react-bootstrap"; -import { useIntl } from "react-intl"; +import { Button, Tabs, Tab, Col, Row } from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; import { useHistory, Redirect, RouteComponentProps } from "react-router-dom"; import { Helmet } from "react-helmet"; import cx from "classnames"; @@ -28,6 +28,7 @@ import { PerformerGroupsPanel } from "./PerformerGroupsPanel"; import { PerformerImagesPanel } from "./PerformerImagesPanel"; import { PerformerAppearsWithPanel } from "./performerAppearsWithPanel"; import { PerformerEditPanel } from "./PerformerEditPanel"; +import { PerformerMergeModal } from "../PerformerMergeDialog"; import { PerformerSubmitButton } from "./PerformerSubmitButton"; import { useRatingKeybinds } from "src/hooks/keybinds"; import { DetailImage } from "src/components/Shared/DetailImage"; @@ -250,6 +251,7 @@ const PerformerPage: React.FC = PatchComponent( const [collapsed, setCollapsed] = useState(!showAllDetails); const [isEditing, setIsEditing] = useState(false); + const [isMerging, setIsMerging] = useState(false); const [image, setImage] = useState(); const [encodingImage, setEncodingImage] = useState(false); const loadStickyHeader = useLoadStickyHeader(); @@ -285,6 +287,33 @@ const PerformerPage: React.FC = PatchComponent( } } + function renderMergeButton() { + return ( + + ); + } + + function renderMergeDialog() { + if (!performer.id) return; + return ( + { + setIsMerging(false); + if (mergedId !== undefined && mergedId !== performer.id) { + // By default, the merge destination is the current performer, but + // the user can change it, in which case we need to redirect. + history.replace(`/performers/${mergedId}`); + } + }} + performers={[performer]} + /> + ); + } + useRatingKeybinds( true, configuration?.ui.ratingSystemOptions?.type, @@ -469,9 +498,12 @@ const PerformerPage: React.FC = PatchComponent( onImageChange={() => {}} classNames="mb-2" customButtons={ -
- -
+ <> + {renderMergeButton()} +
+ +
+ } > @@ -499,6 +531,7 @@ const PerformerPage: React.FC = PatchComponent(
+ {renderMergeDialog()} ); } diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx index b3ec4bff6..afb57a66e 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx @@ -56,7 +56,7 @@ function renderScrapedGender( ); } -function renderScrapedGenderRow( +export function renderScrapedGenderRow( title: string, result: ScrapeResult, onChange: (value: ScrapeResult) => void @@ -104,7 +104,7 @@ function renderScrapedCircumcised( ); } -function renderScrapedCircumcisedRow( +export function renderScrapedCircumcisedRow( title: string, result: ScrapeResult, onChange: (value: ScrapeResult) => void diff --git a/ui/v2.5/src/components/Performers/PerformerList.tsx b/ui/v2.5/src/components/Performers/PerformerList.tsx index b2c6dc36e..ef465fb38 100644 --- a/ui/v2.5/src/components/Performers/PerformerList.tsx +++ b/ui/v2.5/src/components/Performers/PerformerList.tsx @@ -21,6 +21,7 @@ import { EditPerformersDialog } from "./EditPerformersDialog"; import { cmToImperial, cmToInches, kgToLbs } from "src/utils/units"; import TextUtils from "src/utils/text"; import { PerformerCardGrid } from "./PerformerCardGrid"; +import { PerformerMergeModal } from "./PerformerMergeDialog"; import { View } from "../List/views"; import { IItemListOperation } from "../List/FilteredListToolbar"; import { PatchComponent } from "src/patch"; @@ -169,6 +170,9 @@ export const PerformerList: React.FC = PatchComponent( ({ filterHook, view, alterQuery, extraCriteria, extraOperations = [] }) => { const intl = useIntl(); const history = useHistory(); + const [mergePerformers, setMergePerformers] = useState< + GQL.SelectPerformerDataFragment[] | undefined + >(undefined); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportAll, setIsExportAll] = useState(false); @@ -180,6 +184,11 @@ export const PerformerList: React.FC = PatchComponent( text: intl.formatMessage({ id: "actions.open_random" }), onClick: openRandom, }, + { + text: `${intl.formatMessage({ id: "actions.merge" })}…`, + onClick: merge, + isDisplayed: showWhenSelected, + }, { text: intl.formatMessage({ id: "actions.export" }), onClick: onExport, @@ -222,6 +231,18 @@ export const PerformerList: React.FC = PatchComponent( } } + async function merge( + result: GQL.FindPerformersQueryResult, + filter: ListFilterModel, + selectedIds: Set + ) { + const selected = + result.data?.findPerformers.performers.filter((p) => + selectedIds.has(p.id) + ) ?? []; + setMergePerformers(selected); + } + async function onExport() { setIsExportAll(false); setIsExportDialogOpen(true); @@ -238,6 +259,23 @@ export const PerformerList: React.FC = PatchComponent( selectedIds: Set, onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void ) { + function renderMergeDialog() { + if (mergePerformers) { + return ( + { + setMergePerformers(undefined); + if (mergedId) { + history.push(`/performers/${mergedId}`); + } + }} + show + /> + ); + } + } + function maybeRenderPerformerExportDialog() { if (isExportDialogOpen) { return ( @@ -290,6 +328,7 @@ export const PerformerList: React.FC = PatchComponent( return ( <> + {renderMergeDialog()} {maybeRenderPerformerExportDialog()} {renderPerformers()} diff --git a/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx new file mode 100644 index 000000000..834d2ac76 --- /dev/null +++ b/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx @@ -0,0 +1,876 @@ +import { Form, Col, Row, Button } from "react-bootstrap"; +import React, { useEffect, useMemo, useState } from "react"; +import * as GQL from "src/core/generated-graphql"; +import { Icon } from "../Shared/Icon"; +import { LoadingIndicator } from "../Shared/LoadingIndicator"; +import { + circumcisedToString, + stringToCircumcised, +} from "src/utils/circumcised"; +import * as FormUtils from "src/utils/form"; +import { genderToString, stringToGender } from "src/utils/gender"; +import ImageUtils from "src/utils/image"; +import { + mutatePerformerMerge, + queryFindPerformersByID, +} from "src/core/StashService"; +import { FormattedMessage, useIntl } from "react-intl"; +import { useToast } from "src/hooks/Toast"; +import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons"; +import { ScrapeDialog } from "../Shared/ScrapeDialog/ScrapeDialog"; +import { + ScrapedImageRow, + ScrapedInputGroupRow, + ScrapedStringListRow, + ScrapedTextAreaRow, +} from "../Shared/ScrapeDialog/ScrapeDialogRow"; +import { ModalComponent } from "../Shared/Modal"; +import { sortStoredIdObjects } from "src/utils/data"; +import { + ObjectListScrapeResult, + ScrapeResult, + ZeroableScrapeResult, + hasScrapedValues, +} from "../Shared/ScrapeDialog/scrapeResult"; +import { ScrapedTagsRow } from "../Shared/ScrapeDialog/ScrapedObjectsRow"; +import { + renderScrapedGenderRow, + renderScrapedCircumcisedRow, +} from "./PerformerDetails/PerformerScrapeDialog"; +import { PerformerSelect } from "./PerformerSelect"; +import { uniq } from "lodash-es"; + +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +type CustomFieldScrapeResults = Map>; + +// There are a bunch of similar functions in PerformerScrapeDialog, but since we don't support +// scraping custom fields, this one is only needed here. The `renderScraped` naming is kept the same +// for consistency. +function renderScrapedCustomFieldRows( + results: CustomFieldScrapeResults, + onChange: (newCustomFields: CustomFieldScrapeResults) => void +) { + return ( + <> + {Array.from(results.entries()).map(([field, result]) => { + const fieldName = `custom_${field}`; + return ( + { + const newResults = new Map(results); + newResults.set(field, newResult); + onChange(newResults); + }} + /> + ); + })} + + ); +} + +type MergeOptions = { + values: GQL.PerformerUpdateInput; +}; + +interface IPerformerMergeDetailsProps { + sources: GQL.PerformerDataFragment[]; + dest: GQL.PerformerDataFragment; + onClose: (options?: MergeOptions) => void; +} + +const PerformerMergeDetails: React.FC = ({ + sources, + dest, + onClose, +}) => { + const intl = useIntl(); + + const [loading, setLoading] = useState(true); + + const [name, setName] = useState>( + new ScrapeResult(dest.name) + ); + const [disambiguation, setDisambiguation] = useState>( + new ScrapeResult(dest.disambiguation) + ); + const [aliases, setAliases] = useState>( + new ScrapeResult(dest.alias_list) + ); + const [birthdate, setBirthdate] = useState>( + new ScrapeResult(dest.birthdate) + ); + const [deathDate, setDeathDate] = useState>( + new ScrapeResult(dest.death_date) + ); + const [ethnicity, setEthnicity] = useState>( + new ScrapeResult(dest.ethnicity) + ); + const [country, setCountry] = useState>( + new ScrapeResult(dest.country) + ); + const [hairColor, setHairColor] = useState>( + new ScrapeResult(dest.hair_color) + ); + const [eyeColor, setEyeColor] = useState>( + new ScrapeResult(dest.eye_color) + ); + const [height, setHeight] = useState>( + new ScrapeResult(dest.height_cm?.toString()) + ); + const [weight, setWeight] = useState>( + new ScrapeResult(dest.weight?.toString()) + ); + const [penisLength, setPenisLength] = useState>( + new ScrapeResult(dest.penis_length?.toString()) + ); + const [measurements, setMeasurements] = useState>( + new ScrapeResult(dest.measurements) + ); + const [fakeTits, setFakeTits] = useState>( + new ScrapeResult(dest.fake_tits) + ); + const [careerLength, setCareerLength] = useState>( + new ScrapeResult(dest.career_length) + ); + const [tattoos, setTattoos] = useState>( + new ScrapeResult(dest.tattoos) + ); + const [piercings, setPiercings] = useState>( + new ScrapeResult(dest.piercings) + ); + const [urls, setURLs] = useState>( + new ScrapeResult(dest.urls) + ); + const [gender, setGender] = useState>( + new ScrapeResult(genderToString(dest.gender)) + ); + const [circumcised, setCircumcised] = useState>( + new ScrapeResult(circumcisedToString(dest.circumcised)) + ); + const [details, setDetails] = useState>( + new ScrapeResult(dest.details) + ); + const [tags, setTags] = useState>( + new ObjectListScrapeResult( + sortStoredIdObjects(dest.tags.map(idToStoredID)) + ) + ); + + const [image, setImage] = useState>( + new ScrapeResult(dest.image_path) + ); + + const [customFields, setCustomFields] = useState( + new Map() + ); + + function idToStoredID(o: { id: string; name: string }) { + return { + stored_id: o.id, + name: o.name, + }; + } + + // calculate the values for everything + // uses the first set value for single value fields, and combines all + useEffect(() => { + async function loadImages() { + const src = sources.find((s) => s.image_path); + if (!dest.image_path || !src) return; + + setLoading(true); + + const destData = await ImageUtils.imageToDataURL(dest.image_path); + const srcData = await ImageUtils.imageToDataURL(src.image_path!); + + // keep destination image by default + const useNewValue = false; + setImage(new ScrapeResult(destData, srcData, useNewValue)); + + setLoading(false); + } + + setName( + new ScrapeResult(dest.name, sources.find((s) => s.name)?.name, !dest.name) + ); + setDisambiguation( + new ScrapeResult( + dest.disambiguation, + sources.find((s) => s.disambiguation)?.disambiguation, + !dest.disambiguation + ) + ); + + // default alias list should be the existing aliases, plus the names of all sources, + // plus all source aliases, deduplicated + const allAliases = uniq( + dest.alias_list.concat( + sources.map((s) => s.name), + sources.flatMap((s) => s.alias_list) + ) + ); + + setAliases( + new ScrapeResult(dest.alias_list, allAliases, !!allAliases.length) + ); + setBirthdate( + new ScrapeResult( + dest.birthdate, + sources.find((s) => s.birthdate)?.birthdate, + !dest.birthdate + ) + ); + setDeathDate( + new ScrapeResult( + dest.death_date, + sources.find((s) => s.death_date)?.death_date, + !dest.death_date + ) + ); + setEthnicity( + new ScrapeResult( + dest.ethnicity, + sources.find((s) => s.ethnicity)?.ethnicity, + !dest.ethnicity + ) + ); + setCountry( + new ScrapeResult( + dest.country, + sources.find((s) => s.country)?.country, + !dest.country + ) + ); + setHairColor( + new ScrapeResult( + dest.hair_color, + sources.find((s) => s.hair_color)?.hair_color, + !dest.hair_color + ) + ); + setEyeColor( + new ScrapeResult( + dest.eye_color, + sources.find((s) => s.eye_color)?.eye_color, + !dest.eye_color + ) + ); + setHeight( + new ScrapeResult( + dest.height_cm?.toString(), + sources.find((s) => s.height_cm)?.height_cm?.toString(), + !dest.height_cm + ) + ); + setWeight( + new ScrapeResult( + dest.weight?.toString(), + sources.find((s) => s.weight)?.weight?.toString(), + !dest.weight + ) + ); + + setPenisLength( + new ScrapeResult( + dest.penis_length?.toString(), + sources.find((s) => s.penis_length)?.penis_length?.toString(), + !dest.penis_length + ) + ); + setMeasurements( + new ScrapeResult( + dest.measurements, + sources.find((s) => s.measurements)?.measurements, + !dest.measurements + ) + ); + setFakeTits( + new ScrapeResult( + dest.fake_tits, + sources.find((s) => s.fake_tits)?.fake_tits, + !dest.fake_tits + ) + ); + setCareerLength( + new ScrapeResult( + dest.career_length, + sources.find((s) => s.career_length)?.career_length, + !dest.career_length + ) + ); + setTattoos( + new ScrapeResult( + dest.tattoos, + sources.find((s) => s.tattoos)?.tattoos, + !dest.tattoos + ) + ); + setPiercings( + new ScrapeResult( + dest.piercings, + sources.find((s) => s.piercings)?.piercings, + !dest.piercings + ) + ); + setURLs( + new ScrapeResult( + dest.urls, + sources.find((s) => s.urls)?.urls, + !dest.urls?.length + ) + ); + setGender( + new ScrapeResult( + genderToString(dest.gender), + sources.find((s) => s.gender)?.gender + ? genderToString(sources.find((s) => s.gender)?.gender) + : undefined, + !dest.gender + ) + ); + setCircumcised( + new ScrapeResult( + circumcisedToString(dest.circumcised), + sources.find((s) => s.circumcised)?.circumcised + ? circumcisedToString(sources.find((s) => s.circumcised)?.circumcised) + : undefined, + !dest.circumcised + ) + ); + setDetails( + new ScrapeResult( + dest.details, + sources.find((s) => s.details)?.details, + !dest.details + ) + ); + setImage( + new ScrapeResult( + dest.image_path, + sources.find((s) => s.image_path)?.image_path, + !dest.image_path + ) + ); + + const customFieldNames = new Set(Object.keys(dest.custom_fields)); + + for (const s of sources) { + for (const n of Object.keys(s.custom_fields)) { + customFieldNames.add(n); + } + } + + setCustomFields( + new Map( + Array.from(customFieldNames) + .sort() + .map((field) => { + return [ + field, + new ScrapeResult( + dest.custom_fields?.[field], + sources.find((s) => s.custom_fields?.[field])?.custom_fields?.[ + field + ], + dest.custom_fields?.[field] === undefined + ), + ]; + }) + ) + ); + + loadImages(); + }, [sources, dest]); + + const hasCustomFieldValues = useMemo(() => { + return hasScrapedValues(Array.from(customFields.values())); + }, [customFields]); + + // ensure this is updated if fields are changed + const hasValues = useMemo(() => { + return ( + hasCustomFieldValues || + hasScrapedValues([ + name, + disambiguation, + aliases, + birthdate, + deathDate, + ethnicity, + country, + hairColor, + eyeColor, + height, + weight, + penisLength, + measurements, + fakeTits, + careerLength, + tattoos, + piercings, + urls, + gender, + circumcised, + details, + tags, + image, + ]) + ); + }, [ + name, + disambiguation, + aliases, + birthdate, + deathDate, + ethnicity, + country, + hairColor, + eyeColor, + height, + weight, + penisLength, + measurements, + fakeTits, + careerLength, + tattoos, + piercings, + urls, + gender, + circumcised, + details, + tags, + image, + hasCustomFieldValues, + ]); + + function renderScrapeRows() { + if (loading) { + return ( +
+ +
+ ); + } + + if (!hasValues) { + return ( +
+ +
+ ); + } + + return ( + <> + setName(value)} + /> + setDisambiguation(value)} + /> + setAliases(value)} + /> + setBirthdate(value)} + /> + setDeathDate(value)} + /> + setEthnicity(value)} + /> + setCountry(value)} + /> + setHairColor(value)} + /> + setEyeColor(value)} + /> + setHeight(value)} + /> + setWeight(value)} + /> + setPenisLength(value)} + /> + setMeasurements(value)} + /> + setFakeTits(value)} + /> + setCareerLength(value)} + /> + setTattoos(value)} + /> + setPiercings(value)} + /> + setURLs(value)} + /> + {renderScrapedGenderRow( + intl.formatMessage({ id: "gender" }), + gender, + (value) => setGender(value) + )} + {renderScrapedCircumcisedRow( + intl.formatMessage({ id: "circumcised" }), + circumcised, + (value) => setCircumcised(value) + )} + setTags(value)} + /> + setDetails(value)} + /> + setImage(value)} + /> + {hasCustomFieldValues && + renderScrapedCustomFieldRows(customFields, (newCustomFields) => + setCustomFields(newCustomFields) + )} + + ); + } + + function createValues(): MergeOptions { + // only set the cover image if it's different from the existing cover image + const coverImage = image.useNewValue ? image.getNewValue() : undefined; + + return { + values: { + id: dest.id, + name: name.getNewValue(), + disambiguation: disambiguation.getNewValue(), + alias_list: aliases + .getNewValue() + ?.map((s) => s.trim()) + .filter((s) => s.length > 0), + birthdate: birthdate.getNewValue(), + death_date: deathDate.getNewValue(), + ethnicity: ethnicity.getNewValue(), + country: country.getNewValue(), + hair_color: hairColor.getNewValue(), + eye_color: eyeColor.getNewValue(), + height_cm: height.getNewValue() + ? parseFloat(height.getNewValue()!) + : undefined, + weight: weight.getNewValue() + ? parseFloat(weight.getNewValue()!) + : undefined, + penis_length: penisLength.getNewValue() + ? parseFloat(penisLength.getNewValue()!) + : undefined, + measurements: measurements.getNewValue(), + fake_tits: fakeTits.getNewValue(), + career_length: careerLength.getNewValue(), + tattoos: tattoos.getNewValue(), + piercings: piercings.getNewValue(), + urls: urls.getNewValue(), + gender: stringToGender(gender.getNewValue()), + circumcised: stringToCircumcised(circumcised.getNewValue()), + tag_ids: tags.getNewValue()?.map((t) => t.stored_id!), + details: details.getNewValue(), + image: coverImage, + custom_fields: { + partial: Object.fromEntries( + Array.from(customFields.entries()).flatMap(([field, v]) => + v.useNewValue ? [[field, v.getNewValue()]] : [] + ) + ), + }, + }, + }; + } + + const dialogTitle = intl.formatMessage({ + id: "actions.merge", + }); + + const destinationLabel = !hasValues + ? "" + : intl.formatMessage({ id: "dialogs.merge.destination" }); + const sourceLabel = !hasValues + ? "" + : intl.formatMessage({ id: "dialogs.merge.source" }); + + return ( + { + if (!apply) { + onClose(); + } else { + onClose(createValues()); + } + }} + > + {renderScrapeRows()} + + ); +}; + +interface IPerformerMergeModalProps { + show: boolean; + onClose: (mergedId?: string) => void; + performers: GQL.SelectPerformerDataFragment[]; +} + +export const PerformerMergeModal: React.FC = ({ + show, + onClose, + performers, +}) => { + const [sourcePerformers, setSourcePerformers] = useState< + GQL.SelectPerformerDataFragment[] + >([]); + const [destPerformer, setDestPerformer] = useState< + GQL.SelectPerformerDataFragment[] + >([]); + + const [loadedSources, setLoadedSources] = useState< + GQL.PerformerDataFragment[] + >([]); + const [loadedDest, setLoadedDest] = useState(); + + const [running, setRunning] = useState(false); + const [secondStep, setSecondStep] = useState(false); + + const intl = useIntl(); + const Toast = useToast(); + + const title = intl.formatMessage({ + id: "actions.merge", + }); + + useEffect(() => { + if (performers.length > 0) { + // set the first performer as the destination, others as source + setDestPerformer([performers[0]]); + + if (performers.length > 1) { + setSourcePerformers(performers.slice(1)); + } + } + }, [performers]); + + async function loadPerformers() { + const performerIDs = sourcePerformers.map((s) => parseInt(s.id)); + performerIDs.push(parseInt(destPerformer[0].id)); + const query = await queryFindPerformersByID(performerIDs); + const { performers: loadedPerformers } = query.data.findPerformers; + + setLoadedDest(loadedPerformers.find((s) => s.id === destPerformer[0].id)); + setLoadedSources( + loadedPerformers.filter((s) => s.id !== destPerformer[0].id) + ); + setSecondStep(true); + } + + async function onMerge(options: MergeOptions) { + const { values } = options; + try { + setRunning(true); + const result = await mutatePerformerMerge( + destPerformer[0].id, + sourcePerformers.map((s) => s.id), + values + ); + if (result.data?.performerMerge) { + Toast.success(intl.formatMessage({ id: "toast.merged_performers" })); + onClose(destPerformer[0].id); + } + onClose(); + } catch (e) { + Toast.error(e); + } finally { + setRunning(false); + } + } + + function canMerge() { + return sourcePerformers.length > 0 && destPerformer.length !== 0; + } + + function switchPerformers() { + if (sourcePerformers.length && destPerformer.length) { + const newDest = sourcePerformers[0]; + setSourcePerformers([...sourcePerformers.slice(1), destPerformer[0]]); + setDestPerformer([newDest]); + } + } + + if (secondStep && destPerformer.length > 0) { + return ( + { + setSecondStep(false); + if (values) { + onMerge(values); + } else { + onClose(); + } + }} + /> + ); + } + + return ( + loadPerformers(), + }} + disabled={!canMerge()} + cancel={{ + variant: "secondary", + onClick: () => onClose(), + }} + isRunning={running} + > +
+
+ + {FormUtils.renderLabel({ + title: intl.formatMessage({ id: "dialogs.merge.source" }), + labelProps: { + column: true, + sm: 3, + xl: 12, + }, + })} + + setSourcePerformers(items)} + values={sourcePerformers} + menuPortalTarget={document.body} + /> + + + + + + + {FormUtils.renderLabel({ + title: intl.formatMessage({ + id: "dialogs.merge.destination", + }), + labelProps: { + column: true, + sm: 3, + xl: 12, + }, + })} + + setDestPerformer(items)} + values={destPerformer} + menuPortalTarget={document.body} + /> + + +
+
+
+ ); +}; diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index 1840ad960..c3cebf997 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -302,3 +302,11 @@ overflow-y: auto; padding-right: 1.5rem; } + +.performer-merge-dialog .custom-field { + // ensure we don't catch the destination/source labels + & > .form-label, + .form-control { + font-family: "Courier New", Courier, monospace; + } +} diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index 3615f1327..ee38ebd47 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -7,7 +7,7 @@ import React, { useLayoutEffect, } from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import { Link, RouteComponentProps } from "react-router-dom"; +import { useHistory, Link, RouteComponentProps } from "react-router-dom"; import { Helmet } from "react-helmet"; import * as GQL from "src/core/generated-graphql"; import { @@ -50,6 +50,7 @@ import { lazyComponent } from "src/utils/lazyComponent"; import cx from "classnames"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { PatchComponent, PatchContainerComponent } from "src/patch"; +import { SceneMergeModal } from "../SceneMergeDialog"; import { goBackOrReplace } from "src/utils/history"; import { FormattedDate } from "src/components/Shared/Date"; @@ -182,6 +183,7 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { const Toast = useToast(); const intl = useIntl(); + const history = useHistory(); const [updateScene] = useSceneUpdate(); const [generateScreenshot] = useSceneGenerateScreenshot(); const { configuration } = useConfigurationContext(); @@ -205,6 +207,7 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { const [activeTabKey, setActiveTabKey] = useState("scene-details-panel"); + const [isMerging, setIsMerging] = useState(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false); @@ -347,6 +350,24 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { } } + function maybeRenderMergeDialog() { + if (!scene.id) return; + return ( + { + setIsMerging(false); + if (mergedId !== undefined && mergedId !== scene.id) { + // By default, the merge destination is the current scene, but + // the user can change it, in which case we need to redirect. + history.replace(`/scenes/${mergedId}`); + } + }} + scenes={[{ id: scene.id, title: objectTitle(scene) }]} + /> + ); + } + function maybeRenderDeleteDialog() { if (isDeleteAlertOpen) { return ( @@ -419,6 +440,14 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { )} + setIsMerging(true)} + > + + ... + = PatchComponent("ScenePage", (props) => { {title} {maybeRenderSceneGenerateDialog()} + {maybeRenderMergeDialog()} {maybeRenderDeleteDialog()}
= ({ ); if (result.data?.sceneMerge) { Toast.success(intl.formatMessage({ id: "toast.merged_scenes" })); - // refetch the scene - await queryFindScenesByID([parseInt(destScene[0].id)]); onClose(destScene[0].id); } onClose(); @@ -735,6 +733,7 @@ export const SceneMergeModal: React.FC = ({ sources={loadedSources} dest={loadedDest!} onClose={(values) => { + setSecondStep(false); if (values) { onMerge(values); } else { diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx index 98699cbb6..ecf95541f 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx @@ -14,6 +14,7 @@ export const ScrapeDialogContext = React.createContext({}); interface IScrapeDialogProps { + className?: string; title: string; existingLabel?: React.ReactNode; scrapedLabel?: React.ReactNode; @@ -68,7 +69,9 @@ export const ScrapeDialog: React.FC< }} modalProps={{ size: "lg", - dialogClassName: `scrape-dialog ${sfwContentMode ? "sfw-mode" : ""}`, + dialogClassName: `${props.className ?? ""} scrape-dialog ${ + sfwContentMode ? "sfw-mode" : "" + }`, }} >
diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index e0bc11e37..76442b639 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -1,4 +1,4 @@ -import { Tabs, Tab, Dropdown, Form } from "react-bootstrap"; +import { Button, Tabs, Tab, Form } from "react-bootstrap"; import React, { useEffect, useMemo, useState } from "react"; import { useHistory, Redirect, RouteComponentProps } from "react-router-dom"; import { FormattedMessage, useIntl } from "react-intl"; @@ -17,7 +17,6 @@ import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { ErrorMessage } from "src/components/Shared/ErrorMessage"; import { ModalComponent } from "src/components/Shared/Modal"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; -import { Icon } from "src/components/Shared/Icon"; import { useToast } from "src/hooks/Toast"; import { useConfigurationContext } from "src/hooks/Config"; import { tagRelationHook } from "src/core/tags"; @@ -29,12 +28,8 @@ import { TagStudiosPanel } from "./TagStudiosPanel"; import { TagGalleriesPanel } from "./TagGalleriesPanel"; import { CompressedTagDetailsPanel, TagDetailsPanel } from "./TagDetailsPanel"; import { TagEditPanel } from "./TagEditPanel"; -import { TagMergeModal } from "./TagMergeDialog"; -import { - faSignInAlt, - faSignOutAlt, - faTrashAlt, -} from "@fortawesome/free-solid-svg-icons"; +import { TagMergeModal } from "../TagMergeDialog"; +import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; import { DetailImage } from "src/components/Shared/DetailImage"; import { useLoadStickyHeader } from "src/hooks/detailsPanel"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; @@ -306,7 +301,7 @@ const TagPage: React.FC = ({ tag, tabKey }) => { // Editing state const [isEditing, setIsEditing] = useState(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); - const [mergeType, setMergeType] = useState<"from" | "into" | undefined>(); + const [isMerging, setIsMerging] = useState(false); // Editing tag state const [image, setImage] = useState(); @@ -461,41 +456,27 @@ const TagPage: React.FC = ({ tag, tabKey }) => { function renderMergeButton() { return ( - - - - ... - - - setMergeType("from")} - > - - - ... - - setMergeType("into")} - > - - - ... - - - + ); } function renderMergeDialog() { - if (!tag || !mergeType) return; + if (!tag.id) return; return ( setMergeType(undefined)} - show={!!mergeType} - mergeType={mergeType} + show={isMerging} + onClose={(mergedId) => { + setIsMerging(false); + if (mergedId !== undefined && mergedId !== tag.id) { + // By default, the merge destination is the current tag, but + // the user can change it, in which case we need to redirect. + history.replace(`/tags/${mergedId}`); + } + }} + tags={[tag]} /> ); } diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagMergeDialog.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagMergeDialog.tsx deleted file mode 100644 index d6ed87c41..000000000 --- a/ui/v2.5/src/components/Tags/TagDetails/TagMergeDialog.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { Form, Col, Row } from "react-bootstrap"; -import React, { useState } from "react"; -import * as GQL from "src/core/generated-graphql"; -import { ModalComponent } from "src/components/Shared/Modal"; -import * as FormUtils from "src/utils/form"; -import { useTagsMerge } from "src/core/StashService"; -import { useIntl } from "react-intl"; -import { useToast } from "src/hooks/Toast"; -import { useHistory } from "react-router-dom"; -import { faSignInAlt, faSignOutAlt } from "@fortawesome/free-solid-svg-icons"; -import { Tag, TagSelect } from "../TagSelect"; - -interface ITagMergeModalProps { - show: boolean; - onClose: () => void; - tag: Pick; - mergeType: "from" | "into"; -} - -export const TagMergeModal: React.FC = ({ - show, - onClose, - tag, - mergeType, -}) => { - const [src, setSrc] = useState([]); - const [dest, setDest] = useState(null); - - const [running, setRunning] = useState(false); - - const [mergeTags] = useTagsMerge(); - - const intl = useIntl(); - const Toast = useToast(); - const history = useHistory(); - - const title = intl.formatMessage({ - id: mergeType === "from" ? "actions.merge_from" : "actions.merge_into", - }); - - async function onMerge() { - const source = mergeType === "from" ? src.map((s) => s.id) : [tag.id]; - const destination = mergeType === "from" ? tag.id : dest?.id ?? null; - - if (!destination) return; - - try { - setRunning(true); - const result = await mergeTags({ - variables: { - source, - destination, - }, - }); - if (result.data?.tagsMerge) { - Toast.success(intl.formatMessage({ id: "toast.merged_tags" })); - onClose(); - history.replace(`/tags/${destination}`); - } - } catch (e) { - Toast.error(e); - } finally { - setRunning(false); - } - } - - function canMerge() { - return ( - (mergeType === "from" && src.length > 0) || - (mergeType === "into" && dest !== null) - ); - } - - return ( - onMerge(), - }} - disabled={!canMerge()} - cancel={{ - variant: "secondary", - onClick: () => onClose(), - }} - isRunning={running} - > -
-
- {mergeType === "from" && ( - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "dialogs.merge_tags.source" }), - labelProps: { - column: true, - sm: 3, - xl: 12, - }, - })} - - setSrc(items)} - values={src} - excludeIds={tag?.id ? [tag.id] : []} - menuPortalTarget={document.body} - /> - - - )} - {mergeType === "into" && ( - - {FormUtils.renderLabel({ - title: intl.formatMessage({ - id: "dialogs.merge_tags.destination", - }), - labelProps: { - column: true, - sm: 3, - xl: 12, - }, - })} - - setDest(items[0])} - values={dest ? [dest] : undefined} - excludeIds={tag?.id ? [tag.id] : []} - menuPortalTarget={document.body} - /> - - - )} -
-
-
- ); -}; diff --git a/ui/v2.5/src/components/Tags/TagList.tsx b/ui/v2.5/src/components/Tags/TagList.tsx index eb57435ad..e30f6071b 100644 --- a/ui/v2.5/src/components/Tags/TagList.tsx +++ b/ui/v2.5/src/components/Tags/TagList.tsx @@ -23,6 +23,8 @@ import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog"; import { ExportDialog } from "../Shared/ExportDialog"; import { tagRelationHook } from "../../core/tags"; import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; +import { TagMergeModal } from "./TagMergeDialog"; +import { Tag } from "./TagSelect"; import { TagCardGrid } from "./TagCardGrid"; import { EditTagsDialog } from "./EditTagsDialog"; import { View } from "../List/views"; @@ -64,6 +66,7 @@ export const TagList: React.FC = PatchComponent( const intl = useIntl(); const history = useHistory(); + const [mergeTags, setMergeTags] = useState(undefined); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportAll, setIsExportAll] = useState(false); @@ -73,6 +76,11 @@ export const TagList: React.FC = PatchComponent( text: intl.formatMessage({ id: "actions.view_random" }), onClick: viewRandom, }, + { + text: `${intl.formatMessage({ id: "actions.merge" })}…`, + onClick: merge, + isDisplayed: showWhenSelected, + }, { text: intl.formatMessage({ id: "actions.export" }), onClick: onExport, @@ -118,6 +126,16 @@ export const TagList: React.FC = PatchComponent( } } + async function merge( + result: GQL.FindTagsForListQueryResult, + filter: ListFilterModel, + selectedIds: Set + ) { + const selected = + result.data?.findTags.tags.filter((t) => selectedIds.has(t.id)) ?? []; + setMergeTags(selected); + } + async function onExport() { setIsExportAll(false); setIsExportDialogOpen(true); @@ -171,6 +189,23 @@ export const TagList: React.FC = PatchComponent( selectedIds: Set, onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void ) { + function renderMergeDialog() { + if (mergeTags) { + return ( + { + setMergeTags(undefined); + if (mergedId) { + history.push(`/tags/${mergedId}`); + } + }} + show + /> + ); + } + } + function maybeRenderExportDialog() { if (isExportDialogOpen) { return ( @@ -323,6 +358,7 @@ export const TagList: React.FC = PatchComponent( } return ( <> + {renderMergeDialog()} {maybeRenderExportDialog()} {renderTags()} diff --git a/ui/v2.5/src/components/Tags/TagMergeDialog.tsx b/ui/v2.5/src/components/Tags/TagMergeDialog.tsx new file mode 100644 index 000000000..15b648af5 --- /dev/null +++ b/ui/v2.5/src/components/Tags/TagMergeDialog.tsx @@ -0,0 +1,157 @@ +import { Button, Form, Col, Row } from "react-bootstrap"; +import React, { useEffect, useState } from "react"; +import { Icon } from "../Shared/Icon"; +import { ModalComponent } from "src/components/Shared/Modal"; +import * as FormUtils from "src/utils/form"; +import { useTagsMerge } from "src/core/StashService"; +import { useIntl } from "react-intl"; +import { useToast } from "src/hooks/Toast"; +import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons"; +import { Tag, TagSelect } from "./TagSelect"; + +interface ITagMergeModalProps { + show: boolean; + onClose: (mergedID?: string) => void; + tags: Tag[]; +} + +export const TagMergeModal: React.FC = ({ + show, + onClose, + tags, +}) => { + const [src, setSrc] = useState([]); + const [dest, setDest] = useState(null); + + const [running, setRunning] = useState(false); + + const [mergeTags] = useTagsMerge(); + + const intl = useIntl(); + const Toast = useToast(); + + const title = intl.formatMessage({ + id: "actions.merge", + }); + + useEffect(() => { + if (tags.length > 0) { + setDest(tags[0]); + setSrc(tags.slice(1)); + } + }, [tags]); + + async function onMerge() { + if (!dest) return; + + const source = src.map((s) => s.id); + const destination = dest.id; + + try { + setRunning(true); + const result = await mergeTags({ + variables: { + source, + destination, + }, + }); + if (result.data?.tagsMerge) { + Toast.success(intl.formatMessage({ id: "toast.merged_tags" })); + onClose(dest.id); + } + } catch (e) { + Toast.error(e); + } finally { + setRunning(false); + } + } + + function canMerge() { + return src.length > 0 && dest !== null; + } + + function switchTags() { + if (src.length && dest !== null) { + const newDest = src[0]; + setSrc([...src.slice(1), dest]); + setDest(newDest); + } + } + + return ( + onMerge(), + }} + disabled={!canMerge()} + cancel={{ + variant: "secondary", + onClick: () => onClose(), + }} + isRunning={running} + > +
+
+ + {FormUtils.renderLabel({ + title: intl.formatMessage({ id: "dialogs.merge.source" }), + labelProps: { + column: true, + sm: 3, + xl: 12, + }, + })} + + setSrc(items)} + values={src} + menuPortalTarget={document.body} + /> + + + + + + + {FormUtils.renderLabel({ + title: intl.formatMessage({ + id: "dialogs.merge.destination", + }), + labelProps: { + column: true, + sm: 3, + xl: 12, + }, + })} + + setDest(items[0])} + values={dest ? [dest] : undefined} + menuPortalTarget={document.body} + /> + + +
+
+
+ ); +}; diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index c4f3d4732..6aaf17125 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -352,6 +352,14 @@ export const queryFindPerformers = (filter: ListFilterModel) => }, }); +export const queryFindPerformersByID = (performerIDs: number[]) => + client.query({ + query: GQL.FindPerformersDocument, + variables: { + performer_ids: performerIDs, + }, + }); + export const queryFindPerformersByIDForSelect = (performerIDs: string[]) => client.query({ query: GQL.FindPerformersForSelectDocument, @@ -420,6 +428,12 @@ export const useFindTag = (id: string) => { return GQL.useFindTagQuery({ variables: { id }, skip }); }; +export const queryFindTag = (id: string) => + client.query({ + query: GQL.FindTagDocument, + variables: { id }, + }); + export const useFindTags = (filter?: ListFilterModel) => GQL.useFindTagsQuery({ skip: filter === undefined, @@ -903,6 +917,10 @@ export const mutateSceneMerge = ( deleteObject(cache, obj, GQL.FindSceneDocument); } + cache.evict({ + id: cache.identify({ __typename: "Scene", id: destination }), + }); + evictTypeFields(cache, sceneMutationImpactedTypeFields); evictQueries(cache, [ ...sceneMutationImpactedQueries, @@ -1844,7 +1862,6 @@ export const usePerformerDestroy = () => }); evictQueries(cache, [ ...performerMutationImpactedQueries, - GQL.FindPerformersDocument, // appears with GQL.FindGroupsDocument, // filter by performers GQL.FindSceneMarkersDocument, // filter by performers ]); @@ -1884,13 +1901,48 @@ export const usePerformersDestroy = ( }); evictQueries(cache, [ ...performerMutationImpactedQueries, - GQL.FindPerformersDocument, // appears with GQL.FindGroupsDocument, // filter by performers GQL.FindSceneMarkersDocument, // filter by performers ]); }, }); +export const mutatePerformerMerge = ( + destination: string, + source: string[], + values: GQL.PerformerUpdateInput +) => + client.mutate({ + mutation: GQL.PerformerMergeDocument, + variables: { + input: { + source, + destination, + values, + }, + }, + update(cache, result) { + if (!result.data?.performerMerge) return; + + for (const id of source) { + const obj = { __typename: "Performer", id }; + deleteObject(cache, obj, GQL.FindPerformerDocument); + } + + cache.evict({ + id: cache.identify({ __typename: "Performer", id: destination }), + }); + + evictTypeFields(cache, performerMutationImpactedTypeFields); + evictQueries(cache, [ + ...performerMutationImpactedQueries, + GQL.FindGroupsDocument, // filter by performers + GQL.FindSceneMarkersDocument, // filter by performers + GQL.StatsDocument, // performer count + ]); + }, + }); + const studioMutationImpactedTypeFields = { Studio: ["child_studios"], }; @@ -1999,6 +2051,8 @@ const tagMutationImpactedTypeFields = { }; const tagMutationImpactedQueries = [ + GQL.FindGroupsDocument, // filter by tags + GQL.FindSceneMarkersDocument, // filter by tags GQL.FindScenesDocument, // filter by tags GQL.FindImagesDocument, // filter by tags GQL.FindGalleriesDocument, // filter by tags @@ -2106,16 +2160,14 @@ export const useTagsMerge = () => deleteObject(cache, obj, GQL.FindTagDocument); } - updateStats(cache, "tag_count", -source.length); + cache.evict({ + id: cache.identify({ __typename: "Tag", id: destination }), + }); - const obj = { __typename: "Tag", id: destination }; - evictTypeFields( - cache, - tagMutationImpactedTypeFields, - cache.identify(obj) // don't evict destination tag - ); - - evictQueries(cache, tagMutationImpactedQueries); + evictQueries(cache, [ + ...tagMutationImpactedQueries, + GQL.StatsDocument, // tag count + ]); }, }); diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 158164c8d..426440fad 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -75,8 +75,6 @@ "logout": "Log out", "make_primary": "Make Primary", "merge": "Merge", - "merge_from": "Merge from", - "merge_into": "Merge into", "migrate_blobs": "Migrate Blobs", "migrate_scene_screenshots": "Migrate Scene Screenshots", "next_action": "Next", @@ -972,10 +970,6 @@ "empty_results": "Destination field values will be unchanged.", "source": "Source" }, - "merge_tags": { - "destination": "Destination", - "source": "Source" - }, "overwrite_filter_warning": "Saved filter \"{entityName}\" will be overwritten.", "performers_found": "{count} performers found", "reassign_entity_title": "{count, plural, one {Reassign {singularEntity}} other {Reassign {pluralEntity}}}", @@ -1565,6 +1559,7 @@ "delete_past_tense": "Deleted {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "generating_screenshot": "Generating screenshot…", "image_index_too_large": "Error: Image index is larger than the number of images in the Gallery", + "merged_performers": "Merged performers", "merged_scenes": "Merged scenes", "merged_tags": "Merged tags", "reassign_past_tense": "File reassigned", From 66ceceeaf1cb676ea15cf232706cbea37f986dfa Mon Sep 17 00:00:00 2001 From: CJ <72030708+cj12312021@users.noreply.github.com> Date: Sun, 4 Jan 2026 23:10:52 -0600 Subject: [PATCH 004/177] feat(dlna): add activity tracking for DLNA playback (#6407) Adds time-based activity tracking for scenes played via DLNA, enabling play count, play duration, and resume time tracking similar to the web frontend. Key features: - Uses existing 'trackActivity' UI setting (no new config needed) - Time-based tracking (elapsed session time / video duration) - 5-minute session timeout to handle aggressive client buffering - Minimum thresholds before saving (1% watched or 5 seconds) - Respects minimumPlayPercent setting for play count increment Implementation: - New ActivityTracker in internal/dlna/activity.go - Session management with automatic expiration - Integration via DLNA service initialization Limitations: - Cannot detect actual playback position (only elapsed time) - Cannot detect seeking or pause state - Designed for upstream compatibility (no complex dependencies) --- internal/dlna/activity.go | 341 ++++++++++++++++++++++ internal/dlna/activity_test.go | 466 ++++++++++++++++++++++++++++++ internal/dlna/dms.go | 19 ++ internal/dlna/service.go | 43 ++- internal/manager/config/config.go | 36 +++ internal/manager/init.go | 2 +- 6 files changed, 900 insertions(+), 7 deletions(-) create mode 100644 internal/dlna/activity.go create mode 100644 internal/dlna/activity_test.go diff --git a/internal/dlna/activity.go b/internal/dlna/activity.go new file mode 100644 index 000000000..34f0081d7 --- /dev/null +++ b/internal/dlna/activity.go @@ -0,0 +1,341 @@ +package dlna + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/txn" +) + +const ( + // DefaultSessionTimeout is the time after which a session is considered complete + // if no new requests are received. + // This is set high (5 minutes) because DLNA clients buffer aggressively and may not + // send any HTTP requests for extended periods while the user is still watching. + DefaultSessionTimeout = 5 * time.Minute + + // monitorInterval is how often we check for expired sessions. + monitorInterval = 10 * time.Second +) + +// ActivityConfig provides configuration options for DLNA activity tracking. +type ActivityConfig interface { + // GetDLNAActivityTrackingEnabled returns true if activity tracking should be enabled. + // If not implemented, defaults to true. + GetDLNAActivityTrackingEnabled() bool + + // GetMinimumPlayPercent returns the minimum percentage of a video that must be + // watched before incrementing the play count. Uses UI setting if available. + GetMinimumPlayPercent() int +} + +// SceneActivityWriter provides methods for saving scene activity. +type SceneActivityWriter interface { + SaveActivity(ctx context.Context, sceneID int, resumeTime *float64, playDuration *float64) (bool, error) + AddViews(ctx context.Context, sceneID int, dates []time.Time) ([]time.Time, error) +} + +// streamSession represents an active DLNA streaming session. +type streamSession struct { + SceneID int + ClientIP string + StartTime time.Time + LastActivity time.Time + VideoDuration float64 + PlayCountAdded bool +} + +// sessionKey generates a unique key for a session based on client IP and scene ID. +func sessionKey(clientIP string, sceneID int) string { + return fmt.Sprintf("%s:%d", clientIP, sceneID) +} + +// percentWatched calculates the estimated percentage of video watched. +// Uses a time-based approach since DLNA clients buffer aggressively and byte +// positions don't correlate with actual playback position. +// +// The key insight: you cannot have watched more of the video than time has elapsed. +// If the video is 30 minutes and only 1 minute has passed, maximum watched is ~3.3%. +func (s *streamSession) percentWatched() float64 { + if s.VideoDuration <= 0 { + return 0 + } + + // Calculate elapsed time from session start to last activity + elapsed := s.LastActivity.Sub(s.StartTime).Seconds() + if elapsed <= 0 { + return 0 + } + + // Maximum possible percent is based on elapsed time + // You can't watch more of the video than time has passed + timeBasedPercent := (elapsed / s.VideoDuration) * 100 + + // Cap at 100% + if timeBasedPercent > 100 { + return 100 + } + + return timeBasedPercent +} + +// estimatedPlayDuration returns the estimated play duration in seconds. +// Uses elapsed time from session start to last activity, capped by video duration. +func (s *streamSession) estimatedPlayDuration() float64 { + elapsed := s.LastActivity.Sub(s.StartTime).Seconds() + if s.VideoDuration > 0 && elapsed > s.VideoDuration { + return s.VideoDuration + } + return elapsed +} + +// estimatedResumeTime calculates the estimated resume time based on elapsed time. +// Since DLNA clients buffer aggressively, byte positions don't correlate with playback. +// Instead, we estimate based on how long the session has been active. +// Returns the time in seconds, or 0 if the video is nearly complete (>=98%). +func (s *streamSession) estimatedResumeTime() float64 { + if s.VideoDuration <= 0 { + return 0 + } + + // Calculate elapsed time from session start + elapsed := s.LastActivity.Sub(s.StartTime).Seconds() + if elapsed <= 0 { + return 0 + } + + // If elapsed time exceeds 98% of video duration, reset resume time (matches frontend behavior) + if elapsed >= s.VideoDuration*0.98 { + return 0 + } + + // Resume time is approximately where the user was watching + // Capped by video duration + if elapsed > s.VideoDuration { + elapsed = s.VideoDuration + } + + return elapsed +} + +// ActivityTracker tracks DLNA streaming activity and saves it to the database. +type ActivityTracker struct { + txnManager txn.Manager + sceneWriter SceneActivityWriter + config ActivityConfig + sessionTimeout time.Duration + + sessions map[string]*streamSession + mutex sync.RWMutex + + ctx context.Context + cancelFunc context.CancelFunc + wg sync.WaitGroup +} + +// NewActivityTracker creates a new ActivityTracker. +func NewActivityTracker( + txnManager txn.Manager, + sceneWriter SceneActivityWriter, + config ActivityConfig, +) *ActivityTracker { + ctx, cancel := context.WithCancel(context.Background()) + + tracker := &ActivityTracker{ + txnManager: txnManager, + sceneWriter: sceneWriter, + config: config, + sessionTimeout: DefaultSessionTimeout, + sessions: make(map[string]*streamSession), + ctx: ctx, + cancelFunc: cancel, + } + + // Start the session monitor goroutine + tracker.wg.Add(1) + go tracker.monitorSessions() + + return tracker +} + +// Stop stops the activity tracker and processes any remaining sessions. +func (t *ActivityTracker) Stop() { + t.cancelFunc() + t.wg.Wait() + + // Process any remaining sessions + t.mutex.Lock() + sessions := make([]*streamSession, 0, len(t.sessions)) + for _, session := range t.sessions { + sessions = append(sessions, session) + } + t.sessions = make(map[string]*streamSession) + t.mutex.Unlock() + + for _, session := range sessions { + t.processCompletedSession(session) + } +} + +// RecordRequest records a streaming request for activity tracking. +// Each request updates the session's LastActivity time, which is used for +// time-based tracking of watch progress. +func (t *ActivityTracker) RecordRequest(sceneID int, clientIP string, videoDuration float64) { + if !t.isEnabled() { + return + } + + key := sessionKey(clientIP, sceneID) + now := time.Now() + + t.mutex.Lock() + defer t.mutex.Unlock() + + session, exists := t.sessions[key] + if !exists { + session = &streamSession{ + SceneID: sceneID, + ClientIP: clientIP, + StartTime: now, + VideoDuration: videoDuration, + } + t.sessions[key] = session + logger.Debugf("[DLNA Activity] New session started: scene=%d, client=%s", sceneID, clientIP) + } + + session.LastActivity = now +} + +// monitorSessions periodically checks for expired sessions and processes them. +func (t *ActivityTracker) monitorSessions() { + defer t.wg.Done() + + ticker := time.NewTicker(monitorInterval) + defer ticker.Stop() + + for { + select { + case <-t.ctx.Done(): + return + case <-ticker.C: + t.processExpiredSessions() + } + } +} + +// processExpiredSessions finds and processes sessions that have timed out. +func (t *ActivityTracker) processExpiredSessions() { + now := time.Now() + var expiredSessions []*streamSession + + t.mutex.Lock() + for key, session := range t.sessions { + timeSinceStart := now.Sub(session.StartTime) + timeSinceActivity := now.Sub(session.LastActivity) + + // Must have no HTTP activity for the full timeout period + if timeSinceActivity <= t.sessionTimeout { + continue + } + + // DLNA clients buffer aggressively - they fetch most/all of the video quickly, + // then play from cache with NO further HTTP requests. + // + // Two scenarios: + // 1. User watched the whole video: timeSinceStart >= videoDuration + // -> Set LastActivity to when timeout began (they finished watching) + // 2. User stopped early: timeSinceStart < videoDuration + // -> Keep LastActivity as-is (best estimate of when they stopped) + + videoDuration := time.Duration(session.VideoDuration) * time.Second + if timeSinceStart >= videoDuration && videoDuration > 0 { + // User likely watched the whole video, then it timed out + // Estimate they watched until the timeout period started + session.LastActivity = now.Add(-t.sessionTimeout) + } + // else: User stopped early - LastActivity is already our best estimate + + expiredSessions = append(expiredSessions, session) + delete(t.sessions, key) + } + t.mutex.Unlock() + + for _, session := range expiredSessions { + t.processCompletedSession(session) + } +} + +// processCompletedSession saves activity data for a completed streaming session. +func (t *ActivityTracker) processCompletedSession(session *streamSession) { + percentWatched := session.percentWatched() + playDuration := session.estimatedPlayDuration() + resumeTime := session.estimatedResumeTime() + + logger.Debugf("[DLNA Activity] Session completed: scene=%d, client=%s, duration=%.1fs, startTime=%s, lastActivity=%s, percent=%.1f%%, duration=%.1fs, resume=%.1fs", + session.SceneID, session.ClientIP, session.VideoDuration, session.StartTime.String(), session.LastActivity.String(), percentWatched, playDuration, resumeTime) + + // Only save if there was meaningful activity (at least 1% watched or 5 seconds) + if percentWatched < 1 && playDuration < 5 { + logger.Debugf("[DLNA Activity] Session too short, skipping save") + return + } + + // Skip DB operations if txnManager is nil (for testing) + if t.txnManager == nil { + logger.Debugf("[DLNA Activity] No transaction manager, skipping DB save") + return + } + + ctx := context.Background() + + // Save activity (resume time and play duration) + if playDuration > 0 || resumeTime > 0 { + var resumeTimePtr *float64 + if resumeTime > 0 { + resumeTimePtr = &resumeTime + } + + if err := txn.WithTxn(ctx, t.txnManager, func(ctx context.Context) error { + _, err := t.sceneWriter.SaveActivity(ctx, session.SceneID, resumeTimePtr, &playDuration) + return err + }); err != nil { + logger.Warnf("[DLNA Activity] Failed to save activity for scene %d: %v", session.SceneID, err) + } + } + + // Increment play count if threshold met + if !session.PlayCountAdded { + minPercent := t.getMinimumPlayPercent() + if percentWatched >= float64(minPercent) { + if err := txn.WithTxn(ctx, t.txnManager, func(ctx context.Context) error { + _, err := t.sceneWriter.AddViews(ctx, session.SceneID, []time.Time{time.Now()}) + return err + }); err != nil { + logger.Warnf("[DLNA Activity] Failed to increment play count for scene %d: %v", session.SceneID, err) + } else { + logger.Debugf("[DLNA Activity] Incremented play count for scene %d (%.1f%% watched)", + session.SceneID, percentWatched) + session.PlayCountAdded = true + } + } + } +} + +// isEnabled returns true if activity tracking is enabled. +func (t *ActivityTracker) isEnabled() bool { + if t.config == nil { + return true // Default to enabled + } + return t.config.GetDLNAActivityTrackingEnabled() +} + +// getMinimumPlayPercent returns the minimum play percentage for incrementing play count. +func (t *ActivityTracker) getMinimumPlayPercent() int { + if t.config == nil { + return 0 // Default: any play increments count (matches frontend default) + } + return t.config.GetMinimumPlayPercent() +} diff --git a/internal/dlna/activity_test.go b/internal/dlna/activity_test.go new file mode 100644 index 000000000..3c4d890ba --- /dev/null +++ b/internal/dlna/activity_test.go @@ -0,0 +1,466 @@ +package dlna + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// mockSceneWriter is a mock implementation of SceneActivityWriter +type mockSceneWriter struct { + mu sync.Mutex + saveActivityCalls []saveActivityCall + addViewsCalls []addViewsCall +} + +type saveActivityCall struct { + sceneID int + resumeTime *float64 + playDuration *float64 +} + +type addViewsCall struct { + sceneID int + dates []time.Time +} + +func (m *mockSceneWriter) SaveActivity(_ context.Context, sceneID int, resumeTime *float64, playDuration *float64) (bool, error) { + m.mu.Lock() + m.saveActivityCalls = append(m.saveActivityCalls, saveActivityCall{ + sceneID: sceneID, + resumeTime: resumeTime, + playDuration: playDuration, + }) + m.mu.Unlock() + return true, nil +} + +func (m *mockSceneWriter) AddViews(_ context.Context, sceneID int, dates []time.Time) ([]time.Time, error) { + m.mu.Lock() + m.addViewsCalls = append(m.addViewsCalls, addViewsCall{ + sceneID: sceneID, + dates: dates, + }) + m.mu.Unlock() + return dates, nil +} + +// mockConfig is a mock implementation of ActivityConfig +type mockConfig struct { + enabled bool + minPlayPercent int +} + +func (c *mockConfig) GetDLNAActivityTrackingEnabled() bool { + return c.enabled +} + +func (c *mockConfig) GetMinimumPlayPercent() int { + return c.minPlayPercent +} + +func TestStreamSession_PercentWatched(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + startTime time.Time + lastActivity time.Time + videoDuration float64 + expected float64 + }{ + { + name: "no video duration", + startTime: now.Add(-60 * time.Second), + lastActivity: now, + videoDuration: 0, + expected: 0, + }, + { + name: "half watched", + startTime: now.Add(-60 * time.Second), + lastActivity: now, + videoDuration: 120.0, // 2 minutes, watched for 1 minute = 50% + expected: 50.0, + }, + { + name: "fully watched", + startTime: now.Add(-120 * time.Second), + lastActivity: now, + videoDuration: 120.0, // 2 minutes, watched for 2 minutes = 100% + expected: 100.0, + }, + { + name: "quarter watched", + startTime: now.Add(-30 * time.Second), + lastActivity: now, + videoDuration: 120.0, // 2 minutes, watched for 30 seconds = 25% + expected: 25.0, + }, + { + name: "elapsed exceeds duration - capped at 100%", + startTime: now.Add(-180 * time.Second), + lastActivity: now, + videoDuration: 120.0, // 2 minutes, but 3 minutes elapsed = capped at 100% + expected: 100.0, + }, + { + name: "no elapsed time", + startTime: now, + lastActivity: now, + videoDuration: 120.0, + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + session := &streamSession{ + StartTime: tt.startTime, + LastActivity: tt.lastActivity, + VideoDuration: tt.videoDuration, + } + result := session.percentWatched() + assert.InDelta(t, tt.expected, result, 0.01) + }) + } +} + +func TestStreamSession_EstimatedPlayDuration(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + startTime time.Time + lastActivity time.Time + videoDuration float64 + expected float64 + }{ + { + name: "elapsed less than duration", + startTime: now.Add(-30 * time.Second), + lastActivity: now, + videoDuration: 120, + expected: 30.0, + }, + { + name: "elapsed exceeds duration - capped", + startTime: now.Add(-180 * time.Second), + lastActivity: now, + videoDuration: 120, + expected: 120.0, + }, + { + name: "no duration limit", + startTime: now.Add(-300 * time.Second), + lastActivity: now, + videoDuration: 0, + expected: 300.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + session := &streamSession{ + StartTime: tt.startTime, + LastActivity: tt.lastActivity, + VideoDuration: tt.videoDuration, + } + result := session.estimatedPlayDuration() + assert.InDelta(t, tt.expected, result, 1.0) // Allow 1 second tolerance + }) + } +} + +func TestStreamSession_EstimatedResumeTime(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + startTime time.Time + lastActivity time.Time + videoDuration float64 + expected float64 + }{ + { + name: "no elapsed time", + startTime: now, + lastActivity: now, + videoDuration: 120.0, + expected: 0, + }, + { + name: "half way through", + startTime: now.Add(-60 * time.Second), + lastActivity: now, + videoDuration: 120.0, // 2 minutes, watched for 1 minute = resume at 60s + expected: 60.0, + }, + { + name: "quarter way through", + startTime: now.Add(-30 * time.Second), + lastActivity: now, + videoDuration: 120.0, // 2 minutes, watched for 30 seconds = resume at 30s + expected: 30.0, + }, + { + name: "98% complete - should reset to 0", + startTime: now.Add(-118 * time.Second), + lastActivity: now, + videoDuration: 120.0, // 98.3% elapsed, should reset + expected: 0, + }, + { + name: "100% complete - should reset to 0", + startTime: now.Add(-120 * time.Second), + lastActivity: now, + videoDuration: 120.0, + expected: 0, + }, + { + name: "elapsed exceeds duration - capped and reset to 0", + startTime: now.Add(-180 * time.Second), + lastActivity: now, + videoDuration: 120.0, // 150% elapsed, capped at 100%, reset to 0 + expected: 0, + }, + { + name: "no video duration", + startTime: now.Add(-60 * time.Second), + lastActivity: now, + videoDuration: 0, + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + session := &streamSession{ + StartTime: tt.startTime, + LastActivity: tt.lastActivity, + VideoDuration: tt.videoDuration, + } + result := session.estimatedResumeTime() + assert.InDelta(t, tt.expected, result, 1.0) // Allow 1 second tolerance + }) + } +} + +func TestSessionKey(t *testing.T) { + key := sessionKey("192.168.1.100", 42) + assert.Equal(t, "192.168.1.100:42", key) +} + +func TestActivityTracker_RecordRequest(t *testing.T) { + config := &mockConfig{enabled: true, minPlayPercent: 50} + + // Create tracker without starting the goroutine (for unit testing) + tracker := &ActivityTracker{ + txnManager: nil, // Don't need DB for this test + sceneWriter: nil, + config: config, + sessionTimeout: DefaultSessionTimeout, + sessions: make(map[string]*streamSession), + } + + // Record first request - should create new session + tracker.RecordRequest(42, "192.168.1.100", 120.0) + + tracker.mutex.RLock() + session := tracker.sessions["192.168.1.100:42"] + tracker.mutex.RUnlock() + + assert.NotNil(t, session) + assert.Equal(t, 42, session.SceneID) + assert.Equal(t, "192.168.1.100", session.ClientIP) + assert.Equal(t, 120.0, session.VideoDuration) + assert.False(t, session.StartTime.IsZero()) + assert.False(t, session.LastActivity.IsZero()) + + // Record second request - should update LastActivity + firstActivity := session.LastActivity + time.Sleep(10 * time.Millisecond) + tracker.RecordRequest(42, "192.168.1.100", 120.0) + + tracker.mutex.RLock() + session = tracker.sessions["192.168.1.100:42"] + tracker.mutex.RUnlock() + + assert.True(t, session.LastActivity.After(firstActivity)) +} + +func TestActivityTracker_DisabledTracking(t *testing.T) { + config := &mockConfig{enabled: false, minPlayPercent: 50} + + // Create tracker without starting the goroutine (for unit testing) + tracker := &ActivityTracker{ + txnManager: nil, + sceneWriter: nil, + config: config, + sessionTimeout: DefaultSessionTimeout, + sessions: make(map[string]*streamSession), + } + + // Record request - should be ignored when tracking is disabled + tracker.RecordRequest(42, "192.168.1.100", 120.0) + + tracker.mutex.RLock() + sessionCount := len(tracker.sessions) + tracker.mutex.RUnlock() + + assert.Equal(t, 0, sessionCount) +} + +func TestActivityTracker_SessionExpiration(t *testing.T) { + // For this test, we'll test the session expiration logic directly + // without the full transaction manager integration + + sceneWriter := &mockSceneWriter{} + config := &mockConfig{enabled: true, minPlayPercent: 10} + + // Create a tracker with nil txnManager - we'll test processCompletedSession separately + // Here we just verify the session management logic + tracker := &ActivityTracker{ + txnManager: nil, // Skip DB calls for this test + sceneWriter: sceneWriter, + config: config, + sessionTimeout: 100 * time.Millisecond, + sessions: make(map[string]*streamSession), + } + + // Manually add a session + // Use a short video duration (1 second) so the test can verify expiration quickly. + now := time.Now() + tracker.sessions["192.168.1.100:42"] = &streamSession{ + SceneID: 42, + ClientIP: "192.168.1.100", + StartTime: now.Add(-5 * time.Second), // Started 5 seconds ago + LastActivity: now.Add(-200 * time.Millisecond), // Last activity 200ms ago (> 100ms timeout) + VideoDuration: 1.0, // Short video so timeSinceStart > videoDuration + } + + // Verify session exists + assert.Len(t, tracker.sessions, 1) + + // Process expired sessions - this will try to save activity but txnManager is nil + // so it will skip the DB calls but still remove the session + tracker.processExpiredSessions() + + // Verify session was removed (even though DB calls were skipped) + assert.Len(t, tracker.sessions, 0) +} + +func TestActivityTracker_SessionExpiration_StoppedEarly(t *testing.T) { + // Test that sessions expire when user stops watching early (before video ends) + // This was a bug where sessions wouldn't expire until video duration passed + + config := &mockConfig{enabled: true, minPlayPercent: 10} + tracker := &ActivityTracker{ + txnManager: nil, + sceneWriter: nil, + config: config, + sessionTimeout: 100 * time.Millisecond, + sessions: make(map[string]*streamSession), + } + + // User started watching a 30-minute video but stopped after 5 seconds + now := time.Now() + tracker.sessions["192.168.1.100:42"] = &streamSession{ + SceneID: 42, + ClientIP: "192.168.1.100", + StartTime: now.Add(-5 * time.Second), // Started 5 seconds ago + LastActivity: now.Add(-200 * time.Millisecond), // Last activity 200ms ago (> 100ms timeout) + VideoDuration: 1800.0, // 30 minute video - much longer than elapsed time + } + + assert.Len(t, tracker.sessions, 1) + + // Session should expire because timeSinceActivity > timeout + // Even though the video is 30 minutes and only 5 seconds have passed + tracker.processExpiredSessions() + + // Verify session was expired + assert.Len(t, tracker.sessions, 0, "Session should expire when user stops early, not wait for video duration") +} + +func TestActivityTracker_MinimumPlayPercentThreshold(t *testing.T) { + // Test the threshold logic without full transaction integration + config := &mockConfig{enabled: true, minPlayPercent: 75} // High threshold + + tracker := &ActivityTracker{ + txnManager: nil, + sceneWriter: nil, + config: config, + sessionTimeout: 50 * time.Millisecond, + sessions: make(map[string]*streamSession), + } + + // Test that getMinimumPlayPercent returns the configured value + assert.Equal(t, 75, tracker.getMinimumPlayPercent()) + + // Create a session with 30% watched (36 seconds of a 120 second video) + now := time.Now() + session := &streamSession{ + SceneID: 42, + StartTime: now.Add(-36 * time.Second), + LastActivity: now, + VideoDuration: 120.0, + } + + // 30% is below 75% threshold + percentWatched := session.percentWatched() + assert.InDelta(t, 30.0, percentWatched, 0.1) + assert.False(t, percentWatched >= float64(tracker.getMinimumPlayPercent())) +} + +func TestActivityTracker_MultipleSessions(t *testing.T) { + config := &mockConfig{enabled: true, minPlayPercent: 50} + + // Create tracker without starting the goroutine (for unit testing) + tracker := &ActivityTracker{ + txnManager: nil, + sceneWriter: nil, + config: config, + sessionTimeout: DefaultSessionTimeout, + sessions: make(map[string]*streamSession), + } + + // Different clients watching same scene + tracker.RecordRequest(42, "192.168.1.100", 120.0) + tracker.RecordRequest(42, "192.168.1.101", 120.0) + + // Same client watching different scenes + tracker.RecordRequest(43, "192.168.1.100", 180.0) + + tracker.mutex.RLock() + assert.Len(t, tracker.sessions, 3) + tracker.mutex.RUnlock() +} + +func TestActivityTracker_ShortSessionIgnored(t *testing.T) { + // Test that short sessions are ignored + // Create a session with only ~0.8% watched (1 second of a 120 second video) + now := time.Now() + session := &streamSession{ + SceneID: 42, + ClientIP: "192.168.1.100", + StartTime: now.Add(-1 * time.Second), // Only 1 second + LastActivity: now, + VideoDuration: 120.0, // 2 minutes + } + + // Verify percent watched is below threshold (1s / 120s = 0.83%) + assert.InDelta(t, 0.83, session.percentWatched(), 0.1) + + // Verify play duration is short + assert.InDelta(t, 1.0, session.estimatedPlayDuration(), 0.5) + + // Both are below the minimum thresholds (1% and 5 seconds) + percentWatched := session.percentWatched() + playDuration := session.estimatedPlayDuration() + shouldSkip := percentWatched < 1 && playDuration < 5 + assert.True(t, shouldSkip, "Short session should be skipped") +} diff --git a/internal/dlna/dms.go b/internal/dlna/dms.go index 3b27d607b..d68705f74 100644 --- a/internal/dlna/dms.go +++ b/internal/dlna/dms.go @@ -278,6 +278,7 @@ type Server struct { repository Repository sceneServer sceneServer ipWhitelistManager *ipWhitelistManager + activityTracker *ActivityTracker VideoSortOrder string subscribeLock sync.Mutex @@ -596,6 +597,7 @@ func (me *Server) initMux(mux *http.ServeMux) { mux.HandleFunc(resPath, func(w http.ResponseWriter, r *http.Request) { sceneId := r.URL.Query().Get("scene") var scene *models.Scene + var videoDuration float64 repo := me.repository err := repo.WithReadTxn(r.Context(), func(ctx context.Context) error { sceneIdInt, err := strconv.Atoi(sceneId) @@ -603,6 +605,15 @@ func (me *Server) initMux(mux *http.ServeMux) { return nil } scene, _ = repo.SceneFinder.Find(ctx, sceneIdInt) + if scene != nil { + // Load primary file to get duration for activity tracking + if err := scene.LoadPrimaryFile(ctx, repo.FileGetter); err != nil { + logger.Debugf("failed to load primary file for scene %d: %v", sceneIdInt, err) + } + if f := scene.Files.Primary(); f != nil { + videoDuration = f.Duration + } + } return nil }) if err != nil { @@ -615,6 +626,14 @@ func (me *Server) initMux(mux *http.ServeMux) { w.Header().Set("transferMode.dlna.org", "Streaming") w.Header().Set("contentFeatures.dlna.org", "DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01500000000000000000000000000000") + + // Track activity - uses time-based tracking, updated on each request + if me.activityTracker != nil { + sceneIdInt, _ := strconv.Atoi(sceneId) + clientIP, _, _ := net.SplitHostPort(r.RemoteAddr) + me.activityTracker.RecordRequest(sceneIdInt, clientIP, videoDuration) + } + me.sceneServer.StreamSceneDirect(scene, w, r) }) mux.HandleFunc(rootDescPath, func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/dlna/service.go b/internal/dlna/service.go index 6ef825bac..98715b1e6 100644 --- a/internal/dlna/service.go +++ b/internal/dlna/service.go @@ -77,13 +77,29 @@ type Config interface { GetDLNADefaultIPWhitelist() []string GetVideoSortOrder() string GetDLNAPortAsString() string + GetDLNAActivityTrackingEnabled() bool +} + +// activityConfig wraps Config to implement ActivityConfig. +type activityConfig struct { + config Config + minPlayPercent int // cached from UI config +} + +func (c *activityConfig) GetDLNAActivityTrackingEnabled() bool { + return c.config.GetDLNAActivityTrackingEnabled() +} + +func (c *activityConfig) GetMinimumPlayPercent() int { + return c.minPlayPercent } type Service struct { - repository Repository - config Config - sceneServer sceneServer - ipWhitelistMgr *ipWhitelistManager + repository Repository + config Config + sceneServer sceneServer + ipWhitelistMgr *ipWhitelistManager + activityTracker *ActivityTracker server *Server running bool @@ -155,6 +171,7 @@ func (s *Service) init() error { repository: s.repository, sceneServer: s.sceneServer, ipWhitelistManager: s.ipWhitelistMgr, + activityTracker: s.activityTracker, Interfaces: interfaces, HTTPConn: func() net.Listener { conn, err := net.Listen("tcp", dmsConfig.Http) @@ -215,7 +232,14 @@ func (s *Service) init() error { // } // NewService initialises and returns a new DLNA service. -func NewService(repo Repository, cfg Config, sceneServer sceneServer) *Service { +// The sceneWriter parameter should implement SceneActivityWriter (typically models.SceneReaderWriter). +// The minPlayPercent parameter is the minimum percentage of video that must be played to increment play count. +func NewService(repo Repository, cfg Config, sceneServer sceneServer, sceneWriter SceneActivityWriter, minPlayPercent int) *Service { + activityCfg := &activityConfig{ + config: cfg, + minPlayPercent: minPlayPercent, + } + ret := &Service{ repository: repo, sceneServer: sceneServer, @@ -223,7 +247,8 @@ func NewService(repo Repository, cfg Config, sceneServer sceneServer) *Service { ipWhitelistMgr: &ipWhitelistManager{ config: cfg, }, - mutex: sync.Mutex{}, + activityTracker: NewActivityTracker(repo.TxnManager, sceneWriter, activityCfg), + mutex: sync.Mutex{}, } return ret @@ -283,6 +308,12 @@ func (s *Service) Stop(duration *time.Duration) { if s.running { logger.Info("Stopping DLNA") + + // Stop activity tracker first to process any pending sessions + if s.activityTracker != nil { + s.activityTracker.Stop() + } + err := s.server.Close() if err != nil { logger.Error(err) diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 2cc3994f4..35534f119 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -1323,6 +1323,26 @@ func (i *Config) GetUIConfiguration() map[string]interface{} { return i.forKey(UI).Cut(UI).Raw() } +// GetMinimumPlayPercent returns the minimum percentage of a video that must be +// watched before incrementing the play count. Returns 0 if not configured. +func (i *Config) GetMinimumPlayPercent() int { + uiConfig := i.GetUIConfiguration() + if uiConfig == nil { + return 0 + } + if val, ok := uiConfig["minimumPlayPercent"]; ok { + switch v := val.(type) { + case int: + return v + case float64: + return int(v) + case int64: + return int(v) + } + } + return 0 +} + func (i *Config) SetUIConfiguration(v map[string]interface{}) { i.Lock() defer i.Unlock() @@ -1615,6 +1635,22 @@ func (i *Config) GetDLNAPortAsString() string { return ":" + strconv.Itoa(i.GetDLNAPort()) } +// GetDLNAActivityTrackingEnabled returns true if DLNA activity tracking is enabled. +// This uses the same "trackActivity" UI setting that controls frontend play history tracking. +// When enabled, scenes played via DLNA will have their play count and duration tracked. +func (i *Config) GetDLNAActivityTrackingEnabled() bool { + uiConfig := i.GetUIConfiguration() + if uiConfig == nil { + return true // Default to enabled + } + if val, ok := uiConfig["trackActivity"]; ok { + if v, ok := val.(bool); ok { + return v + } + } + return true // Default to enabled +} + // GetVideoSortOrder returns the sort order to display videos. If // empty, videos will be sorted by titles. func (i *Config) GetVideoSortOrder() string { diff --git a/internal/manager/init.go b/internal/manager/init.go index b388bd15c..b4af5eab7 100644 --- a/internal/manager/init.go +++ b/internal/manager/init.go @@ -78,7 +78,7 @@ func Initialize(cfg *config.Config, l *log.Logger) (*Manager, error) { } dlnaRepository := dlna.NewRepository(repo) - dlnaService := dlna.NewService(dlnaRepository, cfg, sceneServer) + dlnaService := dlna.NewService(dlnaRepository, cfg, sceneServer, repo.Scene, cfg.GetMinimumPlayPercent()) mgr := &Manager{ Config: cfg, From 39d3e63cbfa346d5376effd4e62350694b4f6ffd Mon Sep 17 00:00:00 2001 From: bob12224 Date: Sun, 4 Jan 2026 21:11:42 -0800 Subject: [PATCH 005/177] Remove hiding the age in SFW mode (#6450) --- ui/v2.5/src/sfw-mode.scss | 5 ----- 1 file changed, 5 deletions(-) diff --git a/ui/v2.5/src/sfw-mode.scss b/ui/v2.5/src/sfw-mode.scss index 9883afb4b..5ba449433 100644 --- a/ui/v2.5/src/sfw-mode.scss +++ b/ui/v2.5/src/sfw-mode.scss @@ -83,9 +83,4 @@ display: none; } } - - // hide performer age on performer cards - .performer-card__age { - display: none; - } } From 56822dbdc51a0a92c24ea261e1171a20822f481a Mon Sep 17 00:00:00 2001 From: ayaya <62456287+a15355447898a@users.noreply.github.com> Date: Mon, 5 Jan 2026 13:12:44 +0800 Subject: [PATCH 006/177] Fix hardware decoding detection for 10-bit videos on rkmpp (#6420) --- pkg/ffmpeg/codec_hardware.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pkg/ffmpeg/codec_hardware.go b/pkg/ffmpeg/codec_hardware.go index 86fe56bde..aa8c75dcc 100644 --- a/pkg/ffmpeg/codec_hardware.go +++ b/pkg/ffmpeg/codec_hardware.go @@ -363,8 +363,11 @@ func (f *FFMpeg) hwApplyFullHWFilter(args VideoFilter, codec VideoCodec, fullhw args = args.Append("scale_qsv=format=nv12") } case VideoCodecRK264: - // For Rockchip, no extra mapping here. If there is no scale filter, - // leave frames in DRM_PRIME for the encoder. + // Full-hw decode on 10-bit sources often produces DRM_PRIME with sw_pix_fmt=nv15. + // h264_rkmpp does NOT accept nv15, so we must force a conversion to nv12 + if fullhw { + args = args.Append("scale_rkrga=w=iw:h=ih:format=nv12") + } } return args @@ -399,7 +402,7 @@ func (f *FFMpeg) hwApplyScaleTemplate(sargs string, codec VideoCodec, match []in // by downloading the scaled frame to system RAM and re-uploading it. // The filter chain below uses a zero-copy approach, passing the hardware-scaled // frame directly to the encoder. This is more efficient but may be less stable. - template = "scale_rkrga=$value" + template = "scale_rkrga=$value:format=nv12" default: return VideoFilter(sargs) } From 91e1ec520f164dc267f2d8d36e05735e7ab05e0f Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:20:01 -0800 Subject: [PATCH 007/177] FR: Allow Marker Screenshot Generation Unconditionally (#6433) --- internal/manager/task_generate.go | 6 +++++- internal/manager/task_generate_markers.go | 11 +++++++---- .../src/components/Settings/Tasks/GenerateOptions.tsx | 2 -- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/internal/manager/task_generate.go b/internal/manager/task_generate.go index c28ffe55b..30ecd08bf 100644 --- a/internal/manager/task_generate.go +++ b/internal/manager/task_generate.go @@ -411,12 +411,13 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, } } - if j.input.Markers { + if j.input.Markers || j.input.MarkerImagePreviews || j.input.MarkerScreenshots { task := &GenerateMarkersTask{ repository: r, Scene: scene, Overwrite: j.overwrite, fileNamingAlgorithm: j.fileNamingAlgo, + VideoPreview: j.input.Markers, ImagePreview: j.input.MarkerImagePreviews, Screenshot: j.input.MarkerScreenshots, @@ -488,6 +489,9 @@ func (j *GenerateJob) queueMarkerJob(g *generate.Generator, marker *models.Scene Marker: marker, Overwrite: j.overwrite, fileNamingAlgorithm: j.fileNamingAlgo, + VideoPreview: j.input.Markers, + ImagePreview: j.input.MarkerImagePreviews, + Screenshot: j.input.MarkerScreenshots, generator: g, } j.totals.markers++ diff --git a/internal/manager/task_generate_markers.go b/internal/manager/task_generate_markers.go index cfe17926c..1da458ba8 100644 --- a/internal/manager/task_generate_markers.go +++ b/internal/manager/task_generate_markers.go @@ -18,6 +18,7 @@ type GenerateMarkersTask struct { Overwrite bool fileNamingAlgorithm models.HashAlgorithm + VideoPreview bool ImagePreview bool Screenshot bool @@ -115,9 +116,11 @@ func (t *GenerateMarkersTask) generateMarker(videoFile *models.VideoFile, scene g := t.generator - if err := g.MarkerPreviewVideo(context.TODO(), videoFile.Path, sceneHash, seconds, sceneMarker.EndSeconds, instance.Config.GetPreviewAudio()); err != nil { - logger.Errorf("[generator] failed to generate marker video: %v", err) - logErrorOutput(err) + if t.VideoPreview { + if err := g.MarkerPreviewVideo(context.TODO(), videoFile.Path, sceneHash, seconds, sceneMarker.EndSeconds, instance.Config.GetPreviewAudio()); err != nil { + logger.Errorf("[generator] failed to generate marker video: %v", err) + logErrorOutput(err) + } } if t.ImagePreview { @@ -164,7 +167,7 @@ func (t *GenerateMarkersTask) markerExists(sceneChecksum string, seconds int) bo return false } - videoExists := t.videoExists(sceneChecksum, seconds) + videoExists := !t.VideoPreview || t.videoExists(sceneChecksum, seconds) imageExists := !t.ImagePreview || t.imageExists(sceneChecksum, seconds) screenshotExists := !t.Screenshot || t.screenshotExists(sceneChecksum, seconds) diff --git a/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx b/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx index 00d129be7..ee126d41e 100644 --- a/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx @@ -100,7 +100,6 @@ export const GenerateOptions: React.FC = ({ id="marker-image-preview-task" className="sub-setting" checked={options.markerImagePreviews ?? false} - disabled={!options.markers} headingID="dialogs.scene_gen.marker_image_previews" tooltipID="dialogs.scene_gen.marker_image_previews_tooltip" onChange={(v) => @@ -112,7 +111,6 @@ export const GenerateOptions: React.FC = ({ setOptions({ markerScreenshots: v })} From 09ba41b2bb6c30184eb3fb97d153aad8fb6109e6 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:21:55 -0800 Subject: [PATCH 008/177] Chore: Update htmlQuery and Xpath dependencies (#6434) --- go.mod | 4 ++-- go.sum | 54 ++++++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index 0cf02fa0d..705fe40ed 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552 github.com/Yamashou/gqlgenc v0.32.1 github.com/anacrolix/dms v1.2.2 - github.com/antchfx/htmlquery v1.3.0 + github.com/antchfx/htmlquery v1.3.5 github.com/asticode/go-astisub v0.25.1 github.com/chromedp/cdproto v0.0.0-20231007061347-18b01cd81617 github.com/chromedp/chromedp v0.9.2 @@ -69,7 +69,7 @@ require ( require ( github.com/agnivade/levenshtein v1.2.1 // indirect - github.com/antchfx/xpath v1.2.3 // indirect + github.com/antchfx/xpath v1.3.5 // indirect github.com/asticode/go-astikit v0.20.0 // indirect github.com/asticode/go-astits v1.8.0 // indirect github.com/chromedp/sysutil v1.0.0 // indirect diff --git a/go.sum b/go.sum index fc731b705..750acd9ab 100644 --- a/go.sum +++ b/go.sum @@ -85,10 +85,10 @@ github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/ github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= -github.com/antchfx/htmlquery v1.3.0 h1:5I5yNFOVI+egyia5F2s/5Do2nFWxJz41Tr3DyfKD25E= -github.com/antchfx/htmlquery v1.3.0/go.mod h1:zKPDVTMhfOmcwxheXUsx4rKJy8KEY/PU6eXr/2SebQ8= -github.com/antchfx/xpath v1.2.3 h1:CCZWOzv5bAqjVv0offZ2LVgVYFbeldKQVuLNbViZdes= -github.com/antchfx/xpath v1.2.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= +github.com/antchfx/htmlquery v1.3.5 h1:aYthDDClnG2a2xePf6tys/UyyM/kRcsFRm+ifhFKoU0= +github.com/antchfx/htmlquery v1.3.5/go.mod h1:5oyIPIa3ovYGtLqMPNjBF2Uf25NPCKsMjCnQ8lvjaoA= +github.com/antchfx/xpath v1.3.5 h1:PqbXLC3TkfeZyakF5eeh3NTWEbYl4VHNVeufANzDbKQ= +github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= @@ -286,6 +286,7 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -664,6 +665,10 @@ golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -707,6 +712,10 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -757,7 +766,12 @@ golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -789,6 +803,11 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -869,14 +888,25 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -889,7 +919,12 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -956,6 +991,9 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 956af44a2979e0125cd475c197a79b97b255e54a Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:36:21 -0800 Subject: [PATCH 009/177] FR: Sort Scenes and Images by Resolution (#6441) --- pkg/sqlite/image.go | 9 +++++++++ pkg/sqlite/scene.go | 4 ++++ ui/v2.5/src/models/list-filter/images.ts | 8 +++++++- ui/v2.5/src/models/list-filter/scenes.ts | 1 + 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index ccccc90aa..bcaf3f42f 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -942,6 +942,7 @@ var imageSortOptions = sortOptions{ "performer_count", "random", "rating", + "resolution", "tag_count", "title", "updated_at", @@ -1001,6 +1002,14 @@ func (qb *ImageStore) setImageSortAndPagination(q *queryBuilder, findFilter *mod case "mod_time", "filesize": addFilesJoin() sortClause = getSort(sort, direction, "files") + case "resolution": + addFilesJoin() + q.addJoins(join{ + sort: true, + table: imageFileTable, + onClause: "images_files.file_id = image_files.file_id", + }) + sortClause = " ORDER BY MIN(image_files.width, image_files.height) " + direction case "title": addFilesJoin() addFolderJoin() diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 0c2d11345..a0b9005a5 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -1138,6 +1138,7 @@ var sceneSortOptions = sortOptions{ "perceptual_similarity", "random", "rating", + "resolution", "studio", "tag_count", "title", @@ -1236,6 +1237,9 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF sort = "frame_rate" addVideoFileTable() query.sortAndPagination += getSort(sort, direction, videoFileTable) + case "resolution": + addVideoFileTable() + query.sortAndPagination += fmt.Sprintf(" ORDER BY MIN(%s.width, %s.height) %s", videoFileTable, videoFileTable, getSortDirection(direction)) case "filesize": addFileTable() query.sortAndPagination += getSort(sort, direction, fileTable) diff --git a/ui/v2.5/src/models/list-filter/images.ts b/ui/v2.5/src/models/list-filter/images.ts index 4d5630b1c..0b2e06df0 100644 --- a/ui/v2.5/src/models/list-filter/images.ts +++ b/ui/v2.5/src/models/list-filter/images.ts @@ -25,7 +25,13 @@ import { GalleriesCriterionOption } from "./criteria/galleries"; const defaultSortBy = "path"; -const sortByOptions = ["filesize", "file_count", "date", ...MediaSortByOptions] +const sortByOptions = [ + "filesize", + "file_count", + "date", + "resolution", + ...MediaSortByOptions, +] .map(ListFilterOptions.createSortBy) .concat([ { diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index cf2791567..5fdb6a770 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -44,6 +44,7 @@ const sortByOptions = [ "filesize", "duration", "framerate", + "resolution", "bitrate", "last_played_at", "resume_time", From dc7ebadb16c5a99838005e176b00789be8799098 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:37:18 -0800 Subject: [PATCH 010/177] FR: Update Tray Notification to Include Port (#6448) --- internal/desktop/systray_nonlinux.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/desktop/systray_nonlinux.go b/internal/desktop/systray_nonlinux.go index dab6d4dc2..6b6055f11 100644 --- a/internal/desktop/systray_nonlinux.go +++ b/internal/desktop/systray_nonlinux.go @@ -3,6 +3,7 @@ package desktop import ( + "fmt" "runtime" "strings" @@ -58,12 +59,12 @@ func startSystray(exit chan int, faviconProvider FaviconProvider) { func systrayInitialize(exit chan<- int, faviconProvider FaviconProvider) { favicon := faviconProvider.GetFavicon() systray.SetTemplateIcon(favicon, favicon) - systray.SetTooltip("🟢 Stash is Running.") + c := config.GetInstance() + systray.SetTooltip(fmt.Sprintf("🟢 Stash is Running on port %d.", c.GetPort())) openStashButton := systray.AddMenuItem("Open Stash", "Open a browser window to Stash") var menuItems []string systray.AddSeparator() - c := config.GetInstance() if !c.IsNewSystem() { menuItems = c.GetMenuItems() for _, item := range menuItems { From 45dc892a54ee67b6096679ff85b4a56d8b61ee90 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Sun, 4 Jan 2026 22:04:28 -0800 Subject: [PATCH 011/177] FR: Hide Already Installed Plugins or Scrapers (#6443) --- .../src/components/Settings/PluginPackageManager.tsx | 11 ++++++++++- .../src/components/Settings/ScraperPackageManager.tsx | 11 ++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/components/Settings/PluginPackageManager.tsx b/ui/v2.5/src/components/Settings/PluginPackageManager.tsx index ed3160139..d71bea5ee 100644 --- a/ui/v2.5/src/components/Settings/PluginPackageManager.tsx +++ b/ui/v2.5/src/components/Settings/PluginPackageManager.tsx @@ -100,6 +100,12 @@ export const AvailablePluginPackages: React.FC = () => { const [jobID, setJobID] = useState(); const { job } = useMonitorJob(jobID, () => onPackageChanges()); + // Get installed packages to filter them out from available list + const { data: installedData } = useInstalledPluginPackages(false); + const installedPackageIds = new Set( + installedData?.installedPackages?.map((p) => p.package_id) ?? [] + ); + async function onInstallPackages(packages: GQL.PackageSpecInput[]) { const r = await mutateInstallPluginPackages(packages); @@ -114,7 +120,10 @@ export const AvailablePluginPackages: React.FC = () => { async function loadSource(source: string): Promise { const { data } = await queryAvailablePluginPackages(source); - return data.availablePackages; + // Filter out already installed packages + return data.availablePackages.filter( + (pkg) => !installedPackageIds.has(pkg.package_id) + ); } function addSource(source: GQL.PackageSource) { diff --git a/ui/v2.5/src/components/Settings/ScraperPackageManager.tsx b/ui/v2.5/src/components/Settings/ScraperPackageManager.tsx index cb6858610..5c93bc2a3 100644 --- a/ui/v2.5/src/components/Settings/ScraperPackageManager.tsx +++ b/ui/v2.5/src/components/Settings/ScraperPackageManager.tsx @@ -100,6 +100,12 @@ export const AvailableScraperPackages: React.FC = () => { const [jobID, setJobID] = useState(); const { job } = useMonitorJob(jobID, () => onPackageChanges()); + // Get installed packages to filter them out from available list + const { data: installedData } = useInstalledScraperPackages(false); + const installedPackageIds = new Set( + installedData?.installedPackages?.map((p) => p.package_id) ?? [] + ); + async function onInstallPackages(packages: GQL.PackageSpecInput[]) { const r = await mutateInstallScraperPackages(packages); @@ -114,7 +120,10 @@ export const AvailableScraperPackages: React.FC = () => { async function loadSource(source: string): Promise { const { data } = await queryAvailableScraperPackages(source); - return data.availablePackages; + // Filter out already installed packages + return data.availablePackages.filter( + (pkg) => !installedPackageIds.has(pkg.package_id) + ); } function addSource(source: GQL.PackageSource) { From 81e8ccb5a916dc03e8ed763318993079dba89f33 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Sun, 4 Jan 2026 22:34:43 -0800 Subject: [PATCH 012/177] FR: Autopopulate Stash-ID Search Box (#6447) --- .../Performers/PerformerDetails/PerformerEditPanel.tsx | 1 + ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx | 1 + ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx | 4 +++- .../src/components/Studios/StudioDetails/StudioEditPanel.tsx | 1 + ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx | 1 + 5 files changed, 7 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index f2d825e07..000b723e0 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -681,6 +681,7 @@ export const PerformerEditPanel: React.FC = ({ onStashIDSelected(item); setIsStashIDSearchOpen(false); }} + initialQuery={performer.name ?? ""} /> )} diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index 9ba5059de..6a119d8d5 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -731,6 +731,7 @@ export const SceneEditPanel: React.FC = ({ onStashIDSelected(item); setIsStashIDSearchOpen(false); }} + initialQuery={scene.title ?? ""} /> )}
diff --git a/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx b/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx index 4674db08a..47683dc3c 100644 --- a/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx +++ b/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx @@ -33,6 +33,7 @@ interface IProps { stashBoxes: GQL.StashBox[]; excludedStashBoxEndpoints?: string[]; onSelectItem: (item?: GQL.StashIdInput) => void; + initialQuery?: string; } const CLASSNAME = "StashBoxIDSearchModal"; @@ -289,6 +290,7 @@ export const StashBoxIDSearchModal: React.FC = ({ stashBoxes, excludedStashBoxEndpoints = [], onSelectItem, + initialQuery = "", }) => { const intl = useIntl(); const Toast = useToast(); @@ -297,7 +299,7 @@ export const StashBoxIDSearchModal: React.FC = ({ const [selectedStashBox, setSelectedStashBox] = useState( null ); - const [query, setQuery] = useState(""); + const [query, setQuery] = useState(initialQuery); const [results, setResults] = useState( undefined ); diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx index c8cfd3a3e..ab2045aac 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx @@ -200,6 +200,7 @@ export const StudioEditPanel: React.FC = ({ onStashIDSelected(item); setIsStashIDSearchOpen(false); }} + initialQuery={studio.name ?? ""} /> )} diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx index a067470a9..d4784a5fe 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx @@ -213,6 +213,7 @@ export const TagEditPanel: React.FC = ({ onStashIDSelected(item); setIsStashIDSearchOpen(false); }} + initialQuery={tag?.name ?? ""} /> )} From 6eed5390e1b0efdab44187de785d31c31aeb3d61 Mon Sep 17 00:00:00 2001 From: feederbox826 Date: Mon, 5 Jan 2026 19:12:03 -0500 Subject: [PATCH 013/177] specify URL for stash ID endpoint (#6464) --- ui/v2.5/src/locales/en-GB.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 426440fad..f165c03cd 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1459,7 +1459,7 @@ "welcome_to_stash": "Welcome to Stash" }, "stash_id": "Stash ID", - "stash_id_endpoint": "Stash ID Endpoint", + "stash_id_endpoint": "Stash ID Endpoint URL", "stash_ids": "Stash IDs", "stashbox_search": { "header": "Search {entityType} from StashBox", From 3b5e1db2aa53befb2ee0536b5015e2d10c74fc12 Mon Sep 17 00:00:00 2001 From: CJ <72030708+cj12312021@users.noreply.github.com> Date: Mon, 5 Jan 2026 18:12:59 -0600 Subject: [PATCH 014/177] feat(ui): make CustomFieldsInput patchable via PluginApi (#6468) Wrap the CustomFieldsInput component with PatchComponent to allow plugins to modify custom field input behavior. This enables plugins to inject default fields, modify the onChange handler, or customize the component rendering. --- .../src/components/Shared/CustomFields.tsx | 246 +++++++++--------- ui/v2.5/src/docs/en/Manual/UIPluginApi.md | 1 + 2 files changed, 123 insertions(+), 124 deletions(-) diff --git a/ui/v2.5/src/components/Shared/CustomFields.tsx b/ui/v2.5/src/components/Shared/CustomFields.tsx index e7355df66..c8d389a17 100644 --- a/ui/v2.5/src/components/Shared/CustomFields.tsx +++ b/ui/v2.5/src/components/Shared/CustomFields.tsx @@ -189,136 +189,134 @@ interface ICustomFieldsInput { setError: (error?: string) => void; } -export const CustomFieldsInput: React.FC = ({ - values, - error, - onChange, - setError, -}) => { - const intl = useIntl(); +export const CustomFieldsInput: React.FC = PatchComponent( + "CustomFieldsInput", + ({ values, error, onChange, setError }) => { + const intl = useIntl(); - const [newCustomField, setNewCustomField] = useState({ - field: "", - value: "", - }); + const [newCustomField, setNewCustomField] = useState({ + field: "", + value: "", + }); - const fields = useMemo(() => { - const valueCopy = cloneDeep(values); - if (newCustomField.field !== "" && error === undefined) { - delete valueCopy[newCustomField.field]; + const fields = useMemo(() => { + const valueCopy = cloneDeep(values); + if (newCustomField.field !== "" && error === undefined) { + delete valueCopy[newCustomField.field]; + } + + const ret = Object.keys(valueCopy); + ret.sort(); + return ret; + }, [values, newCustomField, error]); + + function onSetNewField(v: ICustomField) { + // validate the field name + let newError = undefined; + if (v.field.length > maxFieldNameLength) { + newError = intl.formatMessage({ + id: "errors.custom_fields.field_name_length", + }); + } + if (v.field.trim() === "" && v.value !== "") { + newError = intl.formatMessage({ + id: "errors.custom_fields.field_name_required", + }); + } + if (v.field.trim() !== v.field) { + newError = intl.formatMessage({ + id: "errors.custom_fields.field_name_whitespace", + }); + } + if (fields.includes(v.field)) { + newError = intl.formatMessage({ + id: "errors.custom_fields.duplicate_field", + }); + } + + const oldField = newCustomField; + + setNewCustomField(v); + + const valuesCopy = cloneDeep(values); + if (oldField.field !== "" && error === undefined) { + delete valuesCopy[oldField.field]; + } + + // if valid, pass up + if (!newError && v.field !== "") { + valuesCopy[v.field] = v.value; + } + + onChange(valuesCopy); + setError(newError); } - const ret = Object.keys(valueCopy); - ret.sort(); - return ret; - }, [values, newCustomField, error]); - - function onSetNewField(v: ICustomField) { - // validate the field name - let newError = undefined; - if (v.field.length > maxFieldNameLength) { - newError = intl.formatMessage({ - id: "errors.custom_fields.field_name_length", - }); - } - if (v.field.trim() === "" && v.value !== "") { - newError = intl.formatMessage({ - id: "errors.custom_fields.field_name_required", - }); - } - if (v.field.trim() !== v.field) { - newError = intl.formatMessage({ - id: "errors.custom_fields.field_name_whitespace", - }); - } - if (fields.includes(v.field)) { - newError = intl.formatMessage({ - id: "errors.custom_fields.duplicate_field", - }); + function onAdd() { + const newValues = { + ...values, + [newCustomField.field]: newCustomField.value, + }; + setNewCustomField({ field: "", value: "" }); + onChange(newValues); } - const oldField = newCustomField; - - setNewCustomField(v); - - const valuesCopy = cloneDeep(values); - if (oldField.field !== "" && error === undefined) { - delete valuesCopy[oldField.field]; + function fieldChanged( + currentField: string, + newField: string, + value: unknown + ) { + let newValues = cloneDeep(values); + delete newValues[currentField]; + if (newField !== "") { + newValues[newField] = value; + } + onChange(newValues); } - // if valid, pass up - if (!newError && v.field !== "") { - valuesCopy[v.field] = v.value; - } - - onChange(valuesCopy); - setError(newError); - } - - function onAdd() { - const newValues = { - ...values, - [newCustomField.field]: newCustomField.value, - }; - setNewCustomField({ field: "", value: "" }); - onChange(newValues); - } - - function fieldChanged( - currentField: string, - newField: string, - value: unknown - ) { - let newValues = cloneDeep(values); - delete newValues[currentField]; - if (newField !== "") { - newValues[newField] = value; - } - onChange(newValues); - } - - return ( - - - - - - - - - - - - {fields.map((field) => ( - - fieldChanged(field, newField, newValue) - } - /> - ))} - onSetNewField({ field, value })} - isNew - /> - - - - - ); -}; + + + + + + + + + + + {fields.map((field) => ( + + fieldChanged(field, newField, newValue) + } + /> + ))} + onSetNewField({ field, value })} + isNew + /> + + + + + ); + } +); diff --git a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md index 67d837ed7..e1347a46f 100644 --- a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md +++ b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md @@ -223,6 +223,7 @@ Returns `void`. - `CountrySelect` - `CustomFieldInput` - `CustomFields` +- `CustomFieldsInput` - `DateInput` - `DetailImage` - `ExternalLinkButtons` From 1e6bf74385f276926a0345d9eebeee6c0b83b5e8 Mon Sep 17 00:00:00 2001 From: Valkyr-JS <154020147+Valkyr-JS@users.noreply.github.com> Date: Tue, 6 Jan 2026 00:14:48 +0000 Subject: [PATCH 015/177] Plugin API more patchable components (#6463) * StudioDetailsPanel * StudioCard * GridCard * ImageGridCard * ImageCard * GroupCard * SceneMarkerCard.Popovers * SceneMarkerCard.Details * SceneMarkerCard.Image * SceneMarkerCard --- ui/v2.5/src/components/Groups/GroupCard.tsx | 252 ++++++------- ui/v2.5/src/components/Images/ImageCard.tsx | 320 ++++++++--------- .../src/components/Scenes/SceneMarkerCard.tsx | 285 ++++++++------- .../components/Shared/GridCard/GridCard.tsx | 196 ++++++----- ui/v2.5/src/components/Studios/StudioCard.tsx | 332 +++++++++--------- .../StudioDetails/StudioDetailsPanel.tsx | 143 ++++---- ui/v2.5/src/pluginApi.d.ts | 9 + 7 files changed, 790 insertions(+), 747 deletions(-) diff --git a/ui/v2.5/src/components/Groups/GroupCard.tsx b/ui/v2.5/src/components/Groups/GroupCard.tsx index 7412c986a..b9b206985 100644 --- a/ui/v2.5/src/components/Groups/GroupCard.tsx +++ b/ui/v2.5/src/components/Groups/GroupCard.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from "react"; import { Button, ButtonGroup } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; +import { PatchComponent } from "src/patch"; import { GridCard } from "../Shared/GridCard/GridCard"; import { HoverPopover } from "../Shared/HoverPopover"; import { Icon } from "../Shared/Icon"; @@ -47,137 +48,140 @@ interface IProps { onMove?: (srcIds: string[], targetId: string, after: boolean) => void; } -export const GroupCard: React.FC = ({ - group, - sceneNumber, - cardWidth, - selecting, - selected, - zoomIndex, - onSelectedChanged, - fromGroupId, - onMove, -}) => { - const groupDescription = useMemo(() => { - if (!fromGroupId) { - return undefined; - } +export const GroupCard: React.FC = PatchComponent( + "GroupCard", + ({ + group, + sceneNumber, + cardWidth, + selecting, + selected, + zoomIndex, + onSelectedChanged, + fromGroupId, + onMove, + }) => { + const groupDescription = useMemo(() => { + if (!fromGroupId) { + return undefined; + } - const containingGroup = group.containing_groups.find( - (cg) => cg.group.id === fromGroupId - ); + const containingGroup = group.containing_groups.find( + (cg) => cg.group.id === fromGroupId + ); - return containingGroup?.description ?? undefined; - }, [fromGroupId, group.containing_groups]); + return containingGroup?.description ?? undefined; + }, [fromGroupId, group.containing_groups]); - function maybeRenderScenesPopoverButton() { - if (group.scenes.length === 0) return; + function maybeRenderScenesPopoverButton() { + if (group.scenes.length === 0) return; - const popoverContent = group.scenes.map((scene) => ( - - )); + const popoverContent = group.scenes.map((scene) => ( + + )); - return ( - - - - ); - } - - function maybeRenderTagPopoverButton() { - if (group.tags.length <= 0) return; - - const popoverContent = group.tags.map((tag) => ( - - )); - - return ( - - - - ); - } - - function maybeRenderOCounter() { - if (!group.o_counter) return; - - return ; - } - - function maybeRenderPopoverButtonGroup() { - if ( - sceneNumber || - groupDescription || - group.scenes.length > 0 || - group.tags.length > 0 || - group.containing_groups.length > 0 || - group.sub_group_count > 0 - ) { return ( - <> - -
- - {maybeRenderScenesPopoverButton()} - {maybeRenderTagPopoverButton()} - {(group.sub_group_count > 0 || - group.containing_groups.length > 0) && ( - - )} - {maybeRenderOCounter()} - - + + + ); } - } - return ( - - {group.name - - + function maybeRenderTagPopoverButton() { + if (group.tags.length <= 0) return; + + const popoverContent = group.tags.map((tag) => ( + + )); + + return ( + + + + ); + } + + function maybeRenderOCounter() { + if (!group.o_counter) return; + + return ; + } + + function maybeRenderPopoverButtonGroup() { + if ( + sceneNumber || + groupDescription || + group.scenes.length > 0 || + group.tags.length > 0 || + group.containing_groups.length > 0 || + group.sub_group_count > 0 + ) { + return ( + <> + +
+ + {maybeRenderScenesPopoverButton()} + {maybeRenderTagPopoverButton()} + {(group.sub_group_count > 0 || + group.containing_groups.length > 0) && ( + + )} + {maybeRenderOCounter()} + + + ); } - details={ -
- {group.date} - -
- } - selected={selected} - selecting={selecting} - onSelectedChanged={onSelectedChanged} - popovers={maybeRenderPopoverButtonGroup()} - /> - ); -}; + } + + return ( + + {group.name + + + } + details={ +
+ {group.date} + +
+ } + selected={selected} + selecting={selecting} + onSelectedChanged={onSelectedChanged} + popovers={maybeRenderPopoverButtonGroup()} + /> + ); + } +); diff --git a/ui/v2.5/src/components/Images/ImageCard.tsx b/ui/v2.5/src/components/Images/ImageCard.tsx index a22e48139..0b60a77ff 100644 --- a/ui/v2.5/src/components/Images/ImageCard.tsx +++ b/ui/v2.5/src/components/Images/ImageCard.tsx @@ -15,6 +15,7 @@ import { faTag, } from "@fortawesome/free-solid-svg-icons"; import { imageTitle } from "src/core/files"; +import { PatchComponent } from "src/patch"; import { TruncatedText } from "../Shared/TruncatedText"; import { StudioOverlay } from "../Shared/GridCard/StudioOverlay"; import { OCounterButton } from "../Shared/CountButton"; @@ -29,168 +30,171 @@ interface IImageCardProps { onPreview?: (ev: MouseEvent) => void; } -export const ImageCard: React.FC = ( - props: IImageCardProps -) => { - const file = useMemo( - () => - props.image.visual_files.length > 0 - ? props.image.visual_files[0] - : undefined, - [props.image] - ); - - function maybeRenderTagPopoverButton() { - if (props.image.tags.length <= 0) return; - - const popoverContent = props.image.tags.map((tag) => ( - - )); - - return ( - - - +export const ImageCard: React.FC = PatchComponent( + "ImageCard", + (props: IImageCardProps) => { + const file = useMemo( + () => + props.image.visual_files.length > 0 + ? props.image.visual_files[0] + : undefined, + [props.image] ); - } - function maybeRenderPerformerPopoverButton() { - if (props.image.performers.length <= 0) return; + function maybeRenderTagPopoverButton() { + if (props.image.tags.length <= 0) return; + + const popoverContent = props.image.tags.map((tag) => ( + + )); + + return ( + + + + ); + } + + function maybeRenderPerformerPopoverButton() { + if (props.image.performers.length <= 0) return; + + return ( + + ); + } + + function maybeRenderOCounter() { + if (props.image.o_counter) { + return ; + } + } + + function maybeRenderGallery() { + if (props.image.galleries.length <= 0) return; + + const popoverContent = props.image.galleries.map((gallery) => ( + + )); + + return ( + + + + ); + } + + function maybeRenderOrganized() { + if (props.image.organized) { + return ( +
+ +
+ ); + } + } + + function maybeRenderPopoverButtonGroup() { + if ( + props.image.tags.length > 0 || + props.image.performers.length > 0 || + props.image.o_counter || + props.image.galleries.length > 0 || + props.image.organized + ) { + return ( + <> +
+ + {maybeRenderTagPopoverButton()} + {maybeRenderPerformerPopoverButton()} + {maybeRenderOCounter()} + {maybeRenderGallery()} + {maybeRenderOrganized()} + + + ); + } + } + + function isPortrait() { + const width = file?.width ? file.width : 0; + const height = file?.height ? file.height : 0; + return height > width; + } + + const source = + props.image.paths.preview != "" + ? props.image.paths.preview ?? "" + : props.image.paths.thumbnail ?? ""; + const video = source.includes("preview"); + const ImagePreview = video ? "video" : "img"; return ( - +
+ + {props.onPreview ? ( +
+ +
+ ) : undefined} +
+ + + } + details={ +
+ {props.image.date} + +
+ } + overlays={} + popovers={maybeRenderPopoverButtonGroup()} + selected={props.selected} + selecting={props.selecting} + onSelectedChanged={props.onSelectedChanged} /> ); } - - function maybeRenderOCounter() { - if (props.image.o_counter) { - return ; - } - } - - function maybeRenderGallery() { - if (props.image.galleries.length <= 0) return; - - const popoverContent = props.image.galleries.map((gallery) => ( - - )); - - return ( - - - - ); - } - - function maybeRenderOrganized() { - if (props.image.organized) { - return ( -
- -
- ); - } - } - - function maybeRenderPopoverButtonGroup() { - if ( - props.image.tags.length > 0 || - props.image.performers.length > 0 || - props.image.o_counter || - props.image.galleries.length > 0 || - props.image.organized - ) { - return ( - <> -
- - {maybeRenderTagPopoverButton()} - {maybeRenderPerformerPopoverButton()} - {maybeRenderOCounter()} - {maybeRenderGallery()} - {maybeRenderOrganized()} - - - ); - } - } - - function isPortrait() { - const width = file?.width ? file.width : 0; - const height = file?.height ? file.height : 0; - return height > width; - } - - const source = - props.image.paths.preview != "" - ? props.image.paths.preview ?? "" - : props.image.paths.thumbnail ?? ""; - const video = source.includes("preview"); - const ImagePreview = video ? "video" : "img"; - - return ( - -
- - {props.onPreview ? ( -
- -
- ) : undefined} -
- - - } - details={ -
- {props.image.date} - -
- } - overlays={} - popovers={maybeRenderPopoverButtonGroup()} - selected={props.selected} - selecting={props.selecting} - onSelectedChanged={props.onSelectedChanged} - /> - ); -}; +); diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerCard.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerCard.tsx index e76beda0a..96961d68b 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerCard.tsx @@ -12,6 +12,7 @@ import { faTag } from "@fortawesome/free-solid-svg-icons"; import { markerTitle } from "src/core/markers"; import { Link } from "react-router-dom"; import { objectTitle } from "src/core/files"; +import { PatchComponent } from "src/patch"; import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton"; import { ScenePreview } from "./SceneCard"; import { TruncatedText } from "../Shared/TruncatedText"; @@ -28,154 +29,166 @@ interface ISceneMarkerCardProps { onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; } -const SceneMarkerCardPopovers = (props: ISceneMarkerCardProps) => { - function maybeRenderPerformerPopoverButton() { - if (props.marker.scene.performers.length <= 0) return; +const SceneMarkerCardPopovers = PatchComponent( + "SceneMarkerCard.Popovers", + (props: ISceneMarkerCardProps) => { + function maybeRenderPerformerPopoverButton() { + if (props.marker.scene.performers.length <= 0) return; - return ( - - ); - } - - function renderTagPopoverButton() { - const popoverContent = [ - , - ]; - - props.marker.tags.map((tag) => - popoverContent.push( - - ) - ); - - return ( - - - - ); - } - - function renderPopoverButtonGroup() { - if (!props.compact) { return ( - <> -
- - {maybeRenderPerformerPopoverButton()} - {renderTagPopoverButton()} - - + ); } + + function renderTagPopoverButton() { + const popoverContent = [ + , + ]; + + props.marker.tags.map((tag) => + popoverContent.push( + + ) + ); + + return ( + + + + ); + } + + function renderPopoverButtonGroup() { + if (!props.compact) { + return ( + <> +
+ + {maybeRenderPerformerPopoverButton()} + {renderTagPopoverButton()} + + + ); + } + } + + return <>{renderPopoverButtonGroup()}; } +); - return <>{renderPopoverButtonGroup()}; -}; - -const SceneMarkerCardDetails = (props: ISceneMarkerCardProps) => { - return ( -
- - {TextUtils.formatTimestampRange( - props.marker.seconds, - props.marker.end_seconds ?? undefined - )} - - - {objectTitle(props.marker.scene)} - - } - /> -
- ); -}; - -const SceneMarkerCardImage = (props: ISceneMarkerCardProps) => { - const { configuration } = useConfigurationContext(); - - const file = useMemo( - () => - props.marker.scene.files.length > 0 - ? props.marker.scene.files[0] - : undefined, - [props.marker.scene] - ); - - function isPortrait() { - const width = file?.width ? file.width : 0; - const height = file?.height ? file.height : 0; - return height > width; - } - - function maybeRenderSceneSpecsOverlay() { +const SceneMarkerCardDetails = PatchComponent( + "SceneMarkerCard.Details", + (props: ISceneMarkerCardProps) => { return ( -
- {props.marker.end_seconds && ( - - {TextUtils.secondsToTimestamp( - props.marker.end_seconds - props.marker.seconds - )} - - )} +
+ + {TextUtils.formatTimestampRange( + props.marker.seconds, + props.marker.end_seconds ?? undefined + )} + + + {objectTitle(props.marker.scene)} + + } + />
); } +); - return ( - <> - - {maybeRenderSceneSpecsOverlay()} - - ); -}; +const SceneMarkerCardImage = PatchComponent( + "SceneMarkerCard.Image", + (props: ISceneMarkerCardProps) => { + const { configuration } = useConfigurationContext(); -export const SceneMarkerCard = (props: ISceneMarkerCardProps) => { - function zoomIndex() { - if (!props.compact && props.zoomIndex !== undefined) { - return `zoom-${props.zoomIndex}`; + const file = useMemo( + () => + props.marker.scene.files.length > 0 + ? props.marker.scene.files[0] + : undefined, + [props.marker.scene] + ); + + function isPortrait() { + const width = file?.width ? file.width : 0; + const height = file?.height ? file.height : 0; + return height > width; } - return ""; - } + function maybeRenderSceneSpecsOverlay() { + return ( +
+ {props.marker.end_seconds && ( + + {TextUtils.secondsToTimestamp( + props.marker.end_seconds - props.marker.seconds + )} + + )} +
+ ); + } - return ( - } - details={} - popovers={} - selected={props.selected} - selecting={props.selecting} - onSelectedChanged={props.onSelectedChanged} - /> - ); -}; + return ( + <> + + {maybeRenderSceneSpecsOverlay()} + + ); + } +); + +export const SceneMarkerCard = PatchComponent( + "SceneMarkerCard", + (props: ISceneMarkerCardProps) => { + function zoomIndex() { + if (!props.compact && props.zoomIndex !== undefined) { + return `zoom-${props.zoomIndex}`; + } + + return ""; + } + + return ( + } + details={} + popovers={} + selected={props.selected} + selecting={props.selecting} + onSelectedChanged={props.onSelectedChanged} + /> + ); + } +); diff --git a/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx b/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx index ecb914fa1..7bdac36c6 100644 --- a/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx +++ b/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx @@ -15,6 +15,7 @@ import { Icon } from "../Icon"; import { faGripLines } from "@fortawesome/free-solid-svg-icons"; import { DragSide, useDragMoveSelect } from "./dragMoveSelect"; import { useDebounce } from "src/hooks/debounce"; +import { PatchComponent } from "src/patch"; interface ICardProps { className?: string; @@ -164,106 +165,113 @@ const MoveTarget: React.FC<{ dragSide: DragSide }> = ({ dragSide }) => { ); }; -export const GridCard: React.FC = (props: ICardProps) => { - const { setInHandle, moveTarget, dragProps } = useDragMoveSelect({ - selecting: props.selecting || false, - selected: props.selected || false, - onSelectedChanged: props.onSelectedChanged, - objectId: props.objectId, - onMove: props.onMove, - }); +export const GridCard: React.FC = PatchComponent( + "GridCard", + (props: ICardProps) => { + const { setInHandle, moveTarget, dragProps } = useDragMoveSelect({ + selecting: props.selecting || false, + selected: props.selected || false, + onSelectedChanged: props.onSelectedChanged, + objectId: props.objectId, + onMove: props.onMove, + }); - function handleImageClick(event: React.MouseEvent) { - const { shiftKey } = event; - - if (!props.onSelectedChanged) { - return; - } - - if (props.selecting) { - props.onSelectedChanged(!props.selected, shiftKey); - event.preventDefault(); - event.stopPropagation(); - } - } - - function maybeRenderInteractiveHeatmap() { - if (props.interactiveHeatmap) { - return ( - interactive heatmap - ); - } - } - - function maybeRenderProgressBar() { - if ( - props.resumeTime && - props.duration && - props.duration > props.resumeTime + function handleImageClick( + event: React.MouseEvent ) { - const percentValue = (100 / props.duration) * props.resumeTime; - const percentStr = percentValue + "%"; - return ( -
-
-
- ); - } - } + const { shiftKey } = event; - return ( - - {moveTarget !== undefined && } - - {props.onSelectedChanged && ( - - )} + ); + } + } - {!!props.objectId && props.onMove && ( - - )} - + function maybeRenderProgressBar() { + if ( + props.resumeTime && + props.duration && + props.duration > props.resumeTime + ) { + const percentValue = (100 / props.duration) * props.resumeTime; + const percentStr = percentValue + "%"; + return ( +
+
+
+ ); + } + } -
- + {moveTarget !== undefined && } + + {props.onSelectedChanged && ( + + )} + + {!!props.objectId && props.onMove && ( + + )} + + +
- {props.image} - - {props.overlays} - {maybeRenderProgressBar()} -
- {maybeRenderInteractiveHeatmap()} -
- -
- {props.pretitleIcon} - -
- - {props.details} -
+ + {props.image} + + {props.overlays} + {maybeRenderProgressBar()} +
+ {maybeRenderInteractiveHeatmap()} +
+ +
+ {props.pretitleIcon} + +
+ + {props.details} +
- {props.popovers} - - ); -}; + {props.popovers} + + ); + } +); diff --git a/ui/v2.5/src/components/Studios/StudioCard.tsx b/ui/v2.5/src/components/Studios/StudioCard.tsx index 01b2b5c5a..87c9b9528 100644 --- a/ui/v2.5/src/components/Studios/StudioCard.tsx +++ b/ui/v2.5/src/components/Studios/StudioCard.tsx @@ -3,6 +3,7 @@ import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import NavUtils from "src/utils/navigation"; import { GridCard } from "src/components/Shared/GridCard/GridCard"; +import { PatchComponent } from "src/patch"; import { HoverPopover } from "../Shared/HoverPopover"; import { Icon } from "../Shared/Icon"; import { TagLink } from "../Shared/TagLink"; @@ -70,179 +71,182 @@ function maybeRenderChildren(studio: GQL.StudioDataFragment) { } } -export const StudioCard: React.FC = ({ - studio, - cardWidth, - hideParent, - selecting, - selected, - zoomIndex, - onSelectedChanged, -}) => { - const [updateStudio] = useStudioUpdate(); +export const StudioCard: React.FC = PatchComponent( + "StudioCard", + ({ + studio, + cardWidth, + hideParent, + selecting, + selected, + zoomIndex, + onSelectedChanged, + }) => { + const [updateStudio] = useStudioUpdate(); - function onToggleFavorite(v: boolean) { - if (studio.id) { - updateStudio({ - variables: { - input: { - id: studio.id, - favorite: v, + function onToggleFavorite(v: boolean) { + if (studio.id) { + updateStudio({ + variables: { + input: { + id: studio.id, + favorite: v, + }, }, - }, - }); + }); + } } - } - function maybeRenderScenesPopoverButton() { - if (!studio.scene_count) return; + function maybeRenderScenesPopoverButton() { + if (!studio.scene_count) return; - return ( - - ); - } - - function maybeRenderImagesPopoverButton() { - if (!studio.image_count) return; - - return ( - - ); - } - - function maybeRenderGalleriesPopoverButton() { - if (!studio.gallery_count) return; - - return ( - - ); - } - - function maybeRenderGroupsPopoverButton() { - if (!studio.group_count) return; - - return ( - - ); - } - - function maybeRenderPerformersPopoverButton() { - if (!studio.performer_count) return; - - return ( - - ); - } - - function maybeRenderTagPopoverButton() { - if (studio.tags.length <= 0) return; - - const popoverContent = studio.tags.map((tag) => ( - - )); - - return ( - - - - ); - } - - function maybeRenderOCounter() { - if (!studio.o_counter) return; - - return ; - } - - function maybeRenderPopoverButtonGroup() { - if ( - studio.scene_count || - studio.image_count || - studio.gallery_count || - studio.group_count || - studio.performer_count || - studio.o_counter || - studio.tags.length > 0 - ) { return ( - <> -
- - {maybeRenderScenesPopoverButton()} - {maybeRenderGroupsPopoverButton()} - {maybeRenderImagesPopoverButton()} - {maybeRenderGalleriesPopoverButton()} - {maybeRenderPerformersPopoverButton()} - {maybeRenderTagPopoverButton()} - {maybeRenderOCounter()} - - + ); } - } - return ( - - } - details={ -
- {maybeRenderParent(studio, hideParent)} - {maybeRenderChildren(studio)} - -
- } - overlays={ - onToggleFavorite(v)} - size="2x" - className="hide-not-favorite" + ); + } + + function maybeRenderGalleriesPopoverButton() { + if (!studio.gallery_count) return; + + return ( + + ); + } + + function maybeRenderGroupsPopoverButton() { + if (!studio.group_count) return; + + return ( + + ); + } + + function maybeRenderPerformersPopoverButton() { + if (!studio.performer_count) return; + + return ( + + ); + } + + function maybeRenderTagPopoverButton() { + if (studio.tags.length <= 0) return; + + const popoverContent = studio.tags.map((tag) => ( + + )); + + return ( + + + + ); + } + + function maybeRenderOCounter() { + if (!studio.o_counter) return; + + return ; + } + + function maybeRenderPopoverButtonGroup() { + if ( + studio.scene_count || + studio.image_count || + studio.gallery_count || + studio.group_count || + studio.performer_count || + studio.o_counter || + studio.tags.length > 0 + ) { + return ( + <> +
+ + {maybeRenderScenesPopoverButton()} + {maybeRenderGroupsPopoverButton()} + {maybeRenderImagesPopoverButton()} + {maybeRenderGalleriesPopoverButton()} + {maybeRenderPerformersPopoverButton()} + {maybeRenderTagPopoverButton()} + {maybeRenderOCounter()} + + + ); } - popovers={maybeRenderPopoverButtonGroup()} - selected={selected} - selecting={selecting} - onSelectedChanged={onSelectedChanged} - /> - ); -}; + } + + return ( + + } + details={ +
+ {maybeRenderParent(studio, hideParent)} + {maybeRenderChildren(studio)} + +
+ } + overlays={ + onToggleFavorite(v)} + size="2x" + className="hide-not-favorite" + /> + } + popovers={maybeRenderPopoverButtonGroup()} + selected={selected} + selecting={selecting} + onSelectedChanged={onSelectedChanged} + /> + ); + } +); diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx index 4d5af043f..5ad92100f 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx @@ -3,6 +3,7 @@ import { TagLink } from "src/components/Shared/TagLink"; import * as GQL from "src/core/generated-graphql"; import { DetailItem } from "src/components/Shared/DetailItem"; import { StashIDPill } from "src/components/Shared/StashID"; +import { PatchComponent } from "src/patch"; import { Link } from "react-router-dom"; interface IStudioDetailsPanel { @@ -11,85 +12,85 @@ interface IStudioDetailsPanel { fullWidth?: boolean; } -export const StudioDetailsPanel: React.FC = ({ - studio, - fullWidth, -}) => { - function renderTagsField() { - if (!studio.tags.length) { - return; - } - return ( -
    - {(studio.tags ?? []).map((tag) => ( - - ))} -
- ); - } - - function renderStashIDs() { - if (!studio.stash_ids?.length) { - return; +export const StudioDetailsPanel: React.FC = PatchComponent( + "StudioDetailsPanel", + ({ studio, fullWidth }) => { + function renderTagsField() { + if (!studio.tags.length) { + return; + } + return ( +
    + {(studio.tags ?? []).map((tag) => ( + + ))} +
+ ); } - return ( -
    - {studio.stash_ids.map((stashID) => { - return ( -
  • - + function renderStashIDs() { + if (!studio.stash_ids?.length) { + return; + } + + return ( +
      + {studio.stash_ids.map((stashID) => { + return ( +
    • + +
    • + ); + })} +
    + ); + } + + function renderURLs() { + if (!studio.urls?.length) { + return; + } + + return ( +
      + {studio.urls.map((url) => ( +
    • + + {url} +
    • - ); - })} -
    - ); - } - - function renderURLs() { - if (!studio.urls?.length) { - return; + ))} +
+ ); } return ( -
    - {studio.urls.map((url) => ( -
  • - - {url} - -
  • - ))} -
+
+ + + + {studio.parent_studio.name} + + ) : ( + "" + ) + } + fullWidth={fullWidth} + /> + + +
); } - - return ( -
- - - - {studio.parent_studio.name} - - ) : ( - "" - ) - } - fullWidth={fullWidth} - /> - - -
- ); -}; +); export const CompressedStudioDetailsPanel: React.FC = ({ studio, diff --git a/ui/v2.5/src/pluginApi.d.ts b/ui/v2.5/src/pluginApi.d.ts index 8f4a896f8..1746cbc44 100644 --- a/ui/v2.5/src/pluginApi.d.ts +++ b/ui/v2.5/src/pluginApi.d.ts @@ -676,6 +676,8 @@ declare namespace PluginApi { GalleryImagesPanel: React.FC; GalleryList: React.FC; GallerySelect: React.FC; + GridCard: React.FC; + GroupCard: React.FC; GroupIDSelect: React.FC; GroupList: React.FC; GroupSelect: React.FC; @@ -683,6 +685,7 @@ declare namespace PluginApi { HeaderImage: React.FC; HoverPopover: React.FC; Icon: React.FC; + ImageCard: React.FC; ImageInput: React.FC; ImageList: React.FC; LightboxLink: React.FC; @@ -726,6 +729,10 @@ declare namespace PluginApi { "SceneCard.Popovers": React.FC; SceneList: React.FC; SceneListOperations: React.FC; + SceneMarkerCard: React.FC; + "SceneMarkerCard.Details": React.FC; + "SceneMarkerCard.Image": React.FC; + "SceneMarkerCard.Popovers": React.FC; SceneMarkerList: React.FC; SelectSetting: React.FC; Setting: React.FC; @@ -733,6 +740,8 @@ declare namespace PluginApi { SettingModal: React.FC; StringListSetting: React.FC; StringSetting: React.FC; + StudioCard: React.FC; + StudioDetailsPanel: React.FC; StudioIDSelect: React.FC; StudioList: React.FC; StudioSelect: React.FC; From fa80454891fd87d07c51ed829ee575aa6e4bc29e Mon Sep 17 00:00:00 2001 From: hckrman101 <239247847+hckrman101@users.noreply.github.com> Date: Mon, 5 Jan 2026 19:46:29 -0500 Subject: [PATCH 016/177] Resume after scrubbing, hide player UI faster (#6336) --- .../src/components/ScenePlayer/ScenePlayer.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index 31e3e79be..36df653ba 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -364,7 +364,7 @@ export const ScenePlayer: React.FC = PatchComponent( }, nativeControlsForTouch: false, playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2], - inactivityTimeout: 2000, + inactivityTimeout: 700, preload: "none", playsinline: true, techOrder: ["chromecast", "html5"], @@ -932,15 +932,23 @@ export const ScenePlayer: React.FC = PatchComponent( ); }, [getPlayer, scene]); + const pausedBeforeScrubber = useRef(true); + function onScrubberScroll() { - if (started.current) { - getPlayer()?.pause(); + const player = getPlayer(); + if (started.current && player) { + pausedBeforeScrubber.current = player.paused(); + player.pause(); } } function onScrubberSeek(seconds: number) { - if (started.current) { - getPlayer()?.currentTime(seconds); + const player = getPlayer(); + if (started.current && player) { + player.currentTime(seconds); + if (!pausedBeforeScrubber.current) { + player.play(); + } } else { setTime(seconds); } From c0260781a59606d526837bd24884df4d545e35b0 Mon Sep 17 00:00:00 2001 From: CJ <72030708+cj12312021@users.noreply.github.com> Date: Mon, 5 Jan 2026 18:47:32 -0600 Subject: [PATCH 017/177] fix(scraper): handle base64 data URIs in processImageField (#6480) Add check to skip HTTP fetch for non-HTTP URLs in processImageField(), matching the existing behavior in setPerformerImage() and setStudioImage(). This allows scrapers to return base64 data URIs (e.g., `data:image/jpeg;base64,...`) directly without triggering an HTTP fetch error. Previously, processImageField() would attempt to create an HTTP request with the data URI as the URL, causing "Could not set image using URL" warnings. --- pkg/scraper/image.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/scraper/image.go b/pkg/scraper/image.go index 87f114668..2f2e038af 100644 --- a/pkg/scraper/image.go +++ b/pkg/scraper/image.go @@ -68,6 +68,12 @@ func processImageField(ctx context.Context, imageField *string, client *http.Cli return nil } + // don't try to get the image if it doesn't appear to be a URL + // this allows scrapers to return base64 data URIs directly + if !strings.HasPrefix(*imageField, "http") { + return nil + } + img, err := getImage(ctx, *imageField, client, globalConfig) if err != nil { return err From 9b709ef61457b9efb38cdb7f6f8401595855947a Mon Sep 17 00:00:00 2001 From: CJ <72030708+cj12312021@users.noreply.github.com> Date: Mon, 5 Jan 2026 18:48:16 -0600 Subject: [PATCH 018/177] Perf: Add lightweight ListGroupData fragment for groups list (#6478) Create a new ListGroupData fragment that excludes expensive recursive count fields (scene_count_all, sub_group_count_all, etc. with depth: -1). These fields cause 10+ second queries on large databases when loading the groups list page. The full GroupData fragment is preserved for detail views where the recursive counts are needed. --- ui/v2.5/graphql/data/group.graphql | 42 +++++++++++++++++++ ui/v2.5/graphql/queries/movie.graphql | 2 +- .../components/Groups/EditGroupsDialog.tsx | 6 +-- ui/v2.5/src/components/Groups/GroupCard.tsx | 2 +- .../src/components/Groups/GroupCardGrid.tsx | 2 +- ui/v2.5/src/components/Groups/GroupList.tsx | 2 +- .../components/Groups/RelatedGroupPopover.tsx | 2 +- 7 files changed, 50 insertions(+), 8 deletions(-) diff --git a/ui/v2.5/graphql/data/group.graphql b/ui/v2.5/graphql/data/group.graphql index 5251bed89..440c420da 100644 --- a/ui/v2.5/graphql/data/group.graphql +++ b/ui/v2.5/graphql/data/group.graphql @@ -1,3 +1,4 @@ +# Full fragment for detail views - includes recursive counts fragment GroupData on Group { id name @@ -39,3 +40,44 @@ fragment GroupData on Group { title } } + +# Lightweight fragment for list views - excludes expensive recursive counts +# The _all fields (depth: -1) cause 10+ second queries on large databases +fragment ListGroupData on Group { + id + name + aliases + duration + date + rating100 + director + + studio { + ...SlimStudioData + } + + tags { + ...SlimTagData + } + + containing_groups { + group { + ...SlimGroupData + } + description + } + + synopsis + urls + front_image_path + back_image_path + scene_count + performer_count + sub_group_count + o_counter + + scenes { + id + title + } +} diff --git a/ui/v2.5/graphql/queries/movie.graphql b/ui/v2.5/graphql/queries/movie.graphql index ad47e908d..2b2af7510 100644 --- a/ui/v2.5/graphql/queries/movie.graphql +++ b/ui/v2.5/graphql/queries/movie.graphql @@ -2,7 +2,7 @@ query FindGroups($filter: FindFilterType, $group_filter: GroupFilterType) { findGroups(filter: $filter, group_filter: $group_filter) { count groups { - ...GroupData + ...ListGroupData } } } diff --git a/ui/v2.5/src/components/Groups/EditGroupsDialog.tsx b/ui/v2.5/src/components/Groups/EditGroupsDialog.tsx index efd14e757..ef3171de2 100644 --- a/ui/v2.5/src/components/Groups/EditGroupsDialog.tsx +++ b/ui/v2.5/src/components/Groups/EditGroupsDialog.tsx @@ -23,12 +23,12 @@ import { ContainingGroupsMultiSet } from "./ContainingGroupsMultiSet"; import { IRelatedGroupEntry } from "./GroupDetails/RelatedGroupTable"; interface IListOperationProps { - selected: GQL.GroupDataFragment[]; + selected: GQL.ListGroupDataFragment[]; onClose: (applied: boolean) => void; } export function getAggregateContainingGroups( - state: Pick[] + state: Pick[] ) { const sortedLists: IRelatedGroupEntry[][] = state.map((o) => o.containing_groups @@ -144,7 +144,7 @@ export const EditGroupsDialog: React.FC = ( let updateDirector: string | undefined; let first = true; - state.forEach((group: GQL.GroupDataFragment) => { + state.forEach((group: GQL.ListGroupDataFragment) => { const groupTagIDs = (group.tags ?? []).map((p) => p.id).sort(); const groupContainingGroupIDs = (group.containing_groups ?? []).sort( (a, b) => a.group.id.localeCompare(b.group.id) diff --git a/ui/v2.5/src/components/Groups/GroupCard.tsx b/ui/v2.5/src/components/Groups/GroupCard.tsx index b9b206985..5bc1b5d7f 100644 --- a/ui/v2.5/src/components/Groups/GroupCard.tsx +++ b/ui/v2.5/src/components/Groups/GroupCard.tsx @@ -37,7 +37,7 @@ const Description: React.FC<{ }; interface IProps { - group: GQL.GroupDataFragment; + group: GQL.ListGroupDataFragment; cardWidth?: number; sceneNumber?: number; selecting?: boolean; diff --git a/ui/v2.5/src/components/Groups/GroupCardGrid.tsx b/ui/v2.5/src/components/Groups/GroupCardGrid.tsx index b73919e64..3ad6b02c4 100644 --- a/ui/v2.5/src/components/Groups/GroupCardGrid.tsx +++ b/ui/v2.5/src/components/Groups/GroupCardGrid.tsx @@ -7,7 +7,7 @@ import { } from "../Shared/GridCard/GridCard"; interface IGroupCardGrid { - groups: GQL.GroupDataFragment[]; + groups: GQL.ListGroupDataFragment[]; selectedIds: Set; zoomIndex: number; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; diff --git a/ui/v2.5/src/components/Groups/GroupList.tsx b/ui/v2.5/src/components/Groups/GroupList.tsx index 38fda7862..a08610569 100644 --- a/ui/v2.5/src/components/Groups/GroupList.tsx +++ b/ui/v2.5/src/components/Groups/GroupList.tsx @@ -199,7 +199,7 @@ export const GroupList: React.FC = PatchComponent( } function renderEditDialog( - selectedGroups: GQL.GroupDataFragment[], + selectedGroups: GQL.ListGroupDataFragment[], onClose: (applied: boolean) => void ) { return ; diff --git a/ui/v2.5/src/components/Groups/RelatedGroupPopover.tsx b/ui/v2.5/src/components/Groups/RelatedGroupPopover.tsx index 03095f284..a2eea9975 100644 --- a/ui/v2.5/src/components/Groups/RelatedGroupPopover.tsx +++ b/ui/v2.5/src/components/Groups/RelatedGroupPopover.tsx @@ -16,7 +16,7 @@ import { GroupTag } from "./GroupTag"; interface IProps { group: Pick< - GQL.GroupDataFragment, + GQL.ListGroupDataFragment, "id" | "name" | "containing_groups" | "sub_group_count" >; } From cf3489efdc450dc2812367e8c4d61fd1c9bf3051 Mon Sep 17 00:00:00 2001 From: Valkyr-JS <154020147+Valkyr-JS@users.noreply.github.com> Date: Sun, 11 Jan 2026 07:07:53 +0000 Subject: [PATCH 019/177] Plugin API - React Font Awesome library (#6487) * ReactFontAwesome added to plugin API libraries * ReactFontAwesome added to plugin API export --- ui/v2.5/src/pluginApi.d.ts | 1 + ui/v2.5/src/pluginApi.tsx | 2 ++ 2 files changed, 3 insertions(+) diff --git a/ui/v2.5/src/pluginApi.d.ts b/ui/v2.5/src/pluginApi.d.ts index 1746cbc44..9372f333d 100644 --- a/ui/v2.5/src/pluginApi.d.ts +++ b/ui/v2.5/src/pluginApi.d.ts @@ -617,6 +617,7 @@ declare namespace PluginApi { const FontAwesomeBrands: typeof import("@fortawesome/free-brands-svg-icons"); const Intl: typeof import("react-intl"); const Mousetrap: typeof import("mousetrap"); + const ReactFontAwesome: typeof import("@fortawesome/react-fontawesome"); const ReactSelect: typeof import("react-select"); // @ts-expect-error diff --git a/ui/v2.5/src/pluginApi.tsx b/ui/v2.5/src/pluginApi.tsx index e534dddef..276091cde 100644 --- a/ui/v2.5/src/pluginApi.tsx +++ b/ui/v2.5/src/pluginApi.tsx @@ -12,6 +12,7 @@ import * as Intl from "react-intl"; import * as FontAwesomeSolid from "@fortawesome/free-solid-svg-icons"; import * as FontAwesomeRegular from "@fortawesome/free-regular-svg-icons"; import * as FontAwesomeBrands from "@fortawesome/free-brands-svg-icons"; +import * as ReactFontAwesome from "@fortawesome/react-fontawesome"; import * as ReactSelect from "react-select"; import { useSpriteInfo } from "./hooks/sprite"; import { useToast } from "./hooks/Toast"; @@ -78,6 +79,7 @@ export const PluginApi = { FontAwesomeBrands, Mousetrap, MousetrapPause, + ReactFontAwesome, ReactSelect, }, register: { From c9fa3b76d996c15c962158da2c10506d3218c2e4 Mon Sep 17 00:00:00 2001 From: funntime Date: Sun, 11 Jan 2026 18:46:50 -0500 Subject: [PATCH 020/177] Update chromedp and cdproto dependencies (#6486) --- go.mod | 11 +++++------ go.sum | 24 ++++++++++-------------- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/go.mod b/go.mod index 705fe40ed..db0d6fe34 100644 --- a/go.mod +++ b/go.mod @@ -9,8 +9,8 @@ require ( github.com/anacrolix/dms v1.2.2 github.com/antchfx/htmlquery v1.3.5 github.com/asticode/go-astisub v0.25.1 - github.com/chromedp/cdproto v0.0.0-20231007061347-18b01cd81617 - github.com/chromedp/chromedp v0.9.2 + github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d + github.com/chromedp/chromedp v0.14.2 github.com/corona10/goimagehash v1.1.0 github.com/disintegration/imaging v1.6.2 github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d @@ -72,17 +72,18 @@ require ( github.com/antchfx/xpath v1.3.5 // indirect github.com/asticode/go-astikit v0.20.0 // indirect github.com/asticode/go-astits v1.8.0 // indirect - github.com/chromedp/sysutil v1.0.0 // indirect + github.com/chromedp/sysutil v1.1.0 // indirect github.com/coder/websocket v1.8.12 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.7.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect - github.com/gobwas/ws v1.3.0 // indirect + github.com/gobwas/ws v1.4.0 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect @@ -90,10 +91,8 @@ require ( github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/josharian/intern v1.0.0 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect github.com/magiconair/properties v1.8.7 // indirect - github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect diff --git a/go.sum b/go.sum index 750acd9ab..dbe82cf99 100644 --- a/go.sum +++ b/go.sum @@ -116,13 +116,12 @@ github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= -github.com/chromedp/cdproto v0.0.0-20231007061347-18b01cd81617 h1:/5dwcyi5WOawM1Iz6MjrYqB90TRIdZv3O0fVHEJb86w= -github.com/chromedp/cdproto v0.0.0-20231007061347-18b01cd81617/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= -github.com/chromedp/chromedp v0.9.2 h1:dKtNz4kApb06KuSXoTQIyUC2TrA0fhGDwNZf3bcgfKw= -github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs= -github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= -github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= +github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU= +github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= +github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM= +github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= @@ -206,6 +205,8 @@ github.com/go-chi/httplog v0.3.1/go.mod h1:UoiQQ/MTZH5V6JbNB2FzF0DynTh5okpXxlhsy github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= +github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= @@ -224,9 +225,8 @@ github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= -github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= -github.com/gobwas/ws v1.3.0 h1:sbeU3Y4Qzlb+MOzIe6mQGf7QR4Hkv6ZD0qhGkBFL2O0= -github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -380,8 +380,6 @@ github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -433,8 +431,6 @@ github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc8 github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= From 579fc662756c3760ba5994d1cfdffe3253159fd6 Mon Sep 17 00:00:00 2001 From: RyanAtNight <232988350+RyanAtNight@users.noreply.github.com> Date: Sun, 11 Jan 2026 16:06:57 -0800 Subject: [PATCH 021/177] Add checkbox controls to Wall View and Tagger for Scenes, Scene Markers, Images, and Galleries (#6476) --- .../src/components/Galleries/GalleryList.tsx | 10 ++- .../components/Galleries/GalleryWallCard.tsx | 49 +++++++++++-- ui/v2.5/src/components/Images/ImageList.tsx | 30 +++++++- .../src/components/Images/ImageWallItem.tsx | 70 ++++++++++++++----- ui/v2.5/src/components/Scenes/SceneList.tsx | 11 ++- .../src/components/Scenes/SceneMarkerList.tsx | 2 + .../Scenes/SceneMarkerWallPanel.tsx | 70 ++++++++++++++++++- .../src/components/Scenes/SceneWallPanel.tsx | 64 ++++++++++++++++- ui/v2.5/src/components/Shared/styles.scss | 36 ++++++++-- .../components/Tagger/scenes/SceneTagger.tsx | 45 ++++++++++-- .../components/Tagger/scenes/TaggerScene.tsx | 26 ++++++- ui/v2.5/src/components/Tagger/styles.scss | 4 ++ ui/v2.5/src/locales/en-GB.json | 1 + 13 files changed, 378 insertions(+), 40 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryList.tsx b/ui/v2.5/src/components/Galleries/GalleryList.tsx index d18aadbd3..952f27808 100644 --- a/ui/v2.5/src/components/Galleries/GalleryList.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryList.tsx @@ -153,7 +153,15 @@ export const GalleryList: React.FC = PatchComponent(
{result.data.findGalleries.galleries.map((gallery) => ( - + + onSelectChange(gallery.id, selected, shiftKey) + } + selecting={selectedIds.size > 0} + /> ))}
diff --git a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx index c57bf45ad..c1501bd9d 100644 --- a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx @@ -1,4 +1,5 @@ import React, { useState } from "react"; +import { Form } from "react-bootstrap"; import { useIntl } from "react-intl"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; @@ -8,6 +9,7 @@ import { useGalleryLightbox } from "src/hooks/Lightbox/hooks"; import { galleryTitle } from "src/core/galleries"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { GalleryPreviewScrubber } from "./GalleryPreviewScrubber"; +import { useDragMoveSelect } from "../Shared/GridCard/dragMoveSelect"; import cx from "classnames"; const CLASSNAME = "GalleryWallCard"; @@ -18,6 +20,9 @@ const CLASSNAME_IMG_CONTAIN = `${CLASSNAME}-img-contain`; interface IProps { gallery: GQL.SlimGalleryDataFragment; + selected?: boolean; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; + selecting?: boolean; } type Orientation = "landscape" | "portrait"; @@ -26,7 +31,12 @@ function getOrientation(width: number, height: number): Orientation { return width > height ? "landscape" : "portrait"; } -const GalleryWallCard: React.FC = ({ gallery }) => { +const GalleryWallCard: React.FC = ({ + gallery, + selected, + onSelectedChanged, + selecting, +}) => { const intl = useIntl(); const [coverOrientation, setCoverOrientation] = React.useState("landscape"); @@ -34,6 +44,12 @@ const GalleryWallCard: React.FC = ({ gallery }) => { React.useState("landscape"); const showLightbox = useGalleryLightbox(gallery.id, gallery.chapters); + const { dragProps } = useDragMoveSelect({ + selecting: selecting || false, + selected: selected || false, + onSelectedChanged: onSelectedChanged, + }); + const cover = gallery?.paths.cover; function onCoverLoad(e: React.SyntheticEvent) { @@ -58,6 +74,14 @@ const GalleryWallCard: React.FC = ({ gallery }) => { ? [...performerNames.slice(0, -2), performerNames.slice(-2).join(" & ")] : performerNames; + function handleCardClick(event: React.MouseEvent) { + if (selecting && onSelectedChanged) { + onSelectedChanged(!selected, event.shiftKey); + return; + } + showLightboxStart(); + } + async function showLightboxStart() { if (gallery.image_count === 0) { return; @@ -69,15 +93,32 @@ const GalleryWallCard: React.FC = ({ gallery }) => { const imgClassname = imageOrientation !== coverOrientation ? CLASSNAME_IMG_CONTAIN : ""; + let shiftKey = false; + return ( <>
showLightboxStart()} role="button" tabIndex={0} + {...dragProps} > + {onSelectedChanged && ( + onSelectedChanged(!selected, shiftKey)} + onClick={( + event: React.MouseEvent + ) => { + shiftKey = event.shiftKey; + event.stopPropagation(); + }} + /> + )} void; zoomIndex: number; + selectedIds?: Set; + onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void; + selecting?: boolean; } const zoomWidths = [280, 340, 480, 640]; @@ -49,6 +52,9 @@ const ImageWall: React.FC = ({ images, zoomIndex, handleImageOpen, + selectedIds, + onSelectChange, + selecting, }) => { const { configuration } = useConfigurationContext(); const uiConfig = configuration?.ui; @@ -121,9 +127,26 @@ const ImageWall: React.FC = ({ ? props.photo.height : targetRowHeight(containerRef.current?.offsetWidth ?? 0) * maxHeightFactor; - return ; + const imageId = props.photo.key; + if (!imageId) { + return null; + } + return ( + + onSelectChange(imageId, selected, shiftKey) + : undefined + } + selecting={selecting} + /> + ); }, - [targetRowHeight] + [targetRowHeight, selectedIds, onSelectChange, selecting] ); return ( @@ -258,6 +281,9 @@ const ImageListImages: React.FC = ({ pageCount={pageCount} handleImageOpen={handleImageOpen} zoomIndex={filter.zoomIndex} + selectedIds={selectedIds} + onSelectChange={onSelectChange} + selecting={!!selectedIds && selectedIds.size > 0} /> ); } diff --git a/ui/v2.5/src/components/Images/ImageWallItem.tsx b/ui/v2.5/src/components/Images/ImageWallItem.tsx index 901295192..a9f681474 100644 --- a/ui/v2.5/src/components/Images/ImageWallItem.tsx +++ b/ui/v2.5/src/components/Images/ImageWallItem.tsx @@ -1,32 +1,50 @@ import React from "react"; +import { Form } from "react-bootstrap"; import type { RenderImageProps } from "react-photo-gallery"; +import { useDragMoveSelect } from "../Shared/GridCard/dragMoveSelect"; interface IExtraProps { maxHeight: number; + selected?: boolean; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; + selecting?: boolean; } export const ImageWallItem: React.FC = ( props: RenderImageProps & IExtraProps ) => { + const { dragProps } = useDragMoveSelect({ + selecting: props.selecting || false, + selected: props.selected || false, + onSelectedChanged: props.onSelectedChanged, + }); + const height = Math.min(props.maxHeight, props.photo.height); const zoomFactor = height / props.photo.height; const width = props.photo.width * zoomFactor; type style = Record; - var imgStyle: style = { + var divStyle: style = { margin: props.margin, display: "block", + position: "relative", }; if (props.direction === "column") { - imgStyle.position = "absolute"; - imgStyle.left = props.left; - imgStyle.top = props.top; + divStyle.position = "absolute"; + divStyle.left = props.left; + divStyle.top = props.top; } var handleClick = function handleClick( event: React.MouseEvent ) { + if (props.selecting && props.onSelectedChanged) { + props.onSelectedChanged(!props.selected, event.shiftKey); + event.preventDefault(); + event.stopPropagation(); + return; + } if (props.onClick) { props.onClick(event, { index: props.index }); } @@ -35,19 +53,39 @@ export const ImageWallItem: React.FC = ( const video = props.photo.src.includes("preview"); const ImagePreview = video ? "video" : "img"; + let shiftKey = false; + return ( - + {...dragProps} + > + {props.onSelectedChanged && ( + props.onSelectedChanged!(!props.selected, shiftKey)} + onClick={(event: React.MouseEvent) => { + shiftKey = event.shiftKey; + event.stopPropagation(); + }} + /> + )} + +
); }; diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 8258f9b57..d139c6a72 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -235,11 +235,20 @@ const SceneList: React.FC<{ scenes={scenes} sceneQueue={queue} zoomIndex={filter.zoomIndex} + selectedIds={selectedIds} + onSelectChange={onSelectChange} /> ); } if (filter.displayMode === DisplayMode.Tagger) { - return ; + return ( + + ); } return null; diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx index 08d4a4046..074ee2b83 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx @@ -101,6 +101,8 @@ export const SceneMarkerList: React.FC = PatchComponent( ); } diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx index 0349fae0f..863078c4e 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Form } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import Gallery, { GalleryI, @@ -10,6 +11,7 @@ import { objectTitle } from "src/core/files"; import { Link, useHistory } from "react-router-dom"; import { TruncatedText } from "../Shared/TruncatedText"; import TextUtils from "src/utils/text"; +import { useDragMoveSelect } from "../Shared/GridCard/dragMoveSelect"; import cx from "classnames"; import NavUtils from "src/utils/navigation"; import { markerTitle } from "src/core/markers"; @@ -35,11 +37,20 @@ interface IMarkerPhoto { interface IExtraProps { maxHeight: number; + selected?: boolean; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; + selecting?: boolean; } export const MarkerWallItem: React.FC< RenderImageProps & IExtraProps > = (props: RenderImageProps & IExtraProps) => { + const { dragProps } = useDragMoveSelect({ + selecting: props.selecting || false, + selected: props.selected || false, + onSelectedChanged: props.onSelectedChanged, + }); + const { configuration } = useConfigurationContext(); const playSound = configuration?.interface.soundOnPreview ?? false; const showTitle = configuration?.interface.wallShowTitle ?? false; @@ -63,6 +74,12 @@ export const MarkerWallItem: React.FC< } var handleClick = function handleClick(event: React.MouseEvent) { + if (props.selecting && props.onSelectedChanged) { + props.onSelectedChanged(!props.selected, event.shiftKey); + event.preventDefault(); + event.stopPropagation(); + return; + } if (props.onClick) { props.onClick(event, { index: props.index }); } @@ -75,16 +92,32 @@ export const MarkerWallItem: React.FC< const title = wallItemTitle(marker); const tagNames = marker.tags.map((p) => p.name); + let shiftKey = false; + return (
+ {props.onSelectedChanged && ( + props.onSelectedChanged!(!props.selected, shiftKey)} + onClick={(event: React.MouseEvent) => { + shiftKey = event.shiftKey; + event.stopPropagation(); + }} + /> + )} ; + onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void; + selecting?: boolean; } // HACK: typescript doesn't allow Gallery to accept a parameter for some reason @@ -163,7 +199,13 @@ const breakpointZoomHeights = [ { minWidth: 1400, heights: [160, 240, 300, 480] }, ]; -const MarkerWall: React.FC = ({ markers, zoomIndex }) => { +const MarkerWall: React.FC = ({ + markers, + zoomIndex, + selectedIds, + onSelectChange, + selecting, +}) => { const history = useHistory(); const containerRef = React.useRef(null); @@ -233,6 +275,7 @@ const MarkerWall: React.FC = ({ markers, zoomIndex }) => { const renderImage = useCallback( (props: RenderImageProps) => { + const markerId = props.photo.marker.id; return ( = ({ markers, zoomIndex }) => { targetRowHeight(containerRef.current?.offsetWidth ?? 0) * maxHeightFactor } + selected={selectedIds?.has(markerId)} + onSelectedChanged={ + onSelectChange + ? (selected, shiftKey) => + onSelectChange(markerId, selected, shiftKey) + : undefined + } + selecting={selecting} /> ); }, - [targetRowHeight] + [targetRowHeight, selectedIds, onSelectChange, selecting] ); return ( @@ -266,11 +317,24 @@ const MarkerWall: React.FC = ({ markers, zoomIndex }) => { interface IMarkerWallPanelProps { markers: GQL.SceneMarkerDataFragment[]; zoomIndex: number; + selectedIds?: Set; + onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void; } export const MarkerWallPanel: React.FC = ({ markers, zoomIndex, + selectedIds, + onSelectChange, }) => { - return ; + const selecting = !!selectedIds && selectedIds.size > 0; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx b/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx index 92aa21f59..bf4a97b49 100644 --- a/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Form } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import { SceneQueue } from "src/models/sceneQueue"; import Gallery, { @@ -12,6 +13,7 @@ import { Link, useHistory } from "react-router-dom"; import { TruncatedText } from "../Shared/TruncatedText"; import TextUtils from "src/utils/text"; import { useIntl } from "react-intl"; +import { useDragMoveSelect } from "../Shared/GridCard/dragMoveSelect"; import cx from "classnames"; interface IScenePhoto { @@ -22,6 +24,9 @@ interface IScenePhoto { interface IExtraProps { maxHeight: number; + selected?: boolean; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; + selecting?: boolean; } export const SceneWallItem: React.FC< @@ -29,6 +34,12 @@ export const SceneWallItem: React.FC< > = (props: RenderImageProps & IExtraProps) => { const intl = useIntl(); + const { dragProps } = useDragMoveSelect({ + selecting: props.selecting || false, + selected: props.selected || false, + onSelectedChanged: props.onSelectedChanged, + }); + const { configuration } = useConfigurationContext(); const playSound = configuration?.interface.soundOnPreview ?? false; const showTitle = configuration?.interface.wallShowTitle ?? false; @@ -52,6 +63,12 @@ export const SceneWallItem: React.FC< } var handleClick = function handleClick(event: React.MouseEvent) { + if (props.selecting && props.onSelectedChanged) { + props.onSelectedChanged(!props.selected, event.shiftKey); + event.preventDefault(); + event.stopPropagation(); + return; + } if (props.onClick) { props.onClick(event, { index: props.index }); } @@ -68,16 +85,32 @@ export const SceneWallItem: React.FC< ? [...performerNames.slice(0, -2), performerNames.slice(-2).join(" & ")] : performerNames; + let shiftKey = false; + return (
+ {props.onSelectedChanged && ( + props.onSelectedChanged!(!props.selected, shiftKey)} + onClick={(event: React.MouseEvent) => { + shiftKey = event.shiftKey; + event.stopPropagation(); + }} + /> + )} ; + onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void; + selecting?: boolean; } // HACK: typescript doesn't allow Gallery to accept a parameter for some reason @@ -148,6 +184,9 @@ const SceneWall: React.FC = ({ scenes, sceneQueue, zoomIndex, + selectedIds, + onSelectChange, + selecting, }) => { const history = useHistory(); @@ -223,6 +262,7 @@ const SceneWall: React.FC = ({ const renderImage = useCallback( (props: RenderImageProps) => { + const sceneId = props.photo.scene.id; return ( = ({ targetRowHeight(containerRef.current?.offsetWidth ?? 0) * maxHeightFactor } + selected={selectedIds?.has(sceneId)} + onSelectedChanged={ + onSelectChange + ? (selected, shiftKey) => + onSelectChange(sceneId, selected, shiftKey) + : undefined + } + selecting={selecting} /> ); }, - [targetRowHeight] + [targetRowHeight, selectedIds, onSelectChange, selecting] ); return ( @@ -257,14 +305,26 @@ interface ISceneWallPanelProps { scenes: GQL.SlimSceneDataFragment[]; sceneQueue?: SceneQueue; zoomIndex: number; + selectedIds?: Set; + onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void; } export const SceneWallPanel: React.FC = ({ scenes, sceneQueue, zoomIndex, + selectedIds, + onSelectChange, }) => { + const selecting = !!selectedIds && selectedIds.size > 0; return ( - + ); }; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index aed03cef9..100b4e643 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -284,6 +284,10 @@ button.collapse-button { opacity: 0; width: 1.2rem; + &:checked { + opacity: 0.75; + } + @media (hover: none), (pointer: coarse) { // always show card controls when hovering not supported opacity: 0.25; @@ -297,10 +301,6 @@ button.collapse-button { .card-check { padding-left: 15px; - &:checked { - opacity: 0.75; - } - @media (hover: none), (pointer: coarse) { // and make it bigger when hovering not supported width: 1.5rem; @@ -314,6 +314,34 @@ button.collapse-button { } } +.search-item-check, +.wall-item-check { + height: 1.2rem; + width: 1.2rem; +} + +// Wall item checkbox styles +.wall-item-check { + left: 0.5rem; + opacity: 0; + position: absolute; + top: 0.5rem; + z-index: 10; + + &:checked { + opacity: 0.75; + } + + @media (hover: none) { + opacity: 0.25; + } +} + +.wall-item:hover .wall-item-check { + opacity: 0.75; + transition: opacity 0.5s; +} + .TruncatedText { -webkit-box-orient: vertical; display: -webkit-box; diff --git a/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx b/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx index 34c86e57c..76a67e306 100755 --- a/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx @@ -22,7 +22,17 @@ const Scene: React.FC<{ queue?: SceneQueue; index: number; showLightboxImage: (imagePath: string) => void; -}> = ({ scene, searchResult, queue, index, showLightboxImage }) => { + selected?: boolean; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; +}> = ({ + scene, + searchResult, + queue, + index, + showLightboxImage, + selected, + onSelectedChanged, +}) => { const intl = useIntl(); const { currentSource, doSceneQuery, doSceneFragmentScrape, loading } = useContext(TaggerStateContext); @@ -71,6 +81,8 @@ const Scene: React.FC<{ showLightboxImage={showLightboxImage} queue={queue} index={index} + selected={selected} + onSelectedChanged={onSelectedChanged} > {searchResult && searchResult.results?.length ? ( @@ -82,9 +94,16 @@ const Scene: React.FC<{ interface ITaggerProps { scenes: GQL.SlimSceneDataFragment[]; queue?: SceneQueue; + selectedIds: Set; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; } -export const Tagger: React.FC = ({ scenes, queue }) => { +export const Tagger: React.FC = ({ + scenes, + queue, + selectedIds, + onSelectChange, +}) => { const { sources, setCurrentSource, @@ -103,6 +122,8 @@ export const Tagger: React.FC = ({ scenes, queue }) => { const intl = useIntl(); + const hasSelection = selectedIds.size > 0; + function handleSourceSelect(e: React.ChangeEvent) { setCurrentSource(sources!.find((s) => s.id === e.currentTarget.value)); } @@ -211,7 +232,12 @@ export const Tagger: React.FC = ({ scenes, queue }) => { return; } - if (scenes.length === 0) { + // Use selected scenes if any, otherwise all scenes + const scenesToScrape = hasSelection + ? scenes.filter((s) => selectedIds.has(s.id)) + : scenes; + + if (scenesToScrape.length === 0) { return; } @@ -232,15 +258,20 @@ export const Tagger: React.FC = ({ scenes, queue }) => { ); } + // Change button text based on selection state + const buttonTextId = hasSelection + ? "component_tagger.verb_scrape_selected" + : "component_tagger.verb_scrape_all"; + return (
{ - await doMultiSceneFragmentScrape(scenes.map((s) => s.id)); + await doMultiSceneFragmentScrape(scenesToScrape.map((s) => s.id)); }} > - {intl.formatMessage({ id: "component_tagger.verb_scrape_all" })} + {intl.formatMessage({ id: buttonTextId })} {multiError && ( <> @@ -276,6 +307,10 @@ export const Tagger: React.FC = ({ scenes, queue }) => { index={i} showLightboxImage={showLightboxImage} queue={queue} + selected={selectedIds.has(s.id)} + onSelectedChanged={(selected, shiftKey) => + onSelectChange(s.id, selected, shiftKey) + } /> ))}
diff --git a/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx b/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx index 4825ebcfd..5446257e5 100644 --- a/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx @@ -116,6 +116,8 @@ interface ITaggerScene { showLightboxImage: (imagePath: string) => void; queue?: SceneQueue; index?: number; + selected?: boolean; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; } export const TaggerScene: React.FC> = ({ @@ -129,6 +131,8 @@ export const TaggerScene: React.FC> = ({ showLightboxImage, queue, index, + selected, + onSelectedChanged, }) => { const { config } = useContext(TaggerStateContext); const [queryString, setQueryString] = useState(""); @@ -235,10 +239,28 @@ export const TaggerScene: React.FC> = ({ history.push(link); } + let shiftKey = false; + return (
-
+ {onSelectedChanged && ( +
+ onSelectedChanged(!selected, shiftKey)} + onClick={( + event: React.MouseEvent + ) => { + shiftKey = event.shiftKey; + event.stopPropagation(); + }} + /> +
+ )} +
> = ({
-
+
{renderQueryForm()} {scrapeSceneFragment ? ( diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index 889d6b1b4..8861d0043 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -56,6 +56,10 @@ } } +.search-item-check { + cursor: pointer; +} + .search-result { background-color: rgba(61, 80, 92, 0.3); padding: 1rem 0; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index f165c03cd..9c040bb1a 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -230,6 +230,7 @@ "verb_match_tag": "Match Tag", "verb_matched": "Matched", "verb_scrape_all": "Scrape All", + "verb_scrape_selected": "Scrape Selected", "verb_submit_fp": "Submit {fpCount, plural, one{# Fingerprint} other{# Fingerprints}}", "verb_toggle_config": "{toggle} {configuration}", "verb_toggle_unmatched": "{toggle} unmatched scenes" From 95b1bce91739f7267903dad08fd3b5c0395815fa Mon Sep 17 00:00:00 2001 From: CJ <72030708+cj12312021@users.noreply.github.com> Date: Sun, 11 Jan 2026 18:12:03 -0600 Subject: [PATCH 022/177] fix(dlna): improve activity tracking accuracy and efficiency (#6483) * fix(dlna): improve activity tracking accuracy and efficiency - Remove play duration tracking: DLNA clients buffer aggressively and don't report playback position, making duration estimates unreliable. Saving inaccurate values corrupts analytics. - Combine database transactions: Resume time and view count updates now happen in a single transaction for atomicity and performance. - Keep resume time tracking: While imprecise, it provides useful "continue watching" hints. The cost of being wrong is low (user just seeks). * remove elasped time check --- internal/dlna/activity.go | 76 +++++++++++++++------------------- internal/dlna/activity_test.go | 54 ++---------------------- 2 files changed, 38 insertions(+), 92 deletions(-) diff --git a/internal/dlna/activity.go b/internal/dlna/activity.go index 34f0081d7..a9a5d9b2d 100644 --- a/internal/dlna/activity.go +++ b/internal/dlna/activity.go @@ -82,16 +82,6 @@ func (s *streamSession) percentWatched() float64 { return timeBasedPercent } -// estimatedPlayDuration returns the estimated play duration in seconds. -// Uses elapsed time from session start to last activity, capped by video duration. -func (s *streamSession) estimatedPlayDuration() float64 { - elapsed := s.LastActivity.Sub(s.StartTime).Seconds() - if s.VideoDuration > 0 && elapsed > s.VideoDuration { - return s.VideoDuration - } - return elapsed -} - // estimatedResumeTime calculates the estimated resume time based on elapsed time. // Since DLNA clients buffer aggressively, byte positions don't correlate with playback. // Instead, we estimate based on how long the session has been active. @@ -271,14 +261,13 @@ func (t *ActivityTracker) processExpiredSessions() { // processCompletedSession saves activity data for a completed streaming session. func (t *ActivityTracker) processCompletedSession(session *streamSession) { percentWatched := session.percentWatched() - playDuration := session.estimatedPlayDuration() resumeTime := session.estimatedResumeTime() - logger.Debugf("[DLNA Activity] Session completed: scene=%d, client=%s, duration=%.1fs, startTime=%s, lastActivity=%s, percent=%.1f%%, duration=%.1fs, resume=%.1fs", - session.SceneID, session.ClientIP, session.VideoDuration, session.StartTime.String(), session.LastActivity.String(), percentWatched, playDuration, resumeTime) + logger.Debugf("[DLNA Activity] Session completed: scene=%d, client=%s, videoDuration=%.1fs, percent=%.1f%%, resume=%.1fs", + session.SceneID, session.ClientIP, session.VideoDuration, percentWatched, resumeTime) - // Only save if there was meaningful activity (at least 1% watched or 5 seconds) - if percentWatched < 1 && playDuration < 5 { + // Only save if there was meaningful activity (at least 1% watched) + if percentWatched < 1 { logger.Debugf("[DLNA Activity] Session too short, skipping save") return } @@ -289,38 +278,41 @@ func (t *ActivityTracker) processCompletedSession(session *streamSession) { return } - ctx := context.Background() + // Determine what needs to be saved + shouldSaveResume := resumeTime > 0 + shouldAddView := !session.PlayCountAdded && percentWatched >= float64(t.getMinimumPlayPercent()) - // Save activity (resume time and play duration) - if playDuration > 0 || resumeTime > 0 { - var resumeTimePtr *float64 - if resumeTime > 0 { - resumeTimePtr = &resumeTime - } - - if err := txn.WithTxn(ctx, t.txnManager, func(ctx context.Context) error { - _, err := t.sceneWriter.SaveActivity(ctx, session.SceneID, resumeTimePtr, &playDuration) - return err - }); err != nil { - logger.Warnf("[DLNA Activity] Failed to save activity for scene %d: %v", session.SceneID, err) - } + // Nothing to save + if !shouldSaveResume && !shouldAddView { + return } - // Increment play count if threshold met - if !session.PlayCountAdded { - minPercent := t.getMinimumPlayPercent() - if percentWatched >= float64(minPercent) { - if err := txn.WithTxn(ctx, t.txnManager, func(ctx context.Context) error { - _, err := t.sceneWriter.AddViews(ctx, session.SceneID, []time.Time{time.Now()}) - return err - }); err != nil { - logger.Warnf("[DLNA Activity] Failed to increment play count for scene %d: %v", session.SceneID, err) - } else { - logger.Debugf("[DLNA Activity] Incremented play count for scene %d (%.1f%% watched)", - session.SceneID, percentWatched) - session.PlayCountAdded = true + // Save everything in a single transaction + ctx := context.Background() + if err := txn.WithTxn(ctx, t.txnManager, func(ctx context.Context) error { + // Save resume time only. DLNA clients buffer aggressively and don't report + // playback position, so we can't accurately track play duration - saving + // guesses would corrupt analytics. Resume time is still useful as a + // "continue watching" hint even if imprecise. + if shouldSaveResume { + if _, err := t.sceneWriter.SaveActivity(ctx, session.SceneID, &resumeTime, nil); err != nil { + return fmt.Errorf("save resume time: %w", err) } } + + // Increment play count (also updates last_played_at via view date) + if shouldAddView { + if _, err := t.sceneWriter.AddViews(ctx, session.SceneID, []time.Time{time.Now()}); err != nil { + return fmt.Errorf("add view: %w", err) + } + session.PlayCountAdded = true + logger.Debugf("[DLNA Activity] Incremented play count for scene %d (%.1f%% watched)", + session.SceneID, percentWatched) + } + + return nil + }); err != nil { + logger.Warnf("[DLNA Activity] Failed to save activity for scene %d: %v", session.SceneID, err) } } diff --git a/internal/dlna/activity_test.go b/internal/dlna/activity_test.go index 3c4d890ba..19ae7ebb8 100644 --- a/internal/dlna/activity_test.go +++ b/internal/dlna/activity_test.go @@ -129,52 +129,6 @@ func TestStreamSession_PercentWatched(t *testing.T) { } } -func TestStreamSession_EstimatedPlayDuration(t *testing.T) { - now := time.Now() - - tests := []struct { - name string - startTime time.Time - lastActivity time.Time - videoDuration float64 - expected float64 - }{ - { - name: "elapsed less than duration", - startTime: now.Add(-30 * time.Second), - lastActivity: now, - videoDuration: 120, - expected: 30.0, - }, - { - name: "elapsed exceeds duration - capped", - startTime: now.Add(-180 * time.Second), - lastActivity: now, - videoDuration: 120, - expected: 120.0, - }, - { - name: "no duration limit", - startTime: now.Add(-300 * time.Second), - lastActivity: now, - videoDuration: 0, - expected: 300.0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - session := &streamSession{ - StartTime: tt.startTime, - LastActivity: tt.lastActivity, - VideoDuration: tt.videoDuration, - } - result := session.estimatedPlayDuration() - assert.InDelta(t, tt.expected, result, 1.0) // Allow 1 second tolerance - }) - } -} - func TestStreamSession_EstimatedResumeTime(t *testing.T) { now := time.Now() @@ -455,12 +409,12 @@ func TestActivityTracker_ShortSessionIgnored(t *testing.T) { // Verify percent watched is below threshold (1s / 120s = 0.83%) assert.InDelta(t, 0.83, session.percentWatched(), 0.1) - // Verify play duration is short - assert.InDelta(t, 1.0, session.estimatedPlayDuration(), 0.5) + // Verify elapsed time is short + elapsed := session.LastActivity.Sub(session.StartTime).Seconds() + assert.InDelta(t, 1.0, elapsed, 0.5) // Both are below the minimum thresholds (1% and 5 seconds) percentWatched := session.percentWatched() - playDuration := session.estimatedPlayDuration() - shouldSkip := percentWatched < 1 && playDuration < 5 + shouldSkip := percentWatched < 1 && elapsed < 5 assert.True(t, shouldSkip, "Short session should be skipped") } From deada580e521b9ac1766ae3b69c2db0cef4df968 Mon Sep 17 00:00:00 2001 From: ghuds540 Date: Sun, 11 Jan 2026 19:17:41 -0500 Subject: [PATCH 023/177] fix: align card images to center (#6481) --- ui/v2.5/src/components/Images/styles.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Images/styles.scss b/ui/v2.5/src/components/Images/styles.scss index 8bc736aae..0050a9434 100644 --- a/ui/v2.5/src/components/Images/styles.scss +++ b/ui/v2.5/src/components/Images/styles.scss @@ -86,6 +86,7 @@ } &-preview { + align-items: center; display: flex; justify-content: center; margin-bottom: 5px; @@ -94,7 +95,6 @@ &-image { height: 100%; object-fit: contain; - object-position: top; width: 100%; } From 6049b21d22bbb0bd032560071a7ae495cd0b763d Mon Sep 17 00:00:00 2001 From: Valkyr-JS <154020147+Valkyr-JS@users.noreply.github.com> Date: Tue, 13 Jan 2026 04:49:50 +0000 Subject: [PATCH 024/177] Plugin API - card grid components (#6482) * SceneCardsGrid plugin API patch * GalleryCardGrid plugin API patch * GroupCardGrid plugin API patch * ImageGridCard plugin API patch * PerformerCardGrid plugin API patch * ImageGridCard name corrected * SceneMarkerCardsGrid plugin API patch * StudioCardGrid plugin API patch * TagCardGrid plugin API patch * GalleryGridCard.tsx renamed to GalleryCardGrid.tsx * ImageGridCard renamed to ImageCardGrid * SceneCardsGrid renamed to SceneCardGrid * SceneMarkerCardsGrid renamed to SceneMarkerCardGrid --- .../components/Galleries/GalleryCardGrid.tsx | 43 ++++++++++++++ .../components/Galleries/GalleryGridCard.tsx | 44 -------------- .../src/components/Galleries/GalleryList.tsx | 2 +- .../src/components/Groups/GroupCardGrid.tsx | 57 +++++++++---------- .../src/components/Images/ImageCardGrid.tsx | 47 +++++++++++++++ .../src/components/Images/ImageGridCard.tsx | 49 ---------------- ui/v2.5/src/components/Images/ImageList.tsx | 4 +- .../Performers/PerformerCardGrid.tsx | 54 +++++++++--------- .../src/components/Scenes/SceneCardGrid.tsx | 50 ++++++++++++++++ .../src/components/Scenes/SceneCardsGrid.tsx | 53 ----------------- ui/v2.5/src/components/Scenes/SceneList.tsx | 4 +- .../components/Scenes/SceneMarkerCardGrid.tsx | 46 +++++++++++++++ .../Scenes/SceneMarkerCardsGrid.tsx | 45 --------------- .../src/components/Scenes/SceneMarkerList.tsx | 4 +- .../src/components/Studios/StudioCardGrid.tsx | 54 +++++++++--------- ui/v2.5/src/components/Tags/TagCardGrid.tsx | 51 ++++++++--------- ui/v2.5/src/pluginApi.d.ts | 8 +++ 17 files changed, 305 insertions(+), 310 deletions(-) create mode 100644 ui/v2.5/src/components/Galleries/GalleryCardGrid.tsx delete mode 100644 ui/v2.5/src/components/Galleries/GalleryGridCard.tsx create mode 100644 ui/v2.5/src/components/Images/ImageCardGrid.tsx delete mode 100644 ui/v2.5/src/components/Images/ImageGridCard.tsx create mode 100644 ui/v2.5/src/components/Scenes/SceneCardGrid.tsx delete mode 100644 ui/v2.5/src/components/Scenes/SceneCardsGrid.tsx create mode 100644 ui/v2.5/src/components/Scenes/SceneMarkerCardGrid.tsx delete mode 100644 ui/v2.5/src/components/Scenes/SceneMarkerCardsGrid.tsx diff --git a/ui/v2.5/src/components/Galleries/GalleryCardGrid.tsx b/ui/v2.5/src/components/Galleries/GalleryCardGrid.tsx new file mode 100644 index 000000000..a249f27f7 --- /dev/null +++ b/ui/v2.5/src/components/Galleries/GalleryCardGrid.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import * as GQL from "src/core/generated-graphql"; +import { GalleryCard } from "./GalleryCard"; +import { + useCardWidth, + useContainerDimensions, +} from "../Shared/GridCard/GridCard"; +import { PatchComponent } from "src/patch"; + +interface IGalleryCardGrid { + galleries: GQL.SlimGalleryDataFragment[]; + selectedIds: Set; + zoomIndex: number; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; +} + +const zoomWidths = [280, 340, 480, 640]; + +export const GalleryCardGrid: React.FC = PatchComponent( + "GalleryCardGrid", + ({ galleries, selectedIds, zoomIndex, onSelectChange }) => { + const [componentRef, { width: containerWidth }] = useContainerDimensions(); + const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); + + return ( +
+ {galleries.map((gallery) => ( + 0} + selected={selectedIds.has(gallery.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + onSelectChange(gallery.id, selected, shiftKey) + } + /> + ))} +
+ ); + } +); diff --git a/ui/v2.5/src/components/Galleries/GalleryGridCard.tsx b/ui/v2.5/src/components/Galleries/GalleryGridCard.tsx deleted file mode 100644 index 111ddf08f..000000000 --- a/ui/v2.5/src/components/Galleries/GalleryGridCard.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from "react"; -import * as GQL from "src/core/generated-graphql"; -import { GalleryCard } from "./GalleryCard"; -import { - useCardWidth, - useContainerDimensions, -} from "../Shared/GridCard/GridCard"; - -interface IGalleryCardGrid { - galleries: GQL.SlimGalleryDataFragment[]; - selectedIds: Set; - zoomIndex: number; - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; -} - -const zoomWidths = [280, 340, 480, 640]; - -export const GalleryCardGrid: React.FC = ({ - galleries, - selectedIds, - zoomIndex, - onSelectChange, -}) => { - const [componentRef, { width: containerWidth }] = useContainerDimensions(); - const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); - - return ( -
- {galleries.map((gallery) => ( - 0} - selected={selectedIds.has(gallery.id)} - onSelectedChanged={(selected: boolean, shiftKey: boolean) => - onSelectChange(gallery.id, selected, shiftKey) - } - /> - ))} -
- ); -}; diff --git a/ui/v2.5/src/components/Galleries/GalleryList.tsx b/ui/v2.5/src/components/Galleries/GalleryList.tsx index 952f27808..9a4fc5236 100644 --- a/ui/v2.5/src/components/Galleries/GalleryList.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryList.tsx @@ -13,7 +13,7 @@ import { EditGalleriesDialog } from "./EditGalleriesDialog"; import { DeleteGalleriesDialog } from "./DeleteGalleriesDialog"; import { ExportDialog } from "../Shared/ExportDialog"; import { GalleryListTable } from "./GalleryListTable"; -import { GalleryCardGrid } from "./GalleryGridCard"; +import { GalleryCardGrid } from "./GalleryCardGrid"; import { View } from "../List/views"; import { PatchComponent } from "src/patch"; import { IItemListOperation } from "../List/FilteredListToolbar"; diff --git a/ui/v2.5/src/components/Groups/GroupCardGrid.tsx b/ui/v2.5/src/components/Groups/GroupCardGrid.tsx index 3ad6b02c4..e3b70c75f 100644 --- a/ui/v2.5/src/components/Groups/GroupCardGrid.tsx +++ b/ui/v2.5/src/components/Groups/GroupCardGrid.tsx @@ -5,6 +5,7 @@ import { useCardWidth, useContainerDimensions, } from "../Shared/GridCard/GridCard"; +import { PatchComponent } from "src/patch"; interface IGroupCardGrid { groups: GQL.ListGroupDataFragment[]; @@ -17,34 +18,30 @@ interface IGroupCardGrid { const zoomWidths = [210, 250, 300, 375]; -export const GroupCardGrid: React.FC = ({ - groups, - selectedIds, - zoomIndex, - onSelectChange, - fromGroupId, - onMove, -}) => { - const [componentRef, { width: containerWidth }] = useContainerDimensions(); - const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); +export const GroupCardGrid: React.FC = PatchComponent( + "GroupCardGrid", + ({ groups, selectedIds, zoomIndex, onSelectChange, fromGroupId, onMove }) => { + const [componentRef, { width: containerWidth }] = useContainerDimensions(); + const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); - return ( -
- {groups.map((p) => ( - 0} - selected={selectedIds.has(p.id)} - onSelectedChanged={(selected: boolean, shiftKey: boolean) => - onSelectChange(p.id, selected, shiftKey) - } - fromGroupId={fromGroupId} - onMove={onMove} - /> - ))} -
- ); -}; + return ( +
+ {groups.map((p) => ( + 0} + selected={selectedIds.has(p.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + onSelectChange(p.id, selected, shiftKey) + } + fromGroupId={fromGroupId} + onMove={onMove} + /> + ))} +
+ ); + } +); diff --git a/ui/v2.5/src/components/Images/ImageCardGrid.tsx b/ui/v2.5/src/components/Images/ImageCardGrid.tsx new file mode 100644 index 000000000..dadab571b --- /dev/null +++ b/ui/v2.5/src/components/Images/ImageCardGrid.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import * as GQL from "src/core/generated-graphql"; +import { ImageCard } from "./ImageCard"; +import { + useCardWidth, + useContainerDimensions, +} from "../Shared/GridCard/GridCard"; +import { PatchComponent } from "src/patch"; + +interface IImageCardGrid { + images: GQL.SlimImageDataFragment[]; + selectedIds: Set; + zoomIndex: number; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; + onPreview: (index: number, ev: React.MouseEvent) => void; +} + +const zoomWidths = [280, 340, 480, 640]; + +export const ImageCardGrid: React.FC = PatchComponent( + "ImageCardGrid", + ({ images, selectedIds, zoomIndex, onSelectChange, onPreview }) => { + const [componentRef, { width: containerWidth }] = useContainerDimensions(); + const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); + + return ( +
+ {images.map((image, index) => ( + 0} + selected={selectedIds.has(image.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + onSelectChange(image.id, selected, shiftKey) + } + onPreview={ + selectedIds.size < 1 ? (ev) => onPreview(index, ev) : undefined + } + /> + ))} +
+ ); + } +); diff --git a/ui/v2.5/src/components/Images/ImageGridCard.tsx b/ui/v2.5/src/components/Images/ImageGridCard.tsx deleted file mode 100644 index cbb76d853..000000000 --- a/ui/v2.5/src/components/Images/ImageGridCard.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React from "react"; -import * as GQL from "src/core/generated-graphql"; -import { ImageCard } from "./ImageCard"; -import { - useCardWidth, - useContainerDimensions, -} from "../Shared/GridCard/GridCard"; - -interface IImageCardGrid { - images: GQL.SlimImageDataFragment[]; - selectedIds: Set; - zoomIndex: number; - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; - onPreview: (index: number, ev: React.MouseEvent) => void; -} - -const zoomWidths = [280, 340, 480, 640]; - -export const ImageGridCard: React.FC = ({ - images, - selectedIds, - zoomIndex, - onSelectChange, - onPreview, -}) => { - const [componentRef, { width: containerWidth }] = useContainerDimensions(); - const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); - - return ( -
- {images.map((image, index) => ( - 0} - selected={selectedIds.has(image.id)} - onSelectedChanged={(selected: boolean, shiftKey: boolean) => - onSelectChange(image.id, selected, shiftKey) - } - onPreview={ - selectedIds.size < 1 ? (ev) => onPreview(index, ev) : undefined - } - /> - ))} -
- ); -}; diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index 79496823d..7928090cc 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -22,7 +22,7 @@ import Gallery, { RenderImageProps } from "react-photo-gallery"; import { ExportDialog } from "../Shared/ExportDialog"; import { objectTitle } from "src/core/files"; import { useConfigurationContext } from "src/hooks/Config"; -import { ImageGridCard } from "./ImageGridCard"; +import { ImageCardGrid } from "./ImageCardGrid"; import { View } from "../List/views"; import { IItemListOperation } from "../List/FilteredListToolbar"; import { FileSize } from "../Shared/FileSize"; @@ -263,7 +263,7 @@ const ImageListImages: React.FC = ({ if (filter.displayMode === DisplayMode.Grid) { return ( - = ({ - performers, - selectedIds, - zoomIndex, - onSelectChange, - extraCriteria, -}) => { - const [componentRef, { width: containerWidth }] = useContainerDimensions(); - const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); +export const PerformerCardGrid: React.FC = PatchComponent( + "PerformerCardGrid", + ({ performers, selectedIds, zoomIndex, onSelectChange, extraCriteria }) => { + const [componentRef, { width: containerWidth }] = useContainerDimensions(); + const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); - return ( -
- {performers.map((p) => ( - 0} - selected={selectedIds.has(p.id)} - onSelectedChanged={(selected: boolean, shiftKey: boolean) => - onSelectChange(p.id, selected, shiftKey) - } - extraCriteria={extraCriteria} - /> - ))} -
- ); -}; + return ( +
+ {performers.map((p) => ( + 0} + selected={selectedIds.has(p.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + onSelectChange(p.id, selected, shiftKey) + } + extraCriteria={extraCriteria} + /> + ))} +
+ ); + } +); diff --git a/ui/v2.5/src/components/Scenes/SceneCardGrid.tsx b/ui/v2.5/src/components/Scenes/SceneCardGrid.tsx new file mode 100644 index 000000000..f60b412d3 --- /dev/null +++ b/ui/v2.5/src/components/Scenes/SceneCardGrid.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import * as GQL from "src/core/generated-graphql"; +import { SceneQueue } from "src/models/sceneQueue"; +import { SceneCard } from "./SceneCard"; +import { + useCardWidth, + useContainerDimensions, +} from "../Shared/GridCard/GridCard"; +import { PatchComponent } from "src/patch"; + +interface ISceneCardGrid { + scenes: GQL.SlimSceneDataFragment[]; + queue?: SceneQueue; + selectedIds: Set; + zoomIndex: number; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; + fromGroupId?: string; +} + +const zoomWidths = [280, 340, 480, 640]; + +export const SceneCardGrid: React.FC = PatchComponent( + "SceneCardGrid", + ({ scenes, queue, selectedIds, zoomIndex, onSelectChange, fromGroupId }) => { + const [componentRef, { width: containerWidth }] = useContainerDimensions(); + + const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); + + return ( +
+ {scenes.map((scene, index) => ( + 0} + selected={selectedIds.has(scene.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + onSelectChange(scene.id, selected, shiftKey) + } + fromGroupId={fromGroupId} + /> + ))} +
+ ); + } +); diff --git a/ui/v2.5/src/components/Scenes/SceneCardsGrid.tsx b/ui/v2.5/src/components/Scenes/SceneCardsGrid.tsx deleted file mode 100644 index 03b907938..000000000 --- a/ui/v2.5/src/components/Scenes/SceneCardsGrid.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React from "react"; -import * as GQL from "src/core/generated-graphql"; -import { SceneQueue } from "src/models/sceneQueue"; -import { SceneCard } from "./SceneCard"; -import { - useCardWidth, - useContainerDimensions, -} from "../Shared/GridCard/GridCard"; - -interface ISceneCardsGrid { - scenes: GQL.SlimSceneDataFragment[]; - queue?: SceneQueue; - selectedIds: Set; - zoomIndex: number; - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; - fromGroupId?: string; -} - -const zoomWidths = [280, 340, 480, 640]; - -export const SceneCardsGrid: React.FC = ({ - scenes, - queue, - selectedIds, - zoomIndex, - onSelectChange, - fromGroupId, -}) => { - const [componentRef, { width: containerWidth }] = useContainerDimensions(); - - const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); - - return ( -
- {scenes.map((scene, index) => ( - 0} - selected={selectedIds.has(scene.id)} - onSelectedChanged={(selected: boolean, shiftKey: boolean) => - onSelectChange(scene.id, selected, shiftKey) - } - fromGroupId={fromGroupId} - /> - ))} -
- ); -}; diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index d139c6a72..b85ca7ad8 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -15,7 +15,7 @@ import { EditScenesDialog } from "./EditScenesDialog"; import { DeleteScenesDialog } from "./DeleteScenesDialog"; import { GenerateDialog } from "../Dialogs/GenerateDialog"; import { ExportDialog } from "../Shared/ExportDialog"; -import { SceneCardsGrid } from "./SceneCardsGrid"; +import { SceneCardGrid } from "./SceneCardGrid"; import { TaggerContext } from "../Tagger/context"; import { IdentifyDialog } from "../Dialogs/IdentifyDialog/IdentifyDialog"; import { useConfigurationContext } from "src/hooks/Config"; @@ -209,7 +209,7 @@ const SceneList: React.FC<{ if (filter.displayMode === DisplayMode.Grid) { return ( - ; + zoomIndex: number; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; +} + +const zoomWidths = [240, 340, 480, 640]; + +export const SceneMarkerCardGrid: React.FC = + PatchComponent( + "SceneMarkerCardGrid", + ({ markers, selectedIds, zoomIndex, onSelectChange }) => { + const [componentRef, { width: containerWidth }] = + useContainerDimensions(); + const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); + + return ( +
+ {markers.map((marker, index) => ( + 0} + selected={selectedIds.has(marker.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + onSelectChange(marker.id, selected, shiftKey) + } + /> + ))} +
+ ); + } + ); diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerCardsGrid.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerCardsGrid.tsx deleted file mode 100644 index 9f01fe6da..000000000 --- a/ui/v2.5/src/components/Scenes/SceneMarkerCardsGrid.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from "react"; -import * as GQL from "src/core/generated-graphql"; -import { SceneMarkerCard } from "./SceneMarkerCard"; -import { - useCardWidth, - useContainerDimensions, -} from "../Shared/GridCard/GridCard"; - -interface ISceneMarkerCardsGrid { - markers: GQL.SceneMarkerDataFragment[]; - selectedIds: Set; - zoomIndex: number; - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; -} - -const zoomWidths = [240, 340, 480, 640]; - -export const SceneMarkerCardsGrid: React.FC = ({ - markers, - selectedIds, - zoomIndex, - onSelectChange, -}) => { - const [componentRef, { width: containerWidth }] = useContainerDimensions(); - const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); - - return ( -
- {markers.map((marker, index) => ( - 0} - selected={selectedIds.has(marker.id)} - onSelectedChanged={(selected: boolean, shiftKey: boolean) => - onSelectChange(marker.id, selected, shiftKey) - } - /> - ))} -
- ); -}; diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx index 074ee2b83..b5975ca5a 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx @@ -14,7 +14,7 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { MarkerWallPanel } from "./SceneMarkerWallPanel"; import { View } from "../List/views"; -import { SceneMarkerCardsGrid } from "./SceneMarkerCardsGrid"; +import { SceneMarkerCardGrid } from "./SceneMarkerCardGrid"; import { DeleteSceneMarkersDialog } from "./DeleteSceneMarkersDialog"; import { EditSceneMarkersDialog } from "./EditSceneMarkersDialog"; import { PatchComponent } from "src/patch"; @@ -109,7 +109,7 @@ export const SceneMarkerList: React.FC = PatchComponent( if (filter.displayMode === DisplayMode.Grid) { return ( - = ({ - studios, - fromParent, - selectedIds, - zoomIndex, - onSelectChange, -}) => { - const [componentRef, { width: containerWidth }] = useContainerDimensions(); - const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); +export const StudioCardGrid: React.FC = PatchComponent( + "StudioCardGrid", + ({ studios, fromParent, selectedIds, zoomIndex, onSelectChange }) => { + const [componentRef, { width: containerWidth }] = useContainerDimensions(); + const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); - return ( -
- {studios.map((studio) => ( - 0} - selected={selectedIds.has(studio.id)} - onSelectedChanged={(selected: boolean, shiftKey: boolean) => - onSelectChange(studio.id, selected, shiftKey) - } - /> - ))} -
- ); -}; + return ( +
+ {studios.map((studio) => ( + 0} + selected={selectedIds.has(studio.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + onSelectChange(studio.id, selected, shiftKey) + } + /> + ))} +
+ ); + } +); diff --git a/ui/v2.5/src/components/Tags/TagCardGrid.tsx b/ui/v2.5/src/components/Tags/TagCardGrid.tsx index ac3bf0317..d779da7e0 100644 --- a/ui/v2.5/src/components/Tags/TagCardGrid.tsx +++ b/ui/v2.5/src/components/Tags/TagCardGrid.tsx @@ -5,6 +5,7 @@ import { useContainerDimensions, } from "../Shared/GridCard/GridCard"; import { TagCard } from "./TagCard"; +import { PatchComponent } from "src/patch"; interface ITagCardGrid { tags: (GQL.TagDataFragment | GQL.TagListDataFragment)[]; @@ -15,30 +16,28 @@ interface ITagCardGrid { const zoomWidths = [280, 340, 480, 640]; -export const TagCardGrid: React.FC = ({ - tags, - selectedIds, - zoomIndex, - onSelectChange, -}) => { - const [componentRef, { width: containerWidth }] = useContainerDimensions(); - const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); +export const TagCardGrid: React.FC = PatchComponent( + "TagCardGrid", + ({ tags, selectedIds, zoomIndex, onSelectChange }) => { + const [componentRef, { width: containerWidth }] = useContainerDimensions(); + const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); - return ( -
- {tags.map((tag) => ( - 0} - selected={selectedIds.has(tag.id)} - onSelectedChanged={(selected: boolean, shiftKey: boolean) => - onSelectChange(tag.id, selected, shiftKey) - } - /> - ))} -
- ); -}; + return ( +
+ {tags.map((tag) => ( + 0} + selected={selectedIds.has(tag.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + onSelectChange(tag.id, selected, shiftKey) + } + /> + ))} +
+ ); + } +); diff --git a/ui/v2.5/src/pluginApi.d.ts b/ui/v2.5/src/pluginApi.d.ts index 9372f333d..680f39cd0 100644 --- a/ui/v2.5/src/pluginApi.d.ts +++ b/ui/v2.5/src/pluginApi.d.ts @@ -672,6 +672,7 @@ declare namespace PluginApi { "GalleryCard.Image": React.FC; "GalleryCard.Overlays": React.FC; "GalleryCard.Popovers": React.FC; + GalleryCardGrid: React.FC; GalleryAddPanel: React.FC; GalleryIDSelect: React.FC; GalleryImagesPanel: React.FC; @@ -679,6 +680,7 @@ declare namespace PluginApi { GallerySelect: React.FC; GridCard: React.FC; GroupCard: React.FC; + GroupCardGrid: React.FC; GroupIDSelect: React.FC; GroupList: React.FC; GroupSelect: React.FC; @@ -687,6 +689,7 @@ declare namespace PluginApi { HoverPopover: React.FC; Icon: React.FC; ImageCard: React.FC; + ImageCardGrid: React.FC; ImageInput: React.FC; ImageList: React.FC; LightboxLink: React.FC; @@ -697,6 +700,7 @@ declare namespace PluginApi { NumberSetting: React.FC; PerformerAppearsWithPanel: React.FC; PerformerCard: React.FC; + PerformerCardGrid: React.FC; "PerformerCard.Details": React.FC; "PerformerCard.Image": React.FC; "PerformerCard.Overlays": React.FC; @@ -728,12 +732,14 @@ declare namespace PluginApi { "SceneCard.Image": React.FC; "SceneCard.Overlays": React.FC; "SceneCard.Popovers": React.FC; + SceneCardGrid: React.FC; SceneList: React.FC; SceneListOperations: React.FC; SceneMarkerCard: React.FC; "SceneMarkerCard.Details": React.FC; "SceneMarkerCard.Image": React.FC; "SceneMarkerCard.Popovers": React.FC; + SceneMarkerCardGrid: React.FC; SceneMarkerList: React.FC; SelectSetting: React.FC; Setting: React.FC; @@ -742,6 +748,7 @@ declare namespace PluginApi { StringListSetting: React.FC; StringSetting: React.FC; StudioCard: React.FC; + StudioCardGrid: React.FC; StudioDetailsPanel: React.FC; StudioIDSelect: React.FC; StudioList: React.FC; @@ -754,6 +761,7 @@ declare namespace PluginApi { "TagCard.Overlays": React.FC; "TagCard.Popovers": React.FC; "TagCard.Title": React.FC; + TagCardGrid: React.FC; TagLink: React.FC; TagList: React.FC; TagSelect: React.FC; From b4969add2747310f34079f01d5f40de1fc679a9d Mon Sep 17 00:00:00 2001 From: Valkyr-JS <154020147+Valkyr-JS@users.noreply.github.com> Date: Tue, 13 Jan 2026 22:29:57 +0000 Subject: [PATCH 025/177] Plugin API - recommendation row components (#6492) * Patched RecommendationRow component * Patched @ant-design/react-slick library to ReactSlick * Patched GalleryRecommendationRow component * Patched GroupRecommendationRow component * Patched ImageRecommendationRow component * Patched PerformerRecommendationRow component * Patched SceneRecommendationRow component * Patched SceneMarkerRecommendationRow component * Patched StudioRecommendationRow component * Patched TagRecommendationRow component --- .../FrontPage/RecommendationRow.tsx | 30 +++---- .../Galleries/GalleryRecommendationRow.tsx | 74 +++++++-------- .../Groups/GroupRecommendationRow.tsx | 71 ++++++++------- .../Images/ImageRecommendationRow.tsx | 71 ++++++++------- .../Performers/PerformerRecommendationRow.tsx | 74 +++++++-------- .../Scenes/SceneMarkerRecommendationRow.tsx | 86 +++++++++--------- .../Scenes/SceneRecommendationRow.tsx | 89 ++++++++++--------- .../Studios/StudioRecommendationRow.tsx | 74 +++++++-------- .../components/Tags/TagRecommendationRow.tsx | 68 +++++++------- ui/v2.5/src/pluginApi.d.ts | 10 +++ ui/v2.5/src/pluginApi.tsx | 2 + 11 files changed, 352 insertions(+), 297 deletions(-) diff --git a/ui/v2.5/src/components/FrontPage/RecommendationRow.tsx b/ui/v2.5/src/components/FrontPage/RecommendationRow.tsx index 0b48434c0..115d8642a 100644 --- a/ui/v2.5/src/components/FrontPage/RecommendationRow.tsx +++ b/ui/v2.5/src/components/FrontPage/RecommendationRow.tsx @@ -1,4 +1,5 @@ import React, { PropsWithChildren } from "react"; +import { PatchComponent } from "src/patch"; interface IProps { className?: string; @@ -6,19 +7,18 @@ interface IProps { link: JSX.Element; } -export const RecommendationRow: React.FC> = ({ - className, - header, - link, - children, -}) => ( -
-
-
-

{header}

+export const RecommendationRow: React.FC> = + PatchComponent( + "RecommendationRow", + ({ className, header, link, children }) => ( +
+
+
+

{header}

+
+ {link} +
+ {children}
- {link} -
- {children} -
-); + ) + ); diff --git a/ui/v2.5/src/components/Galleries/GalleryRecommendationRow.tsx b/ui/v2.5/src/components/Galleries/GalleryRecommendationRow.tsx index ee94d6da2..b56b48c36 100644 --- a/ui/v2.5/src/components/Galleries/GalleryRecommendationRow.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryRecommendationRow.tsx @@ -7,6 +7,7 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import { getSlickSliderSettings } from "src/core/recommendations"; import { RecommendationRow } from "../FrontPage/RecommendationRow"; import { FormattedMessage } from "react-intl"; +import { PatchComponent } from "src/patch"; interface IProps { isTouch: boolean; @@ -14,41 +15,44 @@ interface IProps { header: string; } -export const GalleryRecommendationRow: React.FC = (props) => { - const result = useFindGalleries(props.filter); - const cardCount = result.data?.findGalleries.count; +export const GalleryRecommendationRow: React.FC = PatchComponent( + "GalleryRecommendationRow", + (props) => { + const result = useFindGalleries(props.filter); + const cardCount = result.data?.findGalleries.count; - if (!result.loading && !cardCount) { - return null; - } + if (!result.loading && !cardCount) { + return null; + } - return ( - - - - } - > - + + + } > - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
- )) - : result.data?.findGalleries.galleries.map((g) => ( - - ))} -
-
- ); -}; + + {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findGalleries.galleries.map((g) => ( + + ))} +
+ + ); + } +); diff --git a/ui/v2.5/src/components/Groups/GroupRecommendationRow.tsx b/ui/v2.5/src/components/Groups/GroupRecommendationRow.tsx index 3a8fee856..228cb3467 100644 --- a/ui/v2.5/src/components/Groups/GroupRecommendationRow.tsx +++ b/ui/v2.5/src/components/Groups/GroupRecommendationRow.tsx @@ -7,6 +7,7 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import { getSlickSliderSettings } from "src/core/recommendations"; import { RecommendationRow } from "../FrontPage/RecommendationRow"; import { FormattedMessage } from "react-intl"; +import { PatchComponent } from "src/patch"; interface IProps { isTouch: boolean; @@ -14,38 +15,44 @@ interface IProps { header: string; } -export const GroupRecommendationRow: React.FC = (props: IProps) => { - const result = useFindGroups(props.filter); - const cardCount = result.data?.findGroups.count; +export const GroupRecommendationRow: React.FC = PatchComponent( + "GroupRecommendationRow", + (props: IProps) => { + const result = useFindGroups(props.filter); + const cardCount = result.data?.findGroups.count; - if (!result.loading && !cardCount) { - return null; - } + if (!result.loading && !cardCount) { + return null; + } - return ( - - - - } - > - + + + } > - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
- )) - : result.data?.findGroups.groups.map((g) => ( - - ))} -
-
- ); -}; + + {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findGroups.groups.map((g) => ( + + ))} +
+ + ); + } +); diff --git a/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx b/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx index f0fc84493..6499be894 100644 --- a/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx +++ b/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx @@ -7,6 +7,7 @@ import { getSlickSliderSettings } from "src/core/recommendations"; import { RecommendationRow } from "../FrontPage/RecommendationRow"; import { FormattedMessage } from "react-intl"; import { ImageCard } from "./ImageCard"; +import { PatchComponent } from "src/patch"; interface IProps { isTouch: boolean; @@ -14,38 +15,44 @@ interface IProps { header: string; } -export const ImageRecommendationRow: React.FC = (props: IProps) => { - const result = useFindImages(props.filter); - const cardCount = result.data?.findImages.count; +export const ImageRecommendationRow: React.FC = PatchComponent( + "ImageRecommendationRow", + (props: IProps) => { + const result = useFindImages(props.filter); + const cardCount = result.data?.findImages.count; - if (!result.loading && !cardCount) { - return null; - } + if (!result.loading && !cardCount) { + return null; + } - return ( - - - - } - > - + + + } > - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
- )) - : result.data?.findImages.images.map((i) => ( - - ))} -
-
- ); -}; + + {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findImages.images.map((i) => ( + + ))} +
+ + ); + } +); diff --git a/ui/v2.5/src/components/Performers/PerformerRecommendationRow.tsx b/ui/v2.5/src/components/Performers/PerformerRecommendationRow.tsx index 3c094f7ad..13bba1e99 100644 --- a/ui/v2.5/src/components/Performers/PerformerRecommendationRow.tsx +++ b/ui/v2.5/src/components/Performers/PerformerRecommendationRow.tsx @@ -7,6 +7,7 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import { getSlickSliderSettings } from "src/core/recommendations"; import { RecommendationRow } from "../FrontPage/RecommendationRow"; import { FormattedMessage } from "react-intl"; +import { PatchComponent } from "src/patch"; interface IProps { isTouch: boolean; @@ -14,41 +15,44 @@ interface IProps { header: string; } -export const PerformerRecommendationRow: React.FC = (props) => { - const result = useFindPerformers(props.filter); - const cardCount = result.data?.findPerformers.count; +export const PerformerRecommendationRow: React.FC = PatchComponent( + "PerformerRecommendationRow", + (props) => { + const result = useFindPerformers(props.filter); + const cardCount = result.data?.findPerformers.count; - if (!result.loading && !cardCount) { - return null; - } + if (!result.loading && !cardCount) { + return null; + } - return ( - - - - } - > - + + + } > - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
- )) - : result.data?.findPerformers.performers.map((p) => ( - - ))} -
-
- ); -}; + + {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findPerformers.performers.map((p) => ( + + ))} +
+ + ); + } +); diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerRecommendationRow.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerRecommendationRow.tsx index 7559d609a..5c9769206 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerRecommendationRow.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerRecommendationRow.tsx @@ -7,6 +7,7 @@ import { getSlickSliderSettings } from "src/core/recommendations"; import { RecommendationRow } from "../FrontPage/RecommendationRow"; import { FormattedMessage } from "react-intl"; import { SceneMarkerCard } from "./SceneMarkerCard"; +import { PatchComponent } from "src/patch"; interface IProps { isTouch: boolean; @@ -14,46 +15,51 @@ interface IProps { header: string; } -export const SceneMarkerRecommendationRow: React.FC = (props) => { - const result = useFindSceneMarkers(props.filter); - const cardCount = result.data?.findSceneMarkers.count; +export const SceneMarkerRecommendationRow: React.FC = PatchComponent( + "SceneMarkerRecommendationRow", + (props) => { + const result = useFindSceneMarkers(props.filter); + const cardCount = result.data?.findSceneMarkers.count; - if (!result.loading && !cardCount) { - return null; - } + if (!result.loading && !cardCount) { + return null; + } - return ( - - - - } - > - + + + } > - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
- )) - : result.data?.findSceneMarkers.scene_markers.map((marker, index) => ( - - ))} -
-
- ); -}; + + {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findSceneMarkers.scene_markers.map( + (marker, index) => ( + + ) + )} +
+ + ); + } +); diff --git a/ui/v2.5/src/components/Scenes/SceneRecommendationRow.tsx b/ui/v2.5/src/components/Scenes/SceneRecommendationRow.tsx index d33762761..f90b63ec6 100644 --- a/ui/v2.5/src/components/Scenes/SceneRecommendationRow.tsx +++ b/ui/v2.5/src/components/Scenes/SceneRecommendationRow.tsx @@ -8,6 +8,7 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import { getSlickSliderSettings } from "src/core/recommendations"; import { RecommendationRow } from "../FrontPage/RecommendationRow"; import { FormattedMessage } from "react-intl"; +import { PatchComponent } from "src/patch"; interface IProps { isTouch: boolean; @@ -15,48 +16,54 @@ interface IProps { header: string; } -export const SceneRecommendationRow: React.FC = (props) => { - const result = useFindScenes(props.filter); - const cardCount = result.data?.findScenes.count; +export const SceneRecommendationRow: React.FC = PatchComponent( + "SceneRecommendationRow", + (props) => { + const result = useFindScenes(props.filter); + const cardCount = result.data?.findScenes.count; - const queue = useMemo(() => { - return SceneQueue.fromListFilterModel(props.filter); - }, [props.filter]); + const queue = useMemo(() => { + return SceneQueue.fromListFilterModel(props.filter); + }, [props.filter]); - if (!result.loading && !cardCount) { - return null; - } + if (!result.loading && !cardCount) { + return null; + } - return ( - - - - } - > - + + + } > - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
- )) - : result.data?.findScenes.scenes.map((scene, index) => ( - - ))} -
-
- ); -}; + + {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findScenes.scenes.map((scene, index) => ( + + ))} +
+ + ); + } +); diff --git a/ui/v2.5/src/components/Studios/StudioRecommendationRow.tsx b/ui/v2.5/src/components/Studios/StudioRecommendationRow.tsx index 3df4f65c6..bede2da1d 100644 --- a/ui/v2.5/src/components/Studios/StudioRecommendationRow.tsx +++ b/ui/v2.5/src/components/Studios/StudioRecommendationRow.tsx @@ -7,6 +7,7 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import { getSlickSliderSettings } from "src/core/recommendations"; import { RecommendationRow } from "../FrontPage/RecommendationRow"; import { FormattedMessage } from "react-intl"; +import { PatchComponent } from "src/patch"; interface IProps { isTouch: boolean; @@ -14,41 +15,44 @@ interface IProps { header: string; } -export const StudioRecommendationRow: React.FC = (props) => { - const result = useFindStudios(props.filter); - const cardCount = result.data?.findStudios.count; +export const StudioRecommendationRow: React.FC = PatchComponent( + "StudioRecommendationRow", + (props) => { + const result = useFindStudios(props.filter); + const cardCount = result.data?.findStudios.count; - if (!result.loading && !cardCount) { - return null; - } + if (!result.loading && !cardCount) { + return null; + } - return ( - - - - } - > - + + + } > - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
- )) - : result.data?.findStudios.studios.map((s) => ( - - ))} -
-
- ); -}; + + {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findStudios.studios.map((s) => ( + + ))} +
+ + ); + } +); diff --git a/ui/v2.5/src/components/Tags/TagRecommendationRow.tsx b/ui/v2.5/src/components/Tags/TagRecommendationRow.tsx index 9d10d7333..27e9e8dce 100644 --- a/ui/v2.5/src/components/Tags/TagRecommendationRow.tsx +++ b/ui/v2.5/src/components/Tags/TagRecommendationRow.tsx @@ -7,6 +7,7 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import { getSlickSliderSettings } from "src/core/recommendations"; import { RecommendationRow } from "../FrontPage/RecommendationRow"; import { FormattedMessage } from "react-intl"; +import { PatchComponent } from "src/patch"; interface IProps { isTouch: boolean; @@ -14,38 +15,41 @@ interface IProps { header: string; } -export const TagRecommendationRow: React.FC = (props) => { - const result = useFindTags(props.filter); - const cardCount = result.data?.findTags.count; +export const TagRecommendationRow: React.FC = PatchComponent( + "TagRecommendationRow", + (props) => { + const result = useFindTags(props.filter); + const cardCount = result.data?.findTags.count; - if (!result.loading && !cardCount) { - return null; - } + if (!result.loading && !cardCount) { + return null; + } - return ( - - - - } - > - + + + } > - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
- )) - : result.data?.findTags.tags.map((p) => ( - - ))} -
-
- ); -}; + + {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findTags.tags.map((p) => ( + + ))} +
+ + ); + } +); diff --git a/ui/v2.5/src/pluginApi.d.ts b/ui/v2.5/src/pluginApi.d.ts index 680f39cd0..1aae25129 100644 --- a/ui/v2.5/src/pluginApi.d.ts +++ b/ui/v2.5/src/pluginApi.d.ts @@ -619,6 +619,7 @@ declare namespace PluginApi { const Mousetrap: typeof import("mousetrap"); const ReactFontAwesome: typeof import("@fortawesome/react-fontawesome"); const ReactSelect: typeof import("react-select"); + const ReactSlick: typeof import("@ant-design/react-slick"); // @ts-expect-error import { MousetrapStatic } from "mousetrap"; @@ -677,12 +678,14 @@ declare namespace PluginApi { GalleryIDSelect: React.FC; GalleryImagesPanel: React.FC; GalleryList: React.FC; + GalleryRecommendationRow: React.FC; GallerySelect: React.FC; GridCard: React.FC; GroupCard: React.FC; GroupCardGrid: React.FC; GroupIDSelect: React.FC; GroupList: React.FC; + GroupRecommendationRow: React.FC; GroupSelect: React.FC; GroupSubGroupsPanel: React.FC; HeaderImage: React.FC; @@ -692,6 +695,7 @@ declare namespace PluginApi { ImageCardGrid: React.FC; ImageInput: React.FC; ImageList: React.FC; + ImageRecommendationRow: React.FC; LightboxLink: React.FC; LoadingIndicator: React.FC; "MainNavBar.MenuItems": React.FC; @@ -715,12 +719,14 @@ declare namespace PluginApi { PerformerImagesPanel: React.FC; PerformerList: React.FC; PerformerPage: React.FC; + PerformerRecommendationRow: React.FC; PerformerScenesPanel: React.FC; PerformerSelect: React.FC; PluginSettings: React.FC; RatingNumber: React.FC; RatingStars: React.FC; RatingSystem: React.FC; + RecommendationRow: React.FC; SceneFileInfoPanel: React.FC; SceneIDSelect: React.FC; ScenePage: React.FC; @@ -741,6 +747,8 @@ declare namespace PluginApi { "SceneMarkerCard.Popovers": React.FC; SceneMarkerCardGrid: React.FC; SceneMarkerList: React.FC; + SceneMarkerRecommendationRow: React.FC; + SceneRecommendationRow: React.FC; SelectSetting: React.FC; Setting: React.FC; SettingGroup: React.FC; @@ -752,6 +760,7 @@ declare namespace PluginApi { StudioDetailsPanel: React.FC; StudioIDSelect: React.FC; StudioList: React.FC; + StudioRecommendationRow: React.FC; StudioSelect: React.FC; SweatDrops: React.FC; TabTitleCounter: React.FC; @@ -764,6 +773,7 @@ declare namespace PluginApi { TagCardGrid: React.FC; TagLink: React.FC; TagList: React.FC; + TagRecommendationRow: React.FC; TagSelect: React.FC; TruncatedText: React.FC; }; diff --git a/ui/v2.5/src/pluginApi.tsx b/ui/v2.5/src/pluginApi.tsx index 276091cde..72441cec9 100644 --- a/ui/v2.5/src/pluginApi.tsx +++ b/ui/v2.5/src/pluginApi.tsx @@ -14,6 +14,7 @@ import * as FontAwesomeRegular from "@fortawesome/free-regular-svg-icons"; import * as FontAwesomeBrands from "@fortawesome/free-brands-svg-icons"; import * as ReactFontAwesome from "@fortawesome/react-fontawesome"; import * as ReactSelect from "react-select"; +import * as ReactSlick from "@ant-design/react-slick"; import { useSpriteInfo } from "./hooks/sprite"; import { useToast } from "./hooks/Toast"; import Event from "./hooks/event"; @@ -81,6 +82,7 @@ export const PluginApi = { MousetrapPause, ReactFontAwesome, ReactSelect, + ReactSlick, }, register: { // register a route to be added to the main router From 77d0008c6d5e9d85b03c235403f0a6d6df4241da Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Tue, 13 Jan 2026 19:06:21 -0800 Subject: [PATCH 026/177] FR: Save & New Button on Objects (#6438) --- .../GalleryDetails/GalleryCreate.tsx | 6 +- .../GalleryDetails/GalleryEditPanel.tsx | 48 ++++++++++----- .../Groups/GroupDetails/GroupCreate.tsx | 6 +- .../Groups/GroupDetails/GroupEditPanel.tsx | 12 +++- .../PerformerDetails/PerformerCreate.tsx | 6 +- .../PerformerDetails/PerformerEditPanel.tsx | 58 ++++++++++++++----- .../Scenes/SceneDetails/SceneCreate.tsx | 6 +- .../Scenes/SceneDetails/SceneEditPanel.tsx | 56 +++++++++++++----- .../components/Shared/DetailsEditNavbar.tsx | 20 ++++++- ui/v2.5/src/components/Shared/styles.scss | 15 ++++- .../Studios/StudioDetails/StudioCreate.tsx | 6 +- .../Studios/StudioDetails/StudioEditPanel.tsx | 12 +++- .../components/Tags/TagDetails/TagCreate.tsx | 6 +- .../Tags/TagDetails/TagEditPanel.tsx | 12 +++- ui/v2.5/src/index.scss | 5 ++ ui/v2.5/src/locales/en-GB.json | 1 + 16 files changed, 209 insertions(+), 66 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryCreate.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryCreate.tsx index 128e70d38..ebb465868 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryCreate.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryCreate.tsx @@ -19,12 +19,14 @@ const GalleryCreate: React.FC = () => { const [createGallery] = useGalleryCreate(); - async function onSave(input: GQL.GalleryCreateInput) { + async function onSave(input: GQL.GalleryCreateInput, andNew?: boolean) { const result = await createGallery({ variables: { input }, }); if (result.data?.galleryCreate) { - history.push(`/galleries/${result.data.galleryCreate.id}`); + if (!andNew) { + history.push(`/galleries/${result.data.galleryCreate.id}`); + } Toast.success( intl.formatMessage( { id: "toast.created_entity" }, diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx index fe7959c55..04b802784 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { Prompt } from "react-router-dom"; -import { Button, Form, Col, Row } from "react-bootstrap"; +import { Button, Dropdown, Form, Col, Row, SplitButton } from "react-bootstrap"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; @@ -35,7 +35,7 @@ import { ScraperMenu } from "src/components/Shared/ScraperMenu"; interface IProps { gallery: Partial; isVisible: boolean; - onSubmit: (input: GQL.GalleryCreateInput) => Promise; + onSubmit: (input: GQL.GalleryCreateInput, andNew?: boolean) => Promise; onDelete: () => void; } @@ -177,10 +177,10 @@ export const GalleryEditPanel: React.FC = ({ return
; }, [gallery?.paths?.cover, intl]); - async function onSave(input: InputValues) { + async function onSave(input: InputValues, andNew?: boolean) { setIsLoading(true); try { - await onSubmit(input); + await onSubmit(input, andNew); formik.resetForm(); } catch (e) { Toast.error(e); @@ -188,6 +188,11 @@ export const GalleryEditPanel: React.FC = ({ setIsLoading(false); } + async function onSaveAndNewClick() { + const input = schema.cast(formik.values); + onSave(input, true); + } + async function onScrapeClicked(s: GQL.ScraperSourceInput) { if (!gallery || !gallery.id) return; @@ -445,16 +450,31 @@ export const GalleryEditPanel: React.FC = ({
- + {isNew ? ( + formik.submitForm()} + > + onSaveAndNewClick()}> + + + + ) : ( + + )}
- + {isNew ? ( + formik.submitForm()} + > + onSaveAndNewClick()}> + + + + ) : ( + + )}
); } diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneCreate.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneCreate.tsx index 707740605..8e3807c83 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneCreate.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneCreate.tsx @@ -57,14 +57,16 @@ const SceneCreate: React.FC = () => { return ; } - async function onSave(input: GQL.SceneCreateInput) { + async function onSave(input: GQL.SceneCreateInput, andNew?: boolean) { const fileID = query.get("file_id") ?? undefined; const result = await mutateCreateScene({ ...input, file_ids: fileID ? [fileID] : undefined, }); if (result.data?.sceneCreate?.id) { - history.push(`/scenes/${result.data.sceneCreate.id}`); + if (!andNew) { + history.push(`/scenes/${result.data.sceneCreate.id}`); + } Toast.success( intl.formatMessage( { id: "toast.created_entity" }, diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index 6a119d8d5..48b353165 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -1,6 +1,14 @@ import React, { useEffect, useState, useMemo } from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import { Button, Form, Col, Row, ButtonGroup } from "react-bootstrap"; +import { + Button, + Dropdown, + Form, + Col, + Row, + ButtonGroup, + SplitButton, +} from "react-bootstrap"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; @@ -51,7 +59,7 @@ interface IProps { initialCoverImage?: string; isNew?: boolean; isVisible: boolean; - onSubmit: (input: GQL.SceneCreateInput) => Promise; + onSubmit: (input: GQL.SceneCreateInput, andNew?: boolean) => Promise; onDelete?: () => void; } @@ -268,10 +276,10 @@ export const SceneEditPanel: React.FC = ({ formik.setFieldValue("groups", newGroups); } - async function onSave(input: InputValues) { + async function onSave(input: InputValues, andNew?: boolean) { setIsLoading(true); try { - await onSubmit(input); + await onSubmit(input, andNew); formik.resetForm(); } catch (e) { Toast.error(e); @@ -279,6 +287,11 @@ export const SceneEditPanel: React.FC = ({ setIsLoading(false); } + async function onSaveAndNewClick() { + const input = schema.cast(formik.values); + onSave(input, true); + } + const encodingImage = ImageUtils.usePasteImage(onImageLoad); function onImageLoad(imageData: string) { @@ -737,16 +750,31 @@ export const SceneEditPanel: React.FC = ({
- + {isNew ? ( + formik.submitForm()} + > + onSaveAndNewClick()}> + + + + ) : ( + + )} {onDelete && ( + {onReset && ( + + )} ); } From 211f06963eb248dbeeebcee877e506650d94f7dd Mon Sep 17 00:00:00 2001 From: RyanAtNight <232988350+RyanAtNight@users.noreply.github.com> Date: Tue, 13 Jan 2026 19:20:07 -0800 Subject: [PATCH 028/177] Add Invert Selection feature to list toolbars (#6491) --- .../GroupDetails/GroupSubGroupsPanel.tsx | 4 +- .../components/List/FilteredListToolbar.tsx | 4 +- ui/v2.5/src/components/List/ItemList.tsx | 5 +- .../components/List/ListOperationButtons.tsx | 35 +++++++++++-- ui/v2.5/src/components/List/ListProvider.tsx | 1 + ui/v2.5/src/components/List/util.ts | 15 +++++- ui/v2.5/src/components/Scenes/SceneList.tsx | 50 +++++++++++-------- .../src/docs/en/Manual/KeyboardShortcuts.md | 1 + ui/v2.5/src/locales/en-GB.json | 1 + 9 files changed, 87 insertions(+), 29 deletions(-) diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx index bd66490f9..32836ab24 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx @@ -76,7 +76,8 @@ const Toolbar: React.FC = ({ onDelete, operations, }) => { - const { getSelected, onSelectAll, onSelectNone } = useListContext(); + const { getSelected, onSelectAll, onSelectNone, onInvertSelection } = + useListContext(); const { filter, setFilter } = useFilter(); return ( @@ -91,6 +92,7 @@ const Toolbar: React.FC = ({ 0} otherOperations={operations} onEdit={onEdit} diff --git a/ui/v2.5/src/components/List/FilteredListToolbar.tsx b/ui/v2.5/src/components/List/FilteredListToolbar.tsx index 4e101ee4b..162b30ff3 100644 --- a/ui/v2.5/src/components/List/FilteredListToolbar.tsx +++ b/ui/v2.5/src/components/List/FilteredListToolbar.tsx @@ -99,13 +99,15 @@ export const FilteredListToolbar: React.FC = ({ filter, setFilter, }); - const { selectedIds, onSelectAll, onSelectNone } = listSelect; + const { selectedIds, onSelectAll, onSelectNone, onInvertSelection } = + listSelect; const hasSelection = selectedIds.size > 0; const renderOperations = operationComponent ?? ( 0} onEdit={onEdit} diff --git a/ui/v2.5/src/components/List/ItemList.tsx b/ui/v2.5/src/components/List/ItemList.tsx index 4c360ecc9..67d09e721 100644 --- a/ui/v2.5/src/components/List/ItemList.tsx +++ b/ui/v2.5/src/components/List/ItemList.tsx @@ -73,7 +73,7 @@ export function useFilteredItemList< const { result, items, totalCount, pages } = queryResult; const listSelect = useListSelect(items); - const { onSelectAll, onSelectNone } = listSelect; + const { onSelectAll, onSelectNone, onInvertSelection } = listSelect; const modalState = useModal(); const { showModal, closeModal } = modalState; @@ -99,6 +99,7 @@ export function useFilteredItemList< onChangePage: setPage, onSelectAll, onSelectNone, + onInvertSelection, pages, showEditFilter, }); @@ -164,6 +165,7 @@ export const ItemList = ( onSelectChange, onSelectAll, onSelectNone, + onInvertSelection, } = listSelect; // scroll to the top of the page when the page changes @@ -212,6 +214,7 @@ export const ItemList = ( onChangePage, onSelectAll, onSelectNone, + onInvertSelection, pages, showEditFilter, }); diff --git a/ui/v2.5/src/components/List/ListOperationButtons.tsx b/ui/v2.5/src/components/List/ListOperationButtons.tsx index 66f4b46f3..b377cedba 100644 --- a/ui/v2.5/src/components/List/ListOperationButtons.tsx +++ b/ui/v2.5/src/components/List/ListOperationButtons.tsx @@ -63,6 +63,7 @@ export interface IListFilterOperation { interface IListOperationButtonsProps { onSelectAll?: () => void; onSelectNone?: () => void; + onInvertSelection?: () => void; onEdit?: () => void; onDelete?: () => void; itemsSelected?: boolean; @@ -72,6 +73,7 @@ interface IListOperationButtonsProps { export const ListOperationButtons: React.FC = ({ onSelectAll, onSelectNone, + onInvertSelection, onEdit, onDelete, itemsSelected, @@ -82,6 +84,7 @@ export const ListOperationButtons: React.FC = ({ useEffect(() => { Mousetrap.bind("s a", () => onSelectAll?.()); Mousetrap.bind("s n", () => onSelectNone?.()); + Mousetrap.bind("s i", () => onInvertSelection?.()); Mousetrap.bind("e", () => { if (itemsSelected) { @@ -98,10 +101,18 @@ export const ListOperationButtons: React.FC = ({ return () => { Mousetrap.unbind("s a"); Mousetrap.unbind("s n"); + Mousetrap.unbind("s i"); Mousetrap.unbind("e"); Mousetrap.unbind("d d"); }; - }); + }, [ + onSelectAll, + onSelectNone, + onInvertSelection, + itemsSelected, + onEdit, + onDelete, + ]); const buttons = useMemo(() => { const ret = (otherOperations ?? []).filter((o) => { @@ -185,7 +196,25 @@ export const ListOperationButtons: React.FC = ({ } } - const options = [renderSelectAll(), renderSelectNone()].filter((o) => o); + function renderInvertSelection() { + if (onInvertSelection) { + return ( + onInvertSelection?.()} + > + + + ); + } + } + + const options = [ + renderSelectAll(), + renderSelectNone(), + renderInvertSelection(), + ].filter((o) => o); if (otherOperations) { otherOperations @@ -219,7 +248,7 @@ export const ListOperationButtons: React.FC = ({ {options.length > 0 ? options : undefined} ); - }, [otherOperations, onSelectAll, onSelectNone]); + }, [otherOperations, onSelectAll, onSelectNone, onInvertSelection]); // don't render anything if there are no buttons or operations if (buttons.length === 0 && !moreDropdown) { diff --git a/ui/v2.5/src/components/List/ListProvider.tsx b/ui/v2.5/src/components/List/ListProvider.tsx index 0584a61c6..8b9ee7bfb 100644 --- a/ui/v2.5/src/components/List/ListProvider.tsx +++ b/ui/v2.5/src/components/List/ListProvider.tsx @@ -63,6 +63,7 @@ const emptyState: IListContextState = { onSelectChange: () => {}, onSelectAll: () => {}, onSelectNone: () => {}, + onInvertSelection: () => {}, items: [], hasSelection: false, selectedItems: [], diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index c15c3335a..707346848 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -229,6 +229,7 @@ export function useListKeyboardShortcuts(props: { pages?: number; onSelectAll?: () => void; onSelectNone?: () => void; + onInvertSelection?: () => void; }) { const { currentPage, @@ -237,6 +238,7 @@ export function useListKeyboardShortcuts(props: { pages = 0, onSelectAll, onSelectNone, + onInvertSelection, } = props; // set up hotkeys @@ -298,12 +300,14 @@ export function useListKeyboardShortcuts(props: { useEffect(() => { Mousetrap.bind("s a", () => onSelectAll?.()); Mousetrap.bind("s n", () => onSelectNone?.()); + Mousetrap.bind("s i", () => onInvertSelection?.()); return () => { Mousetrap.unbind("s a"); Mousetrap.unbind("s n"); + Mousetrap.unbind("s i"); }; - }, [onSelectAll, onSelectNone]); + }, [onSelectAll, onSelectNone, onInvertSelection]); } export function useListSelect(items: T[]) { @@ -420,6 +424,14 @@ export function useListSelect(items: T[]) { setLastClickedId(undefined); } + function onInvertSelection() { + setItemsSelected((prevSelected) => { + const selectedSet = new Set(prevSelected.map((item) => item.id)); + return items.filter((item) => !selectedSet.has(item.id)); + }); + setLastClickedId(undefined); + } + // TODO - this is for backwards compatibility const getSelected = useCallback(() => itemsSelected, [itemsSelected]); @@ -433,6 +445,7 @@ export function useListSelect(items: T[]) { onSelectChange, onSelectAll, onSelectNone, + onInvertSelection, hasSelection, }; } diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index b85ca7ad8..ff5237c9f 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -522,6 +522,7 @@ export const FilteredSceneList = (props: IFilteredScenes) => { onSelectChange, onSelectAll, onSelectNone, + onInvertSelection, hasSelection, } = listSelect; @@ -539,6 +540,27 @@ export const FilteredSceneList = (props: IFilteredScenes) => { setShowSidebar, }); + const onCloseEditDelete = useCloseEditDelete({ + closeModal, + onSelectNone, + result, + }); + + const onEdit = useCallback(() => { + showModal( + + ); + }, [showModal, selectedItems, onCloseEditDelete]); + + const onDelete = useCallback(() => { + showModal( + + ); + }, [showModal, selectedItems, onCloseEditDelete]); + useEffect(() => { Mousetrap.bind("e", () => { if (hasSelection) { @@ -556,18 +578,12 @@ export const FilteredSceneList = (props: IFilteredScenes) => { Mousetrap.unbind("e"); Mousetrap.unbind("d d"); }; - }); + }, [onSelectAll, onSelectNone, hasSelection, onEdit, onDelete]); useZoomKeybinds({ zoomIndex: filter.zoomIndex, onChangeZoom: (zoom) => setFilter(filter.setZoom(zoom)), }); - const onCloseEditDelete = useCloseEditDelete({ - closeModal, - onSelectNone, - result, - }); - const metadataByline = useMemo(() => { if (cachedResult.loading) return null; @@ -636,21 +652,6 @@ export const FilteredSceneList = (props: IFilteredScenes) => { ); } - function onEdit() { - showModal( - - ); - } - - function onDelete() { - showModal( - - ); - } - const otherOperations = [ { text: intl.formatMessage({ id: "actions.play" }), @@ -677,6 +678,11 @@ export const FilteredSceneList = (props: IFilteredScenes) => { onClick: () => onSelectNone(), isDisplayed: () => hasSelection, }, + { + text: intl.formatMessage({ id: "actions.invert_selection" }), + onClick: () => onInvertSelection(), + isDisplayed: () => totalCount > 0, + }, { text: intl.formatMessage({ id: "actions.play_random" }), onClick: playRandom, diff --git a/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md b/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md index 69006d429..f6cd29334 100644 --- a/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md +++ b/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md @@ -41,6 +41,7 @@ | `Ctrl + End` | Go to last page of results | | `s a` | Select all on page | | `s n` | Unselect all | +| `s i` | Invert selection | | `e` | Edit selected | | `d d` | Delete selected | diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index dadf8fd24..9fc6f0c0d 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -118,6 +118,7 @@ "select_entity": "Select {entityType}", "select_folders": "Select folders", "select_none": "Select None", + "invert_selection": "Invert Selection", "selective_auto_tag": "Selective Auto Tag", "selective_clean": "Selective Clean", "selective_scan": "Selective Scan", From d7d7530c780e412f3d2d943844ddc85962de6893 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:28:44 +1100 Subject: [PATCH 029/177] Add non-binary gender icon and colour transgender icons (#6489) * Add data-gender to gender icon and color transgender gender icons * Upgrade fontawesome to 7.1 * Add non-binary icon and fix title not showing --- ui/v2.5/package.json | 10 +-- ui/v2.5/pnpm-lock.yaml | 63 ++++++++++--------- .../src/components/Performers/GenderIcon.tsx | 36 +++++++---- ui/v2.5/src/components/Performers/styles.scss | 22 ++++--- 4 files changed, 75 insertions(+), 56 deletions(-) diff --git a/ui/v2.5/package.json b/ui/v2.5/package.json index 5913540db..f774aedbd 100644 --- a/ui/v2.5/package.json +++ b/ui/v2.5/package.json @@ -27,11 +27,11 @@ "@formatjs/intl-locale": "^3.0.11", "@formatjs/intl-numberformat": "^8.3.3", "@formatjs/intl-pluralrules": "^5.1.8", - "@fortawesome/fontawesome-svg-core": "^6.3.0", - "@fortawesome/free-brands-svg-icons": "^6.3.0", - "@fortawesome/free-regular-svg-icons": "^6.3.0", - "@fortawesome/free-solid-svg-icons": "^6.3.0", - "@fortawesome/react-fontawesome": "^0.2.0", + "@fortawesome/fontawesome-svg-core": "^7.1.0", + "@fortawesome/free-brands-svg-icons": "^7.1.0", + "@fortawesome/free-regular-svg-icons": "^7.1.0", + "@fortawesome/free-solid-svg-icons": "^7.1.0", + "@fortawesome/react-fontawesome": "^0.2.6", "@react-hook/resize-observer": "^1.2.6", "@silvermine/videojs-airplay": "^1.2.0", "@silvermine/videojs-chromecast": "^1.4.1", diff --git a/ui/v2.5/pnpm-lock.yaml b/ui/v2.5/pnpm-lock.yaml index 16fef0a19..27b993864 100644 --- a/ui/v2.5/pnpm-lock.yaml +++ b/ui/v2.5/pnpm-lock.yaml @@ -27,20 +27,20 @@ importers: specifier: ^5.1.8 version: 5.4.6 '@fortawesome/fontawesome-svg-core': - specifier: ^6.3.0 - version: 6.7.2 + specifier: ^7.1.0 + version: 7.1.0 '@fortawesome/free-brands-svg-icons': - specifier: ^6.3.0 - version: 6.7.2 + specifier: ^7.1.0 + version: 7.1.0 '@fortawesome/free-regular-svg-icons': - specifier: ^6.3.0 - version: 6.7.2 + specifier: ^7.1.0 + version: 7.1.0 '@fortawesome/free-solid-svg-icons': - specifier: ^6.3.0 - version: 6.7.2 + specifier: ^7.1.0 + version: 7.1.0 '@fortawesome/react-fontawesome': - specifier: ^0.2.0 - version: 0.2.6(@fortawesome/fontawesome-svg-core@6.7.2)(react@17.0.2) + specifier: ^0.2.6 + version: 0.2.6(@fortawesome/fontawesome-svg-core@7.1.0)(react@17.0.2) '@react-hook/resize-observer': specifier: ^1.2.6 version: 1.2.6(react@17.0.2) @@ -1262,28 +1262,29 @@ packages: ts-jest: optional: true - '@fortawesome/fontawesome-common-types@6.7.2': - resolution: {integrity: sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==} + '@fortawesome/fontawesome-common-types@7.1.0': + resolution: {integrity: sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA==} engines: {node: '>=6'} - '@fortawesome/fontawesome-svg-core@6.7.2': - resolution: {integrity: sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==} + '@fortawesome/fontawesome-svg-core@7.1.0': + resolution: {integrity: sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==} engines: {node: '>=6'} - '@fortawesome/free-brands-svg-icons@6.7.2': - resolution: {integrity: sha512-zu0evbcRTgjKfrr77/2XX+bU+kuGfjm0LbajJHVIgBWNIDzrhpRxiCPNT8DW5AdmSsq7Mcf9D1bH0aSeSUSM+Q==} + '@fortawesome/free-brands-svg-icons@7.1.0': + resolution: {integrity: sha512-9byUd9bgNfthsZAjBl6GxOu1VPHgBuRUP9juI7ZoM98h8xNPTCTagfwUFyYscdZq4Hr7gD1azMfM9s5tIWKZZA==} engines: {node: '>=6'} - '@fortawesome/free-regular-svg-icons@6.7.2': - resolution: {integrity: sha512-7Z/ur0gvCMW8G93dXIQOkQqHo2M5HLhYrRVC0//fakJXxcF1VmMPsxnG6Ee8qEylA8b8Q3peQXWMNZ62lYF28g==} + '@fortawesome/free-regular-svg-icons@7.1.0': + resolution: {integrity: sha512-0e2fdEyB4AR+e6kU4yxwA/MonnYcw/CsMEP9lH82ORFi9svA6/RhDyhxIv5mlJaldmaHLLYVTb+3iEr+PDSZuQ==} engines: {node: '>=6'} - '@fortawesome/free-solid-svg-icons@6.7.2': - resolution: {integrity: sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==} + '@fortawesome/free-solid-svg-icons@7.1.0': + resolution: {integrity: sha512-Udu3K7SzAo9N013qt7qmm22/wo2hADdheXtBfxFTecp+ogsc0caQNRKEb7pkvvagUGOpG9wJC1ViH6WXs8oXIA==} engines: {node: '>=6'} '@fortawesome/react-fontawesome@0.2.6': resolution: {integrity: sha512-mtBFIi1UsYQo7rYonYFkjgYKGoL8T+fEH6NGUpvuqtY3ytMsAoDaPo5rk25KuMtKDipY4bGYM/CkmCHA1N3FUg==} + deprecated: v0.2.x is no longer supported. Unless you are still using FontAwesome 5, please update to v3.1.1 or greater. peerDependencies: '@fortawesome/fontawesome-svg-core': ~1 || ~6 || ~7 react: ^16.3 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -6485,27 +6486,27 @@ snapshots: tslib: 2.8.1 typescript: 4.8.4 - '@fortawesome/fontawesome-common-types@6.7.2': {} + '@fortawesome/fontawesome-common-types@7.1.0': {} - '@fortawesome/fontawesome-svg-core@6.7.2': + '@fortawesome/fontawesome-svg-core@7.1.0': dependencies: - '@fortawesome/fontawesome-common-types': 6.7.2 + '@fortawesome/fontawesome-common-types': 7.1.0 - '@fortawesome/free-brands-svg-icons@6.7.2': + '@fortawesome/free-brands-svg-icons@7.1.0': dependencies: - '@fortawesome/fontawesome-common-types': 6.7.2 + '@fortawesome/fontawesome-common-types': 7.1.0 - '@fortawesome/free-regular-svg-icons@6.7.2': + '@fortawesome/free-regular-svg-icons@7.1.0': dependencies: - '@fortawesome/fontawesome-common-types': 6.7.2 + '@fortawesome/fontawesome-common-types': 7.1.0 - '@fortawesome/free-solid-svg-icons@6.7.2': + '@fortawesome/free-solid-svg-icons@7.1.0': dependencies: - '@fortawesome/fontawesome-common-types': 6.7.2 + '@fortawesome/fontawesome-common-types': 7.1.0 - '@fortawesome/react-fontawesome@0.2.6(@fortawesome/fontawesome-svg-core@6.7.2)(react@17.0.2)': + '@fortawesome/react-fontawesome@0.2.6(@fortawesome/fontawesome-svg-core@7.1.0)(react@17.0.2)': dependencies: - '@fortawesome/fontawesome-svg-core': 6.7.2 + '@fortawesome/fontawesome-svg-core': 7.1.0 prop-types: 15.8.1 react: 17.0.2 diff --git a/ui/v2.5/src/components/Performers/GenderIcon.tsx b/ui/v2.5/src/components/Performers/GenderIcon.tsx index 516e70dbd..6f40a2206 100644 --- a/ui/v2.5/src/components/Performers/GenderIcon.tsx +++ b/ui/v2.5/src/components/Performers/GenderIcon.tsx @@ -3,6 +3,7 @@ import { faVenus, faTransgenderAlt, faMars, + faNonBinary, } from "@fortawesome/free-solid-svg-icons"; import * as GQL from "src/core/generated-graphql"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -13,21 +14,34 @@ interface IIconProps { className?: string; } +function genderIcon(gender: GQL.GenderEnum) { + switch (gender) { + case GQL.GenderEnum.Male: + return faMars; + case GQL.GenderEnum.Female: + return faVenus; + case GQL.GenderEnum.NonBinary: + return faNonBinary; + default: + return faTransgenderAlt; + } +} + const GenderIcon: React.FC = ({ gender, className }) => { const intl = useIntl(); if (gender) { - const icon = - gender === GQL.GenderEnum.Male - ? faMars - : gender === GQL.GenderEnum.Female - ? faVenus - : faTransgenderAlt; + const icon = genderIcon(gender); + + // new version of fontawesome doesn't seem to support titles on icons, so adding it + // to a span instead return ( - + + + ); } return null; diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index c3cebf997..17ca3a737 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -193,17 +193,21 @@ display: flex; } -.fa-mars { - color: #89cff0; -} +.gender-icon { + &[data-gender="FEMALE"], + &[data-gender="TRANSGENDER_FEMALE"] { + color: #f38cac; + } -.fa-venus { - color: #f38cac; -} + &[data-gender="MALE"], + &[data-gender="TRANSGENDER_MALE"] { + color: #89cff0; + } -.fa-transgender, -.fa-transgender-alt { - color: #c8a2c8; + &[data-gender="NON_BINARY"], + &[data-gender="INTERSEX"] { + color: #c8a2c8; + } } .performer-height .height-imperial, From 2a5b59a96af3d0713e9851d19121b60b95c2132f Mon Sep 17 00:00:00 2001 From: moonrise-outshoot <254815311+moonrise-outshoot@users.noreply.github.com> Date: Wed, 14 Jan 2026 13:52:50 +1000 Subject: [PATCH 030/177] Fix duplicate file detection in zip archives (#6493) When scanning a zip archive duplicate images are being detected as renames rather than duplicates. This is because in `scanJob.getFileFS` the size of the inner file (`my_archive.zip/001.png`) was being passed to `OpenZip` rather than the size of the zip archive (`my_archive.zip`), causing it to fail when opening the archive. This caused `handleRename` to incorrectly detect it as a rename. The effects of that are: - no info on duplicates in the file data - the file will take the name/path of the final duplicate scanned rather than the first --- pkg/file/scan.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/file/scan.go b/pkg/file/scan.go index 803457665..36b409c89 100644 --- a/pkg/file/scan.go +++ b/pkg/file/scan.go @@ -895,7 +895,8 @@ func (s *scanJob) getFileFS(f *models.BaseFile) (models.FS, error) { } zipPath := f.ZipFile.Base().Path - return fs.OpenZip(zipPath, f.Size) + zipSize := f.ZipFile.Base().Size + return fs.OpenZip(zipPath, zipSize) } func (s *scanJob) handleRename(ctx context.Context, f models.File, fp []models.Fingerprint) (models.File, error) { From ed3a2393667a3a9ea63fb40ec09c66717ca086f1 Mon Sep 17 00:00:00 2001 From: sashapp Date: Wed, 14 Jan 2026 03:53:40 +0000 Subject: [PATCH 031/177] Implement stash_ids_endpoint for the SceneFilterType (#6401) * Implement stash_ids_endpoint for the SceneFilterType * Reduce code duplication by calling the stashIDsCriterionHandler from the stashIDCriterionHandler * Mark stash_id_endpoint in SceneFilterType, StudioFilterType, and PerformerFilterType as deprecated --- graphql/schema/types/filters.graphql | 25 +++++++++- pkg/models/performer.go | 2 + pkg/models/scene.go | 2 + pkg/models/stash_ids.go | 10 +++- pkg/models/studio.go | 2 + pkg/models/tag.go | 2 + pkg/sqlite/criterion_handlers.go | 69 ++++++++++++++++++++++++---- pkg/sqlite/performer_filter.go | 6 +++ pkg/sqlite/performer_test.go | 56 ++++++++++++++++++++++ pkg/sqlite/scene_filter.go | 7 ++- pkg/sqlite/scene_test.go | 56 ++++++++++++++++++++++ pkg/sqlite/setup_test.go | 6 +-- pkg/sqlite/studio_filter.go | 6 +++ pkg/sqlite/tag_filter.go | 6 +++ pkg/sqlite/tag_test.go | 56 ++++++++++++++++++++++ 15 files changed, 297 insertions(+), 14 deletions(-) diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index bb312e31d..4cf25d840 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -84,13 +84,23 @@ input PHashDuplicationCriterionInput { input StashIDCriterionInput { """ If present, this value is treated as a predicate. - That is, it will filter based on stash_ids with the matching endpoint + That is, it will filter based on stash_id with the matching endpoint """ endpoint: String stash_id: String modifier: CriterionModifier! } +input StashIDsCriterionInput { + """ + If present, this value is treated as a predicate. + That is, it will filter based on stash_ids with the matching endpoint + """ + endpoint: String + stash_ids: [String] + modifier: CriterionModifier! +} + input CustomFieldCriterionInput { field: String! value: [Any!] @@ -156,6 +166,9 @@ input PerformerFilterType { o_counter: IntCriterionInput "Filter by StashID" stash_id_endpoint: StashIDCriterionInput + @deprecated(reason: "use stash_ids_endpoint instead") + "Filter by StashIDs" + stash_ids_endpoint: StashIDsCriterionInput # rating expressed as 1-100 rating100: IntCriterionInput "Filter by url" @@ -292,6 +305,9 @@ input SceneFilterType { performer_count: IntCriterionInput "Filter by StashID" stash_id_endpoint: StashIDCriterionInput + @deprecated(reason: "use stash_ids_endpoint instead") + "Filter by StashIDs" + stash_ids_endpoint: StashIDsCriterionInput "Filter by url" url: StringCriterionInput "Filter by interactive" @@ -432,6 +448,9 @@ input StudioFilterType { parents: MultiCriterionInput "Filter by StashID" stash_id_endpoint: StashIDCriterionInput + @deprecated(reason: "use stash_ids_endpoint instead") + "Filter by StashIDs" + stash_ids_endpoint: StashIDsCriterionInput "Filter to only include studios with these tags" tags: HierarchicalMultiCriterionInput "Filter to only include studios missing this property" @@ -608,6 +627,10 @@ input TagFilterType { "Filter by StashID" stash_id_endpoint: StashIDCriterionInput + @deprecated(reason: "use stash_ids_endpoint instead") + + "Filter by StashID" + stash_ids_endpoint: StashIDsCriterionInput "Filter by related scenes that meet this criteria" scenes_filter: SceneFilterType diff --git a/pkg/models/performer.go b/pkg/models/performer.go index 239d8347f..63a08b30c 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -166,6 +166,8 @@ type PerformerFilterType struct { StashID *StringCriterionInput `json:"stash_id"` // Filter by StashID Endpoint StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"` + // Filter by StashIDs Endpoint + StashIDsEndpoint *StashIDsCriterionInput `json:"stash_ids_endpoint"` // Filter by rating expressed as 1-100 Rating100 *IntCriterionInput `json:"rating100"` // Filter by url diff --git a/pkg/models/scene.go b/pkg/models/scene.go index f0a863bf7..1c34967c6 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -79,6 +79,8 @@ type SceneFilterType struct { StashID *StringCriterionInput `json:"stash_id"` // Filter by StashID Endpoint StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"` + // Filter by StashIDs Endpoint + StashIDsEndpoint *StashIDsCriterionInput `json:"stash_ids_endpoint"` // Filter by url URL *StringCriterionInput `json:"url"` // Filter by interactive diff --git a/pkg/models/stash_ids.go b/pkg/models/stash_ids.go index d761e959f..d73bfd880 100644 --- a/pkg/models/stash_ids.go +++ b/pkg/models/stash_ids.go @@ -129,8 +129,16 @@ func (u *UpdateStashIDs) Set(v StashID) { type StashIDCriterionInput struct { // If present, this value is treated as a predicate. - // That is, it will filter based on stash_ids with the matching endpoint + // That is, it will filter based on stash_id with the matching endpoint Endpoint *string `json:"endpoint"` StashID *string `json:"stash_id"` Modifier CriterionModifier `json:"modifier"` } + +type StashIDsCriterionInput struct { + // If present, this value is treated as a predicate. + // That is, it will filter based on stash_ids with the matching endpoint + Endpoint *string `json:"endpoint"` + StashIDs []*string `json:"stash_ids"` + Modifier CriterionModifier `json:"modifier"` +} diff --git a/pkg/models/studio.go b/pkg/models/studio.go index 171168129..fd306b16c 100644 --- a/pkg/models/studio.go +++ b/pkg/models/studio.go @@ -10,6 +10,8 @@ type StudioFilterType struct { StashID *StringCriterionInput `json:"stash_id"` // Filter by StashID Endpoint StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"` + // Filter by StashIDs Endpoint + StashIDsEndpoint *StashIDsCriterionInput `json:"stash_ids_endpoint"` // Filter to only include studios missing this property IsMissing *string `json:"is_missing"` // Filter by rating expressed as 1-100 diff --git a/pkg/models/tag.go b/pkg/models/tag.go index 29b7e9be3..69d4f9e3c 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -42,6 +42,8 @@ type TagFilterType struct { IgnoreAutoTag *bool `json:"ignore_auto_tag"` // Filter by StashID Endpoint StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"` + // Filter by StashIDs Endpoint + StashIDsEndpoint *StashIDsCriterionInput `json:"stash_ids_endpoint"` // Filter by related scenes that meet this criteria ScenesFilter *SceneFilterType `json:"scenes_filter"` // Filter by related images that meet this criteria diff --git a/pkg/sqlite/criterion_handlers.go b/pkg/sqlite/criterion_handlers.go index fe6d1fcb5..c848f1a8b 100644 --- a/pkg/sqlite/criterion_handlers.go +++ b/pkg/sqlite/criterion_handlers.go @@ -1012,6 +1012,41 @@ func (h *stashIDCriterionHandler) handle(ctx context.Context, f *filterBuilder) return } + var stashIDs []*string + if h.c.StashID != nil { + stashIDs = []*string{h.c.StashID} + } else { + stashIDs = nil + } + + convertedInput := &models.StashIDsCriterionInput{ + Endpoint: h.c.Endpoint, + StashIDs: stashIDs, + Modifier: h.c.Modifier, + } + + convertedHandler := stashIDsCriterionHandler{ + c: convertedInput, + stashIDRepository: h.stashIDRepository, + stashIDTableAs: h.stashIDTableAs, + parentIDCol: h.parentIDCol, + } + + convertedHandler.handle(ctx, f) +} + +type stashIDsCriterionHandler struct { + c *models.StashIDsCriterionInput + stashIDRepository *stashIDRepository + stashIDTableAs string + parentIDCol string +} + +func (h *stashIDsCriterionHandler) handle(ctx context.Context, f *filterBuilder) { + if h.c == nil { + return + } + stashIDRepo := h.stashIDRepository t := stashIDRepo.tableName if h.stashIDTableAs != "" { @@ -1025,15 +1060,33 @@ func (h *stashIDCriterionHandler) handle(ctx context.Context, f *filterBuilder) f.addLeftJoin(stashIDRepo.tableName, h.stashIDTableAs, joinClause) - v := "" - if h.c.StashID != nil { - v = *h.c.StashID - } + if len(h.c.StashIDs) == 0 { + stringCriterionHandler(&models.StringCriterionInput{ + Value: "", + Modifier: h.c.Modifier, + }, t+".stash_id")(ctx, f) + } else { + b := f + for _, n := range h.c.StashIDs { + query := &filterBuilder{} + v := "" + if n != nil { + v = *n + } - stringCriterionHandler(&models.StringCriterionInput{ - Value: v, - Modifier: h.c.Modifier, - }, t+".stash_id")(ctx, f) + stringCriterionHandler(&models.StringCriterionInput{ + Value: v, + Modifier: h.c.Modifier, + }, t+".stash_id")(ctx, query) + + if h.c.Modifier == models.CriterionModifierNotEquals { + b.and(query) + } else { + b.or(query) + } + b = query + } + } } type relatedFilterHandler struct { diff --git a/pkg/sqlite/performer_filter.go b/pkg/sqlite/performer_filter.go index 11d3138bc..401664e33 100644 --- a/pkg/sqlite/performer_filter.go +++ b/pkg/sqlite/performer_filter.go @@ -148,6 +148,12 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler { stashIDTableAs: "performer_stash_ids", parentIDCol: "performers.id", }, + &stashIDsCriterionHandler{ + c: filter.StashIDsEndpoint, + stashIDRepository: &performerRepository.stashIDs, + stashIDTableAs: "performer_stash_ids", + parentIDCol: "performers.id", + }, qb.aliasCriterionHandler(filter.Aliases), diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index a88166657..8d53ca0db 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -1069,6 +1069,8 @@ func TestPerformerQuery(t *testing.T) { var ( endpoint = performerStashID(performerIdxWithGallery).Endpoint stashID = performerStashID(performerIdxWithGallery).StashID + stashID2 = performerStashID(performerIdx1WithGallery).StashID + stashIDs = []*string{&stashID, &stashID2} ) tests := []struct { @@ -1133,6 +1135,60 @@ func TestPerformerQuery(t *testing.T) { nil, false, }, + { + "stash ids with endpoint", + nil, + &models.PerformerFilterType{ + StashIDsEndpoint: &models.StashIDsCriterionInput{ + Endpoint: &endpoint, + StashIDs: stashIDs, + Modifier: models.CriterionModifierEquals, + }, + }, + []int{performerIdxWithGallery, performerIdx1WithGallery}, + nil, + false, + }, + { + "exclude stash ids with endpoint", + nil, + &models.PerformerFilterType{ + StashIDsEndpoint: &models.StashIDsCriterionInput{ + Endpoint: &endpoint, + StashIDs: stashIDs, + Modifier: models.CriterionModifierNotEquals, + }, + }, + nil, + []int{performerIdxWithGallery, performerIdx1WithGallery}, + false, + }, + { + "null stash ids with endpoint", + nil, + &models.PerformerFilterType{ + StashIDsEndpoint: &models.StashIDsCriterionInput{ + Endpoint: &endpoint, + Modifier: models.CriterionModifierIsNull, + }, + }, + nil, + []int{performerIdxWithGallery, performerIdx1WithGallery}, + false, + }, + { + "not null stash ids with endpoint", + nil, + &models.PerformerFilterType{ + StashIDsEndpoint: &models.StashIDsCriterionInput{ + Endpoint: &endpoint, + Modifier: models.CriterionModifierNotNull, + }, + }, + []int{performerIdxWithGallery, performerIdx1WithGallery}, + nil, + false, + }, { "circumcised (cut)", nil, diff --git a/pkg/sqlite/scene_filter.go b/pkg/sqlite/scene_filter.go index fad300248..72c75eca5 100644 --- a/pkg/sqlite/scene_filter.go +++ b/pkg/sqlite/scene_filter.go @@ -114,13 +114,18 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler { stringCriterionHandler(sceneFilter.StashID, "scene_stash_ids.stash_id")(ctx, f) } }), - &stashIDCriterionHandler{ c: sceneFilter.StashIDEndpoint, stashIDRepository: &sceneRepository.stashIDs, stashIDTableAs: "scene_stash_ids", parentIDCol: "scenes.id", }, + &stashIDsCriterionHandler{ + c: sceneFilter.StashIDsEndpoint, + stashIDRepository: &sceneRepository.stashIDs, + stashIDTableAs: "scene_stash_ids", + parentIDCol: "scenes.id", + }, boolCriterionHandler(sceneFilter.Interactive, "video_files.interactive", qb.addVideoFilesTable), intCriterionHandler(sceneFilter.InteractiveSpeed, "video_files.interactive_speed", qb.addVideoFilesTable), diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index 1efc4d705..df6676a0f 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -2098,6 +2098,8 @@ func TestSceneQuery(t *testing.T) { var ( endpoint = sceneStashID(sceneIdxWithGallery).Endpoint stashID = sceneStashID(sceneIdxWithGallery).StashID + stashID2 = sceneStashID(sceneIdxWithPerformer).StashID + stashIDs = []*string{&stashID, &stashID2} depth = -1 ) @@ -2203,6 +2205,60 @@ func TestSceneQuery(t *testing.T) { nil, false, }, + { + "stash ids with endpoint", + nil, + &models.SceneFilterType{ + StashIDsEndpoint: &models.StashIDsCriterionInput{ + Endpoint: &endpoint, + StashIDs: stashIDs, + Modifier: models.CriterionModifierEquals, + }, + }, + []int{sceneIdxWithGallery, sceneIdxWithPerformer}, + nil, + false, + }, + { + "exclude stash ids with endpoint", + nil, + &models.SceneFilterType{ + StashIDsEndpoint: &models.StashIDsCriterionInput{ + Endpoint: &endpoint, + StashIDs: stashIDs, + Modifier: models.CriterionModifierNotEquals, + }, + }, + nil, + []int{sceneIdxWithGallery, sceneIdxWithPerformer}, + false, + }, + { + "null stash ids with endpoint", + nil, + &models.SceneFilterType{ + StashIDsEndpoint: &models.StashIDsCriterionInput{ + Endpoint: &endpoint, + Modifier: models.CriterionModifierIsNull, + }, + }, + nil, + []int{sceneIdxWithGallery, sceneIdxWithPerformer}, + false, + }, + { + "not null stash ids with endpoint", + nil, + &models.SceneFilterType{ + StashIDsEndpoint: &models.StashIDsCriterionInput{ + Endpoint: &endpoint, + Modifier: models.CriterionModifierNotNull, + }, + }, + []int{sceneIdxWithGallery, sceneIdxWithPerformer}, + nil, + false, + }, { "with studio id 0 including child studios", nil, diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 63c66fd06..7e6f821d1 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1079,7 +1079,7 @@ func getObjectDate(index int) *models.Date { func sceneStashID(i int) models.StashID { return models.StashID{ StashID: getSceneStringValue(i, "stashid"), - Endpoint: getSceneStringValue(i, "endpoint"), + Endpoint: getSceneStringValue(0, "endpoint"), UpdatedAt: epochTime, } } @@ -1547,7 +1547,7 @@ func getIgnoreAutoTag(index int) bool { func performerStashID(i int) models.StashID { return models.StashID{ StashID: getPerformerStringValue(i, "stashid"), - Endpoint: getPerformerStringValue(i, "endpoint"), + Endpoint: getPerformerStringValue(0, "endpoint"), } } @@ -1700,7 +1700,7 @@ func getTagChildCount(id int) int { func tagStashID(i int) models.StashID { return models.StashID{ StashID: getTagStringValue(i, "stashid"), - Endpoint: getTagStringValue(i, "endpoint"), + Endpoint: getTagStringValue(0, "endpoint"), } } diff --git a/pkg/sqlite/studio_filter.go b/pkg/sqlite/studio_filter.go index 6ff7fcced..83a917701 100644 --- a/pkg/sqlite/studio_filter.go +++ b/pkg/sqlite/studio_filter.go @@ -72,6 +72,12 @@ func (qb *studioFilterHandler) criterionHandler() criterionHandler { stashIDTableAs: "studio_stash_ids", parentIDCol: "studios.id", }, + &stashIDsCriterionHandler{ + c: studioFilter.StashIDsEndpoint, + stashIDRepository: &studioRepository.stashIDs, + stashIDTableAs: "studio_stash_ids", + parentIDCol: "studios.id", + }, qb.isMissingCriterionHandler(studioFilter.IsMissing), qb.tagCountCriterionHandler(studioFilter.TagCount), diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index 27ccf3c09..344b7de91 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -91,6 +91,12 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler { stashIDTableAs: "tag_stash_ids", parentIDCol: "tags.id", }, + &stashIDsCriterionHandler{ + c: tagFilter.StashIDsEndpoint, + stashIDRepository: &tagRepository.stashIDs, + stashIDTableAs: "tag_stash_ids", + parentIDCol: "tags.id", + }, ×tampCriterionHandler{tagFilter.CreatedAt, "tags.created_at", nil}, ×tampCriterionHandler{tagFilter.UpdatedAt, "tags.updated_at", nil}, diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index 18fe486bc..f1bac19b2 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -356,6 +356,8 @@ func TestTagQuery(t *testing.T) { var ( endpoint = tagStashID(tagIdxWithPerformer).Endpoint stashID = tagStashID(tagIdxWithPerformer).StashID + stashID2 = tagStashID(tagIdx1WithPerformer).StashID + stashIDs = []*string{&stashID, &stashID2} ) tests := []struct { @@ -420,6 +422,60 @@ func TestTagQuery(t *testing.T) { nil, false, }, + { + "stash ids with endpoint", + nil, + &models.TagFilterType{ + StashIDsEndpoint: &models.StashIDsCriterionInput{ + Endpoint: &endpoint, + StashIDs: stashIDs, + Modifier: models.CriterionModifierEquals, + }, + }, + []int{tagIdxWithPerformer, tagIdx1WithPerformer}, + nil, + false, + }, + { + "exclude stash ids with endpoint", + nil, + &models.TagFilterType{ + StashIDsEndpoint: &models.StashIDsCriterionInput{ + Endpoint: &endpoint, + StashIDs: stashIDs, + Modifier: models.CriterionModifierNotEquals, + }, + }, + nil, + []int{tagIdxWithPerformer, tagIdx1WithPerformer}, + false, + }, + { + "null stash ids with endpoint", + nil, + &models.TagFilterType{ + StashIDsEndpoint: &models.StashIDsCriterionInput{ + Endpoint: &endpoint, + Modifier: models.CriterionModifierIsNull, + }, + }, + nil, + []int{tagIdxWithPerformer, tagIdx1WithPerformer}, + false, + }, + { + "not null stash ids with endpoint", + nil, + &models.TagFilterType{ + StashIDsEndpoint: &models.StashIDsCriterionInput{ + Endpoint: &endpoint, + Modifier: models.CriterionModifierNotNull, + }, + }, + []int{tagIdxWithPerformer, tagIdx1WithPerformer}, + nil, + false, + }, } for _, tt := range tests { From 5b3785f16490e8bd603dbe71915a6233379e72e1 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 16 Jan 2026 10:21:15 +1100 Subject: [PATCH 032/177] Revert stashIDCriterionHandler changes. Reimplement stashIDsCriterionHandler to not use stringCriterionHandler (#6496) --- pkg/sqlite/criterion_handlers.go | 83 ++++++++++++++++---------------- pkg/sqlite/sql.go | 2 + 2 files changed, 43 insertions(+), 42 deletions(-) diff --git a/pkg/sqlite/criterion_handlers.go b/pkg/sqlite/criterion_handlers.go index c848f1a8b..6fe9c7ce9 100644 --- a/pkg/sqlite/criterion_handlers.go +++ b/pkg/sqlite/criterion_handlers.go @@ -1012,27 +1012,33 @@ func (h *stashIDCriterionHandler) handle(ctx context.Context, f *filterBuilder) return } - var stashIDs []*string + // ideally, this handler should just convert to stashIDsCriterionHandler + // but there are some differences in how the existing handler works compared + // to the new code, specifically because this code uses the stringCriterionHandler. + // To minimise potential regressions, we'll keep the existing logic for now. + + stashIDRepo := h.stashIDRepository + t := stashIDRepo.tableName + if h.stashIDTableAs != "" { + t = h.stashIDTableAs + } + + joinClause := fmt.Sprintf("%s.%s = %s", t, stashIDRepo.idColumn, h.parentIDCol) + if h.c.Endpoint != nil && *h.c.Endpoint != "" { + joinClause += fmt.Sprintf(" AND %s.endpoint = '%s'", t, *h.c.Endpoint) + } + + f.addLeftJoin(stashIDRepo.tableName, h.stashIDTableAs, joinClause) + + v := "" if h.c.StashID != nil { - stashIDs = []*string{h.c.StashID} - } else { - stashIDs = nil + v = *h.c.StashID } - convertedInput := &models.StashIDsCriterionInput{ - Endpoint: h.c.Endpoint, - StashIDs: stashIDs, + stringCriterionHandler(&models.StringCriterionInput{ + Value: v, Modifier: h.c.Modifier, - } - - convertedHandler := stashIDsCriterionHandler{ - c: convertedInput, - stashIDRepository: h.stashIDRepository, - stashIDTableAs: h.stashIDTableAs, - parentIDCol: h.parentIDCol, - } - - convertedHandler.handle(ctx, f) + }, t+".stash_id")(ctx, f) } type stashIDsCriterionHandler struct { @@ -1060,32 +1066,25 @@ func (h *stashIDsCriterionHandler) handle(ctx context.Context, f *filterBuilder) f.addLeftJoin(stashIDRepo.tableName, h.stashIDTableAs, joinClause) - if len(h.c.StashIDs) == 0 { - stringCriterionHandler(&models.StringCriterionInput{ - Value: "", - Modifier: h.c.Modifier, - }, t+".stash_id")(ctx, f) - } else { - b := f - for _, n := range h.c.StashIDs { - query := &filterBuilder{} - v := "" - if n != nil { - v = *n - } - - stringCriterionHandler(&models.StringCriterionInput{ - Value: v, - Modifier: h.c.Modifier, - }, t+".stash_id")(ctx, query) - - if h.c.Modifier == models.CriterionModifierNotEquals { - b.and(query) - } else { - b.or(query) - } - b = query + switch h.c.Modifier { + case models.CriterionModifierIsNull: + f.addWhere(fmt.Sprintf("%s.stash_id IS NULL", t)) + case models.CriterionModifierNotNull: + f.addWhere(fmt.Sprintf("%s.stash_id IS NOT NULL", t)) + case models.CriterionModifierEquals: + var clauses []sqlClause + for _, id := range h.c.StashIDs { + clauses = append(clauses, makeClause(fmt.Sprintf("%s.stash_id = ?", t), id)) } + f.whereClauses = append(f.whereClauses, orClauses(clauses...)) + case models.CriterionModifierNotEquals: + var clauses []sqlClause + for _, id := range h.c.StashIDs { + clauses = append(clauses, makeClause(fmt.Sprintf("%s.stash_id != ?", t), id)) + } + f.whereClauses = append(f.whereClauses, andClauses(clauses...)) + default: + f.setError(fmt.Errorf("invalid modifier %s for stash IDs criterion", h.c.Modifier)) } } diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index 2d5922555..0b55af8db 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -137,6 +137,8 @@ func getCountSort(primaryTable, joinTable, primaryFK, direction string) string { return fmt.Sprintf(" ORDER BY (SELECT COUNT(*) FROM %s AS sort WHERE sort.%s = %s.id) %s", joinTable, primaryFK, primaryTable, getSortDirection(direction)) } +// getStringSearchClause returns a sqlClause for searching strings in the provided columns. +// It is used for includes and excludes string criteria. func getStringSearchClause(columns []string, q string, not bool) sqlClause { var likeClauses []string var args []interface{} From bef4e3fbd585a272f51c71aaf16d06185d3e26d9 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Mon, 26 Jan 2026 19:26:26 -0800 Subject: [PATCH 033/177] Feature: Add "Troubleshooting Mode" (#6343) Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Co-authored-by: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> --- graphql/schema/types/config.graphql | 6 ++ internal/api/resolver_mutation_configure.go | 2 + internal/api/resolver_query_configuration.go | 2 + internal/api/server.go | 6 +- internal/manager/config/config.go | 8 ++ ui/v2.5/graphql/data/config.graphql | 1 + ui/v2.5/src/App.tsx | 9 +- ui/v2.5/src/components/Help/Manual.tsx | 6 ++ ui/v2.5/src/components/Settings/Settings.tsx | 4 + ui/v2.5/src/components/Settings/styles.scss | 12 +++ .../TroubleshootingModeButton.tsx | 67 +++++++++++++++ .../TroubleshootingModeOverlay.tsx | 28 +++++++ .../useTroubleshootingMode.ts | 83 +++++++++++++++++++ .../src/docs/en/Manual/TroubleshootingMode.md | 7 ++ ui/v2.5/src/index.scss | 37 +++++++++ ui/v2.5/src/locales/en-GB.json | 14 ++++ ui/v2.5/src/plugins.tsx | 28 +++++-- 17 files changed, 309 insertions(+), 11 deletions(-) create mode 100644 ui/v2.5/src/components/TroubleshootingMode/TroubleshootingModeButton.tsx create mode 100644 ui/v2.5/src/components/TroubleshootingMode/TroubleshootingModeOverlay.tsx create mode 100644 ui/v2.5/src/components/TroubleshootingMode/useTroubleshootingMode.ts create mode 100644 ui/v2.5/src/docs/en/Manual/TroubleshootingMode.md diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index b6f52091b..6990d9d95 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -395,6 +395,9 @@ input ConfigInterfaceInput { customLocales: String customLocalesEnabled: Boolean + "When true, disables all customizations (plugins, CSS, JavaScript, locales) for troubleshooting" + disableCustomizations: Boolean + "Interface language" language: String @@ -469,6 +472,9 @@ type ConfigInterfaceResult { customLocales: String customLocalesEnabled: Boolean + "When true, disables all customizations (plugins, CSS, JavaScript, locales) for troubleshooting" + disableCustomizations: Boolean + "Interface language" language: String diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index daed0b5b7..23b61c208 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -515,6 +515,8 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input ConfigI r.setConfigBool(config.CustomLocalesEnabled, input.CustomLocalesEnabled) + r.setConfigBool(config.DisableCustomizations, input.DisableCustomizations) + if input.DisableDropdownCreate != nil { ddc := input.DisableDropdownCreate r.setConfigBool(config.DisableDropdownCreatePerformer, ddc.Performer) diff --git a/internal/api/resolver_query_configuration.go b/internal/api/resolver_query_configuration.go index 8a20fcad1..bc76212eb 100644 --- a/internal/api/resolver_query_configuration.go +++ b/internal/api/resolver_query_configuration.go @@ -156,6 +156,7 @@ func makeConfigInterfaceResult() *ConfigInterfaceResult { javascriptEnabled := config.GetJavascriptEnabled() customLocales := config.GetCustomLocales() customLocalesEnabled := config.GetCustomLocalesEnabled() + disableCustomizations := config.GetDisableCustomizations() language := config.GetLanguage() handyKey := config.GetHandyKey() scriptOffset := config.GetFunscriptOffset() @@ -183,6 +184,7 @@ func makeConfigInterfaceResult() *ConfigInterfaceResult { JavascriptEnabled: &javascriptEnabled, CustomLocales: &customLocales, CustomLocalesEnabled: &customLocalesEnabled, + DisableCustomizations: &disableCustomizations, Language: &language, ImageLightbox: &imageLightboxOptions, diff --git a/internal/api/server.go b/internal/api/server.go index ed11a99a5..a7516da52 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -450,7 +450,7 @@ func cssHandler(c *config.Config) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { var paths []string - if c.GetCSSEnabled() { + if c.GetCSSEnabled() && !c.GetDisableCustomizations() { // search for custom.css in current directory, then $HOME/.stash fn := c.GetCSSPath() exists, _ := fsutil.FileExists(fn) @@ -468,7 +468,7 @@ func javascriptHandler(c *config.Config) func(w http.ResponseWriter, r *http.Req return func(w http.ResponseWriter, r *http.Request) { var paths []string - if c.GetJavascriptEnabled() { + if c.GetJavascriptEnabled() && !c.GetDisableCustomizations() { // search for custom.js in current directory, then $HOME/.stash fn := c.GetJavascriptPath() exists, _ := fsutil.FileExists(fn) @@ -486,7 +486,7 @@ func customLocalesHandler(c *config.Config) func(w http.ResponseWriter, r *http. return func(w http.ResponseWriter, r *http.Request) { buffer := bytes.Buffer{} - if c.GetCustomLocalesEnabled() { + if c.GetCustomLocalesEnabled() && !c.GetDisableCustomizations() { // search for custom-locales.json in current directory, then $HOME/.stash path := c.GetCustomLocalesPath() exists, _ := fsutil.FileExists(path) diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 35534f119..bb99bdcfc 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -194,6 +194,7 @@ const ( CSSEnabled = "cssenabled" JavascriptEnabled = "javascriptenabled" CustomLocalesEnabled = "customlocalesenabled" + DisableCustomizations = "disable_customizations" ShowScrubber = "show_scrubber" showScrubberDefault = true @@ -1479,6 +1480,13 @@ func (i *Config) GetCustomLocalesEnabled() bool { return i.getBool(CustomLocalesEnabled) } +// GetDisableCustomizations returns true if all customizations (plugins, custom CSS, +// custom JavaScript, and custom locales) should be disabled. This is useful for +// troubleshooting issues without permanently disabling individual customizations. +func (i *Config) GetDisableCustomizations() bool { + return i.getBool(DisableCustomizations) +} + func (i *Config) GetHandyKey() string { return i.getString(HandyKey) } diff --git a/ui/v2.5/graphql/data/config.graphql b/ui/v2.5/graphql/data/config.graphql index b65ba21cc..08dcf5d3b 100644 --- a/ui/v2.5/graphql/data/config.graphql +++ b/ui/v2.5/graphql/data/config.graphql @@ -92,6 +92,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult { javascriptEnabled customLocales customLocalesEnabled + disableCustomizations language imageLightbox { slideshowDelay diff --git a/ui/v2.5/src/App.tsx b/ui/v2.5/src/App.tsx index 761352373..d08274b18 100644 --- a/ui/v2.5/src/App.tsx +++ b/ui/v2.5/src/App.tsx @@ -49,6 +49,7 @@ import { PluginRoutes, PluginsLoader } from "./plugins"; // import plugin_api to run code import "./pluginApi"; import { ConnectionMonitor } from "./ConnectionMonitor"; +import { TroubleshootingModeOverlay } from "./components/TroubleshootingMode/TroubleshootingModeOverlay"; import { PatchFunction } from "./patch"; import moment from "moment/min/moment-with-locales"; @@ -352,11 +353,17 @@ export const App: React.FC = () => { formats={intlFormats} > - + {maybeRenderReleaseNotes()} + }> diff --git a/ui/v2.5/src/components/Help/Manual.tsx b/ui/v2.5/src/components/Help/Manual.tsx index d8fc1dbed..e90e2e5ac 100644 --- a/ui/v2.5/src/components/Help/Manual.tsx +++ b/ui/v2.5/src/components/Help/Manual.tsx @@ -23,6 +23,7 @@ import Interactive from "src/docs/en/Manual/Interactive.md"; import Captions from "src/docs/en/Manual/Captions.md"; import Identify from "src/docs/en/Manual/Identify.md"; import Browsing from "src/docs/en/Manual/Browsing.md"; +import TroubleshootingMode from "src/docs/en/Manual/TroubleshootingMode.md"; import { MarkdownPage } from "../Shared/MarkdownPage"; interface IManualProps { @@ -152,6 +153,11 @@ export const Manual: React.FC = ({ title: "Keyboard Shortcuts", content: KeyboardShortcuts, }, + { + key: "TroubleshootingMode.md", + title: "Troubleshooting Mode", + content: TroubleshootingMode, + }, { key: "Contributing.md", title: "Contributing", diff --git a/ui/v2.5/src/components/Settings/Settings.tsx b/ui/v2.5/src/components/Settings/Settings.tsx index 4c2b02455..86a781445 100644 --- a/ui/v2.5/src/components/Settings/Settings.tsx +++ b/ui/v2.5/src/components/Settings/Settings.tsx @@ -18,6 +18,8 @@ import { SettingsContext, useSettings } from "./context"; import { SettingsLibraryPanel } from "./SettingsLibraryPanel"; import { SettingsSecurityPanel } from "./SettingsSecurityPanel"; import Changelog from "../Changelog/Changelog"; +import { TroubleshootingModeButton } from "../TroubleshootingMode/TroubleshootingModeButton"; +import { useTroubleshootingMode } from "../TroubleshootingMode/useTroubleshootingMode"; const validTabs = [ "tasks", @@ -43,6 +45,7 @@ function isTabKey(tab: string | null): tab is TabKey { const SettingTabs: React.FC<{ tab: TabKey }> = ({ tab }) => { const { advancedMode, setAdvancedMode } = useSettings(); + const { isActive: troubleshootingModeActive } = useTroubleshootingMode(); const titleProps = useTitleProps({ id: "settings" }); @@ -148,6 +151,7 @@ const SettingTabs: React.FC<{ tab: TabKey }> = ({ tab }) => { />
+ {!troubleshootingModeActive && }
diff --git a/ui/v2.5/src/components/Settings/styles.scss b/ui/v2.5/src/components/Settings/styles.scss index ed8242ce3..3f3a292b4 100644 --- a/ui/v2.5/src/components/Settings/styles.scss +++ b/ui/v2.5/src/components/Settings/styles.scss @@ -447,3 +447,15 @@ display: inline-block; } } + +.troubleshooting-mode-button { + bottom: 1rem; + left: 1rem; + position: fixed; + z-index: 100; + + @include media-breakpoint-down(xs) { + padding-left: 0.5rem; + position: static; + } +} diff --git a/ui/v2.5/src/components/TroubleshootingMode/TroubleshootingModeButton.tsx b/ui/v2.5/src/components/TroubleshootingMode/TroubleshootingModeButton.tsx new file mode 100644 index 000000000..164774446 --- /dev/null +++ b/ui/v2.5/src/components/TroubleshootingMode/TroubleshootingModeButton.tsx @@ -0,0 +1,67 @@ +import React, { useState } from "react"; +import { Button } from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; +import { faBug } from "@fortawesome/free-solid-svg-icons"; +import { ModalComponent } from "src/components/Shared/Modal"; +import { useTroubleshootingMode } from "./useTroubleshootingMode"; + +const DIALOG_ITEMS = [ + "config.ui.troubleshooting_mode.dialog_item_plugins", + "config.ui.troubleshooting_mode.dialog_item_css", + "config.ui.troubleshooting_mode.dialog_item_js", + "config.ui.troubleshooting_mode.dialog_item_locales", +] as const; + +export const TroubleshootingModeButton: React.FC = () => { + const intl = useIntl(); + const [showDialog, setShowDialog] = useState(false); + const { enable, isLoading } = useTroubleshootingMode(); + + return ( + <> +
+ +
+ + setShowDialog(false)} + header={intl.formatMessage({ + id: "config.ui.troubleshooting_mode.dialog_title", + })} + icon={faBug} + accept={{ + text: intl.formatMessage({ + id: "config.ui.troubleshooting_mode.enable", + }), + variant: "primary", + onClick: enable, + }} + cancel={{ + onClick: () => setShowDialog(false), + variant: "secondary", + }} + isRunning={isLoading} + > +

+ +

+
    + {DIALOG_ITEMS.map((id) => ( +
  • + +
  • + ))} +
+

+ +

+

+ +

+
+ + ); +}; diff --git a/ui/v2.5/src/components/TroubleshootingMode/TroubleshootingModeOverlay.tsx b/ui/v2.5/src/components/TroubleshootingMode/TroubleshootingModeOverlay.tsx new file mode 100644 index 000000000..bf2b38f8a --- /dev/null +++ b/ui/v2.5/src/components/TroubleshootingMode/TroubleshootingModeOverlay.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { Button } from "react-bootstrap"; +import { FormattedMessage } from "react-intl"; +import { faBug } from "@fortawesome/free-solid-svg-icons"; +import { Icon } from "src/components/Shared/Icon"; +import { useTroubleshootingMode } from "./useTroubleshootingMode"; + +export const TroubleshootingModeOverlay: React.FC = () => { + const { isActive, isLoading, disable } = useTroubleshootingMode(); + + if (!isActive) { + return null; + } + + return ( +
+
+ + + + + +
+
+ ); +}; diff --git a/ui/v2.5/src/components/TroubleshootingMode/useTroubleshootingMode.ts b/ui/v2.5/src/components/TroubleshootingMode/useTroubleshootingMode.ts new file mode 100644 index 000000000..63b4edd4f --- /dev/null +++ b/ui/v2.5/src/components/TroubleshootingMode/useTroubleshootingMode.ts @@ -0,0 +1,83 @@ +import { useState, useRef, useEffect } from "react"; +import { + useConfigureInterface, + useConfigureGeneral, + useConfiguration, +} from "src/core/StashService"; + +const ORIGINAL_LOG_LEVEL_KEY = "troubleshootingMode_originalLogLevel"; + +export function useTroubleshootingMode() { + const [isLoading, setIsLoading] = useState(false); + const isMounted = useRef(true); + + const { data: config } = useConfiguration(); + const [configureInterface] = useConfigureInterface(); + const [configureGeneral] = useConfigureGeneral(); + + const isActive = + config?.configuration?.interface?.disableCustomizations ?? false; + const currentLogLevel = config?.configuration?.general?.logLevel || "Info"; + + useEffect(() => { + return () => { + isMounted.current = false; + }; + }, []); + + async function enable() { + setIsLoading(true); + try { + // Store original log level for restoration later + localStorage.setItem(ORIGINAL_LOG_LEVEL_KEY, currentLogLevel); + + // Enable troubleshooting mode and set log level to Debug + await Promise.all([ + configureInterface({ + variables: { input: { disableCustomizations: true } }, + }), + configureGeneral({ + variables: { input: { logLevel: "Debug" } }, + }), + ]); + + window.location.reload(); + } catch (e) { + if (isMounted.current) { + setIsLoading(false); + } + throw e; + } + } + + async function disable() { + setIsLoading(true); + try { + // Restore original log level + const originalLogLevel = + localStorage.getItem(ORIGINAL_LOG_LEVEL_KEY) || "Info"; + + // Disable troubleshooting mode and restore log level + await Promise.all([ + configureInterface({ + variables: { input: { disableCustomizations: false } }, + }), + configureGeneral({ + variables: { input: { logLevel: originalLogLevel } }, + }), + ]); + + // Clean up localStorage + localStorage.removeItem(ORIGINAL_LOG_LEVEL_KEY); + + window.location.reload(); + } catch (e) { + if (isMounted.current) { + setIsLoading(false); + } + throw e; + } + } + + return { isActive, isLoading, enable, disable }; +} diff --git a/ui/v2.5/src/docs/en/Manual/TroubleshootingMode.md b/ui/v2.5/src/docs/en/Manual/TroubleshootingMode.md new file mode 100644 index 000000000..d7a2c1cee --- /dev/null +++ b/ui/v2.5/src/docs/en/Manual/TroubleshootingMode.md @@ -0,0 +1,7 @@ +# Troubleshooting Mode + +Troubleshooting Mode disables all plugins and all custom CSS, JavaScript, and locales. It also temporarily sets the log level to `DEBUG`. This is useful when you are experiencing issues with your Stash instance to eliminate the possibility that a plugin or custom code is causing the issue. + +Troubleshooting Mode is enabled from the Settings page, by clicking the `Troubleshooting Mode` button at the bottom left of the page. + +When Troubleshooting Mode is enabled, a red border and a banner will be displayed to remind you that you are in Troubleshooting Mode. To exit Troubleshooting Mode, click the `Exit` button in the banner. \ No newline at end of file diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index 0c0bffdec..24679c158 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -1438,3 +1438,40 @@ select { h3 .TruncatedText { line-height: 1.5; } + +// Troubleshooting Mode overlay banner +.troubleshooting-mode-overlay { + border: 5px solid $danger; + bottom: 0; + left: 0; + opacity: 0.75; + pointer-events: none; + position: fixed; + right: 0; + top: 0; + z-index: 1040; + + .troubleshooting-mode-alert { + align-items: baseline; + border-radius: 0; + bottom: 0.5rem; + display: inline-flex; + margin: 0; + position: fixed; + right: 0.5rem; + + @include media-breakpoint-down(xs) { + @media (orientation: portrait) { + bottom: $navbar-height; + + & > span { + font-size: 0.75rem; + } + } + } + } + + .btn { + pointer-events: auto; + } +} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 9fc6f0c0d..4cec0af66 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -616,6 +616,20 @@ "heading": "Custom CSS", "option_label": "Custom CSS enabled" }, + "troubleshooting_mode": { + "button": "Troubleshooting Mode", + "dialog_title": "Enable Troubleshooting Mode", + "dialog_description": "This will temporarily disable all customizations to help diagnose issues:", + "dialog_item_plugins": "All plugins", + "dialog_item_css": "Custom CSS", + "dialog_item_js": "Custom JavaScript", + "dialog_item_locales": "Custom locales", + "dialog_log_level": "Log level will be set to Debug for detailed diagnostics.", + "dialog_reload_note": "The page will reload automatically.", + "enable": "Enable & Reload", + "overlay_message": "Troubleshooting Mode is active - all customizations are disabled", + "exit": "Exit" + }, "custom_javascript": { "description": "Page must be reloaded for changes to take effect. There is no guarantee of compatibility between custom Javascript and future releases of Stash.", "heading": "Custom Javascript", diff --git a/ui/v2.5/src/plugins.tsx b/ui/v2.5/src/plugins.tsx index 41577a92c..00ffb9ca4 100644 --- a/ui/v2.5/src/plugins.tsx +++ b/ui/v2.5/src/plugins.tsx @@ -59,7 +59,8 @@ function sortPlugins(plugins: PluginList) { // load all plugins and their dependencies // returns true when all plugins are loaded, regardess of success or failure -function useLoadPlugins() { +// if disableCustomizations is true, skip loading plugins entirely +function useLoadPlugins(disableCustomizations?: boolean) { const { data: plugins, loading: pluginsLoading, @@ -74,6 +75,12 @@ function useLoadPlugins() { }, [plugins?.plugins, pluginsLoading, pluginsError]); const pluginJavascripts = useMemoOnce(() => { + // Skip loading plugin JS if customizations are disabled. + // Note: We check inside useMemoOnce rather than early-returning from useLoadPlugins + // to comply with React's rules of hooks - hooks must be called unconditionally. + if (disableCustomizations) { + return [[], true]; + } return [ uniq( sortedPlugins @@ -83,9 +90,12 @@ function useLoadPlugins() { ), !!sortedPlugins && !pluginsLoading && !pluginsError, ]; - }, [sortedPlugins, pluginsLoading, pluginsError]); + }, [sortedPlugins, pluginsLoading, pluginsError, disableCustomizations]); const pluginCSS = useMemoOnce(() => { + if (disableCustomizations) { + return [[], true]; + } return [ uniq( sortedPlugins @@ -95,7 +105,7 @@ function useLoadPlugins() { ), !!sortedPlugins && !pluginsLoading && !pluginsError, ]; - }, [sortedPlugins, pluginsLoading, pluginsError]); + }, [sortedPlugins, pluginsLoading, pluginsError, disableCustomizations]); const pluginJavascriptLoaded = useScript( pluginJavascripts ?? [], @@ -109,11 +119,15 @@ function useLoadPlugins() { }; } -export const PluginsLoader: React.FC> = ({ - children, -}) => { +interface IPluginsLoaderProps { + disableCustomizations?: boolean; +} + +export const PluginsLoader: React.FC< + React.PropsWithChildren +> = ({ disableCustomizations, children }) => { const Toast = useToast(); - const { loading: loaded, error } = useLoadPlugins(); + const { loading: loaded, error } = useLoadPlugins(disableCustomizations); useEffect(() => { if (error) { From 2c8e7d709ff7dc41f605077ad2081c985fe6a557 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Mon, 26 Jan 2026 21:02:47 -0800 Subject: [PATCH 034/177] FR: Add Interfaces to Destroy File Database Entries (#6437) --- graphql/schema/schema.graphql | 2 + graphql/schema/types/gallery.graphql | 2 + graphql/schema/types/image.graphql | 4 ++ graphql/schema/types/scene.graphql | 4 ++ internal/api/resolver_mutation_file.go | 52 +++++++++++++++++++++++ internal/api/resolver_mutation_gallery.go | 3 +- internal/api/resolver_mutation_image.go | 4 +- internal/api/resolver_mutation_scene.go | 6 ++- internal/manager/repository.go | 6 +-- internal/manager/task_clean.go | 10 ++++- pkg/gallery/delete.go | 12 ++++-- pkg/gallery/service.go | 2 +- pkg/image/delete.go | 48 ++++++++++++++++++--- pkg/models/gallery.go | 5 ++- pkg/models/image.go | 14 +++--- pkg/models/scene.go | 14 +++--- pkg/scene/delete.go | 35 ++++++++++++++- pkg/scene/merge.go | 3 +- 18 files changed, 191 insertions(+), 35 deletions(-) diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index edfdecaac..7fda85b24 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -422,6 +422,8 @@ type Mutation { """ moveFiles(input: MoveFilesInput!): Boolean! deleteFiles(ids: [ID!]!): Boolean! + "Deletes file entries from the database without deleting the files from the filesystem" + destroyFiles(ids: [ID!]!): Boolean! fileSetFingerprints(input: FileSetFingerprintsInput!): Boolean! diff --git a/graphql/schema/types/gallery.graphql b/graphql/schema/types/gallery.graphql index 999a743f7..f456157a7 100644 --- a/graphql/schema/types/gallery.graphql +++ b/graphql/schema/types/gallery.graphql @@ -100,6 +100,8 @@ input GalleryDestroyInput { """ delete_file: Boolean delete_generated: Boolean + "If true, delete the file entry from the database if the file is not assigned to any other objects" + destroy_file_entry: Boolean } type FindGalleriesResultType { diff --git a/graphql/schema/types/image.graphql b/graphql/schema/types/image.graphql index fb95556f5..b7ec1a9f5 100644 --- a/graphql/schema/types/image.graphql +++ b/graphql/schema/types/image.graphql @@ -82,12 +82,16 @@ input ImageDestroyInput { id: ID! delete_file: Boolean delete_generated: Boolean + "If true, delete the file entry from the database if the file is not assigned to any other objects" + destroy_file_entry: Boolean } input ImagesDestroyInput { ids: [ID!]! delete_file: Boolean delete_generated: Boolean + "If true, delete the file entry from the database if the file is not assigned to any other objects" + destroy_file_entry: Boolean } type FindImagesResultType { diff --git a/graphql/schema/types/scene.graphql b/graphql/schema/types/scene.graphql index eca01d15e..5fba3819d 100644 --- a/graphql/schema/types/scene.graphql +++ b/graphql/schema/types/scene.graphql @@ -196,12 +196,16 @@ input SceneDestroyInput { id: ID! delete_file: Boolean delete_generated: Boolean + "If true, delete the file entry from the database if the file is not assigned to any other objects" + destroy_file_entry: Boolean } input ScenesDestroyInput { ids: [ID!]! delete_file: Boolean delete_generated: Boolean + "If true, delete the file entry from the database if the file is not assigned to any other objects" + destroy_file_entry: Boolean } type FindScenesResultType { diff --git a/internal/api/resolver_mutation_file.go b/internal/api/resolver_mutation_file.go index c5e5e3530..afbefe554 100644 --- a/internal/api/resolver_mutation_file.go +++ b/internal/api/resolver_mutation_file.go @@ -210,6 +210,58 @@ func (r *mutationResolver) DeleteFiles(ctx context.Context, ids []string) (ret b return true, nil } +func (r *mutationResolver) DestroyFiles(ctx context.Context, ids []string) (ret bool, err error) { + fileIDs, err := stringslice.StringSliceToIntSlice(ids) + if err != nil { + return false, fmt.Errorf("converting ids: %w", err) + } + + destroyer := &file.ZipDestroyer{ + FileDestroyer: r.repository.File, + FolderDestroyer: r.repository.Folder, + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.File + + for _, fileIDInt := range fileIDs { + fileID := models.FileID(fileIDInt) + f, err := qb.Find(ctx, fileID) + if err != nil { + return err + } + + if len(f) == 0 { + return fmt.Errorf("file with id %d not found", fileID) + } + + path := f[0].Base().Path + + // ensure not a primary file + isPrimary, err := qb.IsPrimary(ctx, fileID) + if err != nil { + return fmt.Errorf("checking if file %s is primary: %w", path, err) + } + + if isPrimary { + return fmt.Errorf("cannot destroy primary file entry %s", path) + } + + // destroy DB entries only (no filesystem deletion) + const deleteFile = false + if err := destroyer.DestroyZip(ctx, f[0], nil, deleteFile); err != nil { + return fmt.Errorf("destroying file entry %s: %w", path, err) + } + } + + return nil + }); err != nil { + return false, err + } + + return true, nil +} + func (r *mutationResolver) FileSetFingerprints(ctx context.Context, input FileSetFingerprintsInput) (bool, error) { fileIDInt, err := strconv.Atoi(input.ID) if err != nil { diff --git a/internal/api/resolver_mutation_gallery.go b/internal/api/resolver_mutation_gallery.go index 8f4863c6d..e7f853922 100644 --- a/internal/api/resolver_mutation_gallery.go +++ b/internal/api/resolver_mutation_gallery.go @@ -346,6 +346,7 @@ func (r *mutationResolver) GalleryDestroy(ctx context.Context, input models.Gall deleteGenerated := utils.IsTrue(input.DeleteGenerated) deleteFile := utils.IsTrue(input.DeleteFile) + destroyFileEntry := utils.IsTrue(input.DestroyFileEntry) if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Gallery @@ -366,7 +367,7 @@ func (r *mutationResolver) GalleryDestroy(ctx context.Context, input models.Gall galleries = append(galleries, gallery) - imgsDestroyed, err = r.galleryService.Destroy(ctx, gallery, fileDeleter, deleteGenerated, deleteFile) + imgsDestroyed, err = r.galleryService.Destroy(ctx, gallery, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry) if err != nil { return err } diff --git a/internal/api/resolver_mutation_image.go b/internal/api/resolver_mutation_image.go index 82d9be4cd..230d48358 100644 --- a/internal/api/resolver_mutation_image.go +++ b/internal/api/resolver_mutation_image.go @@ -325,7 +325,7 @@ func (r *mutationResolver) ImageDestroy(ctx context.Context, input models.ImageD return fmt.Errorf("image with id %d not found", imageID) } - return r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile)) + return r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile), utils.IsTrue(input.DestroyFileEntry)) }); err != nil { fileDeleter.Rollback() return false, err @@ -372,7 +372,7 @@ func (r *mutationResolver) ImagesDestroy(ctx context.Context, input models.Image images = append(images, i) - if err := r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile)); err != nil { + if err := r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile), utils.IsTrue(input.DestroyFileEntry)); err != nil { return err } } diff --git a/internal/api/resolver_mutation_scene.go b/internal/api/resolver_mutation_scene.go index cb2aa7d24..6ac5b0227 100644 --- a/internal/api/resolver_mutation_scene.go +++ b/internal/api/resolver_mutation_scene.go @@ -441,6 +441,7 @@ func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneD deleteGenerated := utils.IsTrue(input.DeleteGenerated) deleteFile := utils.IsTrue(input.DeleteFile) + destroyFileEntry := utils.IsTrue(input.DestroyFileEntry) if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene @@ -457,7 +458,7 @@ func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneD // kill any running encoders manager.KillRunningStreams(s, fileNamingAlgo) - return r.sceneService.Destroy(ctx, s, fileDeleter, deleteGenerated, deleteFile) + return r.sceneService.Destroy(ctx, s, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry) }); err != nil { fileDeleter.Rollback() return false, err @@ -495,6 +496,7 @@ func (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.Scene deleteGenerated := utils.IsTrue(input.DeleteGenerated) deleteFile := utils.IsTrue(input.DeleteFile) + destroyFileEntry := utils.IsTrue(input.DestroyFileEntry) if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene @@ -513,7 +515,7 @@ func (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.Scene // kill any running encoders manager.KillRunningStreams(scene, fileNamingAlgo) - if err := r.sceneService.Destroy(ctx, scene, fileDeleter, deleteGenerated, deleteFile); err != nil { + if err := r.sceneService.Destroy(ctx, scene, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil { return err } } diff --git a/internal/manager/repository.go b/internal/manager/repository.go index 8d4ef1137..e51e737ee 100644 --- a/internal/manager/repository.go +++ b/internal/manager/repository.go @@ -13,14 +13,14 @@ type SceneService interface { Create(ctx context.Context, input *models.Scene, fileIDs []models.FileID, coverImage []byte) (*models.Scene, error) AssignFile(ctx context.Context, sceneID int, fileID models.FileID) error Merge(ctx context.Context, sourceIDs []int, destinationID int, fileDeleter *scene.FileDeleter, options scene.MergeOptions) error - Destroy(ctx context.Context, scene *models.Scene, fileDeleter *scene.FileDeleter, deleteGenerated, deleteFile bool) error + Destroy(ctx context.Context, scene *models.Scene, fileDeleter *scene.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error FindByIDs(ctx context.Context, ids []int, load ...scene.LoadRelationshipOption) ([]*models.Scene, error) sceneFingerprintGetter } type ImageService interface { - Destroy(ctx context.Context, image *models.Image, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) error + Destroy(ctx context.Context, image *models.Image, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error DestroyZipImages(ctx context.Context, zipFile models.File, fileDeleter *image.FileDeleter, deleteGenerated bool) ([]*models.Image, error) } @@ -31,7 +31,7 @@ type GalleryService interface { SetCover(ctx context.Context, g *models.Gallery, coverImageId int) error ResetCover(ctx context.Context, g *models.Gallery) error - Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) + Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) ([]*models.Image, error) ValidateImageGalleryChange(ctx context.Context, i *models.Image, updateIDs models.UpdateIDs) error diff --git a/internal/manager/task_clean.go b/internal/manager/task_clean.go index 9690cf4c8..ddd86e2f2 100644 --- a/internal/manager/task_clean.go +++ b/internal/manager/task_clean.go @@ -300,7 +300,10 @@ func (h *cleanHandler) handleRelatedScenes(ctx context.Context, fileDeleter *fil // only delete if the scene has no other files if len(scene.Files.List()) <= 1 { logger.Infof("Deleting scene %q since it has no other related files", scene.DisplayName()) - if err := mgr.SceneService.Destroy(ctx, scene, sceneFileDeleter, true, false); err != nil { + const deleteGenerated = true + const deleteFile = false + const destroyFileEntry = false + if err := mgr.SceneService.Destroy(ctx, scene, sceneFileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil { return err } @@ -421,7 +424,10 @@ func (h *cleanHandler) handleRelatedImages(ctx context.Context, fileDeleter *fil if len(i.Files.List()) <= 1 { logger.Infof("Deleting image %q since it has no other related files", i.DisplayName()) - if err := mgr.ImageService.Destroy(ctx, i, imageFileDeleter, true, false); err != nil { + const deleteGenerated = true + const deleteFile = false + const destroyFileEntry = false + if err := mgr.ImageService.Destroy(ctx, i, imageFileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil { return err } diff --git a/pkg/gallery/delete.go b/pkg/gallery/delete.go index f5186f948..4bc2e2492 100644 --- a/pkg/gallery/delete.go +++ b/pkg/gallery/delete.go @@ -8,13 +8,13 @@ import ( "github.com/stashapp/stash/pkg/models" ) -func (s *Service) Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) { +func (s *Service) Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) ([]*models.Image, error) { var imgsDestroyed []*models.Image // chapter deletion is done via delete cascade, so we don't need to do anything here // if this is a zip-based gallery, delete the images as well first - zipImgsDestroyed, err := s.destroyZipFileImages(ctx, i, fileDeleter, deleteGenerated, deleteFile) + zipImgsDestroyed, err := s.destroyZipFileImages(ctx, i, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry) if err != nil { return nil, err } @@ -45,7 +45,7 @@ func DestroyChapter(ctx context.Context, galleryChapter *models.GalleryChapter, return qb.Destroy(ctx, galleryChapter.ID) } -func (s *Service) destroyZipFileImages(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) { +func (s *Service) destroyZipFileImages(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) ([]*models.Image, error) { if err := i.LoadFiles(ctx, s.Repository); err != nil { return nil, err } @@ -81,6 +81,12 @@ func (s *Service) destroyZipFileImages(ctx context.Context, i *models.Gallery, f if err := destroyer.DestroyZip(ctx, f, fileDeleter.Deleter, deleteFile); err != nil { return nil, err } + } else if destroyFileEntry { + // destroy file DB entry without deleting filesystem file + const deleteFileFromFS = false + if err := destroyer.DestroyZip(ctx, f, nil, deleteFileFromFS); err != nil { + return nil, err + } } } diff --git a/pkg/gallery/service.go b/pkg/gallery/service.go index 62604e0c5..5b2678480 100644 --- a/pkg/gallery/service.go +++ b/pkg/gallery/service.go @@ -16,7 +16,7 @@ type ImageFinder interface { } type ImageService interface { - Destroy(ctx context.Context, i *models.Image, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) error + Destroy(ctx context.Context, i *models.Image, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error DestroyZipImages(ctx context.Context, zipFile models.File, fileDeleter *image.FileDeleter, deleteGenerated bool) ([]*models.Image, error) DestroyFolderImages(ctx context.Context, folderID models.FolderID, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) } diff --git a/pkg/image/delete.go b/pkg/image/delete.go index aa3a9c1c8..28bb54a59 100644 --- a/pkg/image/delete.go +++ b/pkg/image/delete.go @@ -37,8 +37,8 @@ func (d *FileDeleter) MarkGeneratedFiles(image *models.Image) error { } // Destroy destroys an image, optionally marking the file and generated files for deletion. -func (s *Service) Destroy(ctx context.Context, i *models.Image, fileDeleter *FileDeleter, deleteGenerated, deleteFile bool) error { - return s.destroyImage(ctx, i, fileDeleter, deleteGenerated, deleteFile) +func (s *Service) Destroy(ctx context.Context, i *models.Image, fileDeleter *FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error { + return s.destroyImage(ctx, i, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry) } // DestroyZipImages destroys all images in zip, optionally marking the files and generated files for deletion. @@ -75,7 +75,8 @@ func (s *Service) DestroyZipImages(ctx context.Context, zipFile models.File, fil } const deleteFileInZip = false - if err := s.destroyImage(ctx, img, fileDeleter, deleteGenerated, deleteFileInZip); err != nil { + const destroyFileEntry = false + if err := s.destroyImage(ctx, img, fileDeleter, deleteGenerated, deleteFileInZip, destroyFileEntry); err != nil { return nil, err } @@ -135,7 +136,8 @@ func (s *Service) DestroyFolderImages(ctx context.Context, folderID models.Folde continue } - if err := s.Destroy(ctx, img, fileDeleter, deleteGenerated, deleteFile); err != nil { + const destroyFileEntry = false + if err := s.Destroy(ctx, img, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil { return nil, err } @@ -146,11 +148,15 @@ func (s *Service) DestroyFolderImages(ctx context.Context, folderID models.Folde } // Destroy destroys an image, optionally marking the file and generated files for deletion. -func (s *Service) destroyImage(ctx context.Context, i *models.Image, fileDeleter *FileDeleter, deleteGenerated, deleteFile bool) error { +func (s *Service) destroyImage(ctx context.Context, i *models.Image, fileDeleter *FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error { if deleteFile { if err := s.deleteFiles(ctx, i, fileDeleter); err != nil { return err } + } else if destroyFileEntry { + if err := s.destroyFileEntries(ctx, i); err != nil { + return err + } } if deleteGenerated { @@ -192,3 +198,35 @@ func (s *Service) deleteFiles(ctx context.Context, i *models.Image, fileDeleter return nil } + +// destroyFileEntries destroys file entries from the database without deleting +// the files from the filesystem +func (s *Service) destroyFileEntries(ctx context.Context, i *models.Image) error { + if err := i.LoadFiles(ctx, s.Repository); err != nil { + return err + } + + for _, f := range i.Files.List() { + // only destroy file entries where there is no other associated image + otherImages, err := s.Repository.FindByFileID(ctx, f.Base().ID) + if err != nil { + return err + } + + if len(otherImages) > 1 { + // other image associated, don't remove + continue + } + + // don't destroy files in zip archives + if f.Base().ZipFileID == nil { + const deleteFile = false + logger.Info("Destroying image file entry: ", f.Base().Path) + if err := file.Destroy(ctx, s.File, f, nil, deleteFile); err != nil { + return err + } + } + } + + return nil +} diff --git a/pkg/models/gallery.go b/pkg/models/gallery.go index 5b75febc5..dfc776afe 100644 --- a/pkg/models/gallery.go +++ b/pkg/models/gallery.go @@ -95,6 +95,7 @@ type GalleryDestroyInput struct { // If true, then the zip file will be deleted if the gallery is zip-file-based. // If gallery is folder-based, then any files not associated with other // galleries will be deleted, along with the folder, if it is not empty. - DeleteFile *bool `json:"delete_file"` - DeleteGenerated *bool `json:"delete_generated"` + DeleteFile *bool `json:"delete_file"` + DeleteGenerated *bool `json:"delete_generated"` + DestroyFileEntry *bool `json:"destroy_file_entry"` } diff --git a/pkg/models/image.go b/pkg/models/image.go index 4ab10eabf..54d1e8a82 100644 --- a/pkg/models/image.go +++ b/pkg/models/image.go @@ -88,15 +88,17 @@ type ImageUpdateInput struct { } type ImageDestroyInput struct { - ID string `json:"id"` - DeleteFile *bool `json:"delete_file"` - DeleteGenerated *bool `json:"delete_generated"` + ID string `json:"id"` + DeleteFile *bool `json:"delete_file"` + DeleteGenerated *bool `json:"delete_generated"` + DestroyFileEntry *bool `json:"destroy_file_entry"` } type ImagesDestroyInput struct { - Ids []string `json:"ids"` - DeleteFile *bool `json:"delete_file"` - DeleteGenerated *bool `json:"delete_generated"` + Ids []string `json:"ids"` + DeleteFile *bool `json:"delete_file"` + DeleteGenerated *bool `json:"delete_generated"` + DestroyFileEntry *bool `json:"destroy_file_entry"` } type ImageQueryOptions struct { diff --git a/pkg/models/scene.go b/pkg/models/scene.go index 1c34967c6..f50709356 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -204,15 +204,17 @@ type SceneUpdateInput struct { } type SceneDestroyInput struct { - ID string `json:"id"` - DeleteFile *bool `json:"delete_file"` - DeleteGenerated *bool `json:"delete_generated"` + ID string `json:"id"` + DeleteFile *bool `json:"delete_file"` + DeleteGenerated *bool `json:"delete_generated"` + DestroyFileEntry *bool `json:"destroy_file_entry"` } type ScenesDestroyInput struct { - Ids []string `json:"ids"` - DeleteFile *bool `json:"delete_file"` - DeleteGenerated *bool `json:"delete_generated"` + Ids []string `json:"ids"` + DeleteFile *bool `json:"delete_file"` + DeleteGenerated *bool `json:"delete_generated"` + DestroyFileEntry *bool `json:"destroy_file_entry"` } func NewSceneQueryResult(getter SceneGetter) *SceneQueryResult { diff --git a/pkg/scene/delete.go b/pkg/scene/delete.go index c34bbdf14..8ca3d6e11 100644 --- a/pkg/scene/delete.go +++ b/pkg/scene/delete.go @@ -109,7 +109,7 @@ func (d *FileDeleter) MarkMarkerFiles(scene *models.Scene, seconds int) error { // Destroy deletes a scene and its associated relationships from the // database. -func (s *Service) Destroy(ctx context.Context, scene *models.Scene, fileDeleter *FileDeleter, deleteGenerated, deleteFile bool) error { +func (s *Service) Destroy(ctx context.Context, scene *models.Scene, fileDeleter *FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error { mqb := s.MarkerRepository markers, err := mqb.FindBySceneID(ctx, scene.ID) if err != nil { @@ -126,6 +126,10 @@ func (s *Service) Destroy(ctx context.Context, scene *models.Scene, fileDeleter if err := s.deleteFiles(ctx, scene, fileDeleter); err != nil { return err } + } else if destroyFileEntry { + if err := s.destroyFileEntries(ctx, scene); err != nil { + return err + } } if deleteGenerated { @@ -180,6 +184,35 @@ func (s *Service) deleteFiles(ctx context.Context, scene *models.Scene, fileDele return nil } +// destroyFileEntries destroys file entries from the database without deleting +// the files from the filesystem +func (s *Service) destroyFileEntries(ctx context.Context, scene *models.Scene) error { + if err := scene.LoadFiles(ctx, s.Repository); err != nil { + return err + } + + for _, f := range scene.Files.List() { + // only destroy file entries where there is no other associated scene + otherScenes, err := s.Repository.FindByFileID(ctx, f.ID) + if err != nil { + return err + } + + if len(otherScenes) > 1 { + // other scenes associated, don't remove + continue + } + + const deleteFile = false + logger.Info("Destroying scene file entry: ", f.Path) + if err := file.Destroy(ctx, s.File, f, nil, deleteFile); err != nil { + return err + } + } + + return nil +} + // DestroyMarker deletes the scene marker from the database and returns a // function that removes the generated files, to be executed after the // transaction is successfully committed. diff --git a/pkg/scene/merge.go b/pkg/scene/merge.go index 77b551ab2..b2650ca92 100644 --- a/pkg/scene/merge.go +++ b/pkg/scene/merge.go @@ -120,7 +120,8 @@ func (s *Service) Merge(ctx context.Context, sourceIDs []int, destinationID int, for _, src := range sources { const deleteGenerated = true const deleteFile = false - if err := s.Destroy(ctx, src, fileDeleter, deleteGenerated, deleteFile); err != nil { + const destroyFileEntry = false + if err := s.Destroy(ctx, src, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil { return fmt.Errorf("deleting scene %d: %w", src.ID, err) } } From 09044b92bfe6cd4fd9f2c34a6c3cc3313d1a8b1f Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Tue, 27 Jan 2026 07:06:27 +0200 Subject: [PATCH 035/177] docs: add missing patchable components and library (#6517) --- ui/v2.5/src/docs/en/Manual/UIPluginApi.md | 31 ++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md index e1347a46f..fe33b2ffe 100644 --- a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md +++ b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md @@ -33,6 +33,7 @@ This namespace contains the generated graphql client interface. This is a low-le - `FontAwesomeBrands` - `Mousetrap` - `MousetrapPause` +- `ReactFontAwesome` - `ReactSelect` ### `register` @@ -235,17 +236,26 @@ Returns `void`. - `GalleryCard.Image` - `GalleryCard.Overlays` - `GalleryCard.Popovers` +- `GalleryCardGrid` - `GalleryIDSelect` +- `GalleryRecommendationRow` - `GallerySelect` - `GallerySelect.sort` +- `GridCard` +- `GroupCard` +- `GroupCardGrid` - `GroupIDSelect` +- `GroupRecommendationRow` - `GroupSelect` - `GroupSelect.sort` - `HeaderImage` - `HoverPopover` - `Icon` +- `ImageCard` - `ImageDetailPanel` +- `ImageGridCard` - `ImageInput` +- `ImageRecommendationRow` - `LightboxLink` - `LoadingIndicator` - `MainNavBar.MenuItems` @@ -261,6 +271,7 @@ Returns `void`. - `PerformerCard.Overlays` - `PerformerCard.Popovers` - `PerformerCard.Title` +- `PerformerCardGrid` - `PerformerDetailsPanel` - `PerformerDetailsPanel.DetailGroup` - `PerformerGalleriesPanel` @@ -269,6 +280,7 @@ Returns `void`. - `PerformerIDSelect` - `PerformerImagesPanel` - `PerformerPage` +- `PerformerRecommendationRow` - `PerformerScenesPanel` - `PerformerSelect` - `PerformerSelect.sort` @@ -277,17 +289,26 @@ Returns `void`. - `RatingNumber` - `RatingStars` - `RatingSystem` +- `RecommendationRow` - `SceneCard` - `SceneCard.Details` - `SceneCard.Image` - `SceneCard.Overlays` - `SceneCard.Popovers` +- `SceneCardsGrid` - `SceneFileInfoPanel` - `SceneIDSelect` +- `SceneMarkerCard` +- `SceneMarkerCard.Details` +- `SceneMarkerCard.Image` +- `SceneMarkerCard.Popovers` +- `SceneMarkerCardsGrid` +- `SceneMarkerRecommendationRow` - `ScenePage` - `ScenePage.TabContent` - `ScenePage.Tabs` - `ScenePlayer` +- `SceneRecommendationRow` - `SceneSelect` - `SceneSelect.sort` - `SelectSetting` @@ -296,7 +317,11 @@ Returns `void`. - `SettingModal` - `StringListSetting` - `StringSetting` +- `StudioCard` +- `StudioCardGrid` +- `StudioDetailsPanel` - `StudioIDSelect` +- `StudioRecommendationRow` - `StudioSelect` - `StudioSelect.sort` - `SweatDrops` @@ -307,8 +332,10 @@ Returns `void`. - `TagCard.Overlays` - `TagCard.Popovers` - `TagCard.Title` +- `TagCardGrid` - `TagIDSelect` - `TagLink` +- `TagRecommendationRow` - `TagSelect` - `TagSelect.sort` - `TruncatedText` @@ -319,6 +346,4 @@ Allows plugins to listen for Stash's events. ```js PluginApi.Event.addEventListener("stash:location", (e) => console.log("Page Changed", e.detail.data.location.pathname)) -``` - - +``` \ No newline at end of file From 6bb22146b2c1a4039f99dce12c7e928a06758801 Mon Sep 17 00:00:00 2001 From: CJ <72030708+cj12312021@users.noreply.github.com> Date: Mon, 26 Jan 2026 23:10:49 -0600 Subject: [PATCH 036/177] make ImageCard patchable for plugin extensibility (#6470) * refactor(ui): make ImageCard patchable for plugin extensibility Refactor ImageCard component to use PatchComponent wrapper. Changes: - Wrap ImageCard and sub-components with PatchComponent - Extract ImageCardPopovers, ImageCardDetails, ImageCardOverlays, ImageCardImage as separate patchable components * Add documentation --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- ui/v2.5/src/components/Images/ImageCard.tsx | 158 +++++++++++--------- ui/v2.5/src/docs/en/Manual/UIPluginApi.md | 4 + 2 files changed, 95 insertions(+), 67 deletions(-) diff --git a/ui/v2.5/src/components/Images/ImageCard.tsx b/ui/v2.5/src/components/Images/ImageCard.tsx index 0b60a77ff..adaee9923 100644 --- a/ui/v2.5/src/components/Images/ImageCard.tsx +++ b/ui/v2.5/src/components/Images/ImageCard.tsx @@ -30,17 +30,9 @@ interface IImageCardProps { onPreview?: (ev: MouseEvent) => void; } -export const ImageCard: React.FC = PatchComponent( - "ImageCard", +const ImageCardPopovers = PatchComponent( + "ImageCard.Popovers", (props: IImageCardProps) => { - const file = useMemo( - () => - props.image.visual_files.length > 0 - ? props.image.visual_files[0] - : undefined, - [props.image] - ); - function maybeRenderTagPopoverButton() { if (props.image.tags.length <= 0) return; @@ -112,29 +104,65 @@ export const ImageCard: React.FC = PatchComponent( } } - function maybeRenderPopoverButtonGroup() { - if ( - props.image.tags.length > 0 || - props.image.performers.length > 0 || - props.image.o_counter || - props.image.galleries.length > 0 || - props.image.organized - ) { - return ( - <> -
- - {maybeRenderTagPopoverButton()} - {maybeRenderPerformerPopoverButton()} - {maybeRenderOCounter()} - {maybeRenderGallery()} - {maybeRenderOrganized()} - - - ); - } + if ( + props.image.tags.length > 0 || + props.image.performers.length > 0 || + props.image.o_counter || + props.image.galleries.length > 0 || + props.image.organized + ) { + return ( + <> +
+ + {maybeRenderTagPopoverButton()} + {maybeRenderPerformerPopoverButton()} + {maybeRenderOCounter()} + {maybeRenderGallery()} + {maybeRenderOrganized()} + + + ); } + return null; + } +); + +const ImageCardDetails = PatchComponent( + "ImageCard.Details", + (props: IImageCardProps) => { + return ( +
+ {props.image.date} + +
+ ); + } +); + +const ImageCardOverlays = PatchComponent( + "ImageCard.Overlays", + (props: IImageCardProps) => { + return ; + } +); + +const ImageCardImage = PatchComponent( + "ImageCard.Image", + (props: IImageCardProps) => { + const file = useMemo( + () => + props.image.visual_files.length > 0 + ? props.image.visual_files[0] + : undefined, + [props.image] + ); + function isPortrait() { const width = file?.width ? file.width : 0; const height = file?.height ? file.height : 0; @@ -148,6 +176,34 @@ export const ImageCard: React.FC = PatchComponent( const video = source.includes("preview"); const ImagePreview = video ? "video" : "img"; + return ( + <> +
+ + {props.onPreview ? ( +
+ +
+ ) : undefined} +
+ + + ); + } +); + +export const ImageCard: React.FC = PatchComponent( + "ImageCard", + (props: IImageCardProps) => { return ( = PatchComponent( width={props.cardWidth} title={imageTitle(props.image)} linkClassName="image-card-link" - image={ - <> -
- - {props.onPreview ? ( -
- -
- ) : undefined} -
- - - } - details={ -
- {props.image.date} - -
- } - overlays={} - popovers={maybeRenderPopoverButtonGroup()} + image={} + details={} + overlays={} + popovers={} selected={props.selected} selecting={props.selecting} onSelectedChanged={props.onSelectedChanged} diff --git a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md index fe33b2ffe..683ea5b4e 100644 --- a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md +++ b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md @@ -252,6 +252,10 @@ Returns `void`. - `HoverPopover` - `Icon` - `ImageCard` +- `ImageCard.Details` +- `ImageCard.Image` +- `ImageCard.Overlays` +- `ImageCard.Popovers` - `ImageDetailPanel` - `ImageGridCard` - `ImageInput` From a05500342a562f08cead711821122365e4d0ad56 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 27 Jan 2026 17:00:56 +1100 Subject: [PATCH 037/177] Image phash generation (#6497) * Add image phash generation * Add phash image filter * Add phash to image file info and phash image filtering in ui * Add options to generate image phash for generate/scan tasks * Add imageIDs input to generate task * Add generate option to image menus * Add ellipses to generate --- cmd/phasher/main.go | 42 ++++++- graphql/schema/types/filters.graphql | 2 + graphql/schema/types/metadata.graphql | 13 ++- internal/manager/config/tasks.go | 4 +- internal/manager/task_generate.go | 48 +++++++- internal/manager/task_generate_image_phash.go | 103 ++++++++++++++++++ internal/manager/task_generate_phash.go | 2 +- internal/manager/task_scan.go | 23 ++++ pkg/hash/imagephash/phash.go | 48 ++++++++ pkg/models/image.go | 2 + pkg/sqlite/criterion_handlers.go | 37 +++++++ pkg/sqlite/image_filter.go | 9 ++ pkg/sqlite/scene_filter.go | 63 +++-------- .../src/components/Dialogs/GenerateDialog.tsx | 8 +- .../components/Images/ImageDetails/Image.tsx | 24 ++++ .../ImageDetails/ImageFileInfoPanel.tsx | 11 ++ ui/v2.5/src/components/Images/ImageList.tsx | 21 +++- .../components/Scenes/SceneDetails/Scene.tsx | 2 +- .../Settings/Tasks/GenerateOptions.tsx | 7 ++ .../components/Settings/Tasks/ScanOptions.tsx | 8 ++ ui/v2.5/src/docs/en/Manual/Tasks.md | 15 ++- ui/v2.5/src/locales/en-GB.json | 8 +- ui/v2.5/src/models/list-filter/images.ts | 2 + ui/v2.5/src/utils/navigation.ts | 10 ++ 24 files changed, 447 insertions(+), 65 deletions(-) create mode 100644 internal/manager/task_generate_image_phash.go create mode 100644 pkg/hash/imagephash/phash.go diff --git a/cmd/phasher/main.go b/cmd/phasher/main.go index 864195631..e0801d5d7 100644 --- a/cmd/phasher/main.go +++ b/cmd/phasher/main.go @@ -5,20 +5,39 @@ import ( "fmt" "os" "os/exec" + "path/filepath" flag "github.com/spf13/pflag" "github.com/stashapp/stash/pkg/ffmpeg" + "github.com/stashapp/stash/pkg/hash/imagephash" "github.com/stashapp/stash/pkg/hash/videophash" "github.com/stashapp/stash/pkg/models" ) func customUsage() { fmt.Fprintf(os.Stderr, "Usage:\n") - fmt.Fprintf(os.Stderr, "%s [OPTIONS] VIDEOFILE...\n\nOptions:\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "%s [OPTIONS] FILE...\n\nOptions:\n", os.Args[0]) flag.PrintDefaults() } func printPhash(ff *ffmpeg.FFMpeg, ffp *ffmpeg.FFProbe, inputfile string, quiet *bool) error { + // Determine if this is a video or image file based on extension + ext := filepath.Ext(inputfile) + ext = ext[1:] // remove the leading dot + + // Common image extensions + imageExts := map[string]bool{ + "jpg": true, "jpeg": true, "png": true, "gif": true, "webp": true, "bmp": true, + } + + if imageExts[ext] { + return printImagePhash(inputfile, quiet) + } + + return printVideoPhash(ff, ffp, inputfile, quiet) +} + +func printVideoPhash(ff *ffmpeg.FFMpeg, ffp *ffmpeg.FFProbe, inputfile string, quiet *bool) error { ffvideoFile, err := ffp.NewVideoFile(inputfile) if err != nil { return err @@ -46,6 +65,24 @@ func printPhash(ff *ffmpeg.FFMpeg, ffp *ffmpeg.FFProbe, inputfile string, quiet return nil } +func printImagePhash(inputfile string, quiet *bool) error { + imgFile := &models.ImageFile{ + BaseFile: &models.BaseFile{Path: inputfile}, + } + + phash, err := imagephash.Generate(imgFile) + if err != nil { + return err + } + + if *quiet { + fmt.Printf("%x\n", *phash) + } else { + fmt.Printf("%x %v\n", *phash, imgFile.Path) + } + return nil +} + func getPaths() (string, string) { ffmpegPath, _ := exec.LookPath("ffmpeg") ffprobePath, _ := exec.LookPath("ffprobe") @@ -67,7 +104,7 @@ func main() { args := flag.Args() if len(args) < 1 { - fmt.Fprintf(os.Stderr, "Missing VIDEOFILE argument.\n") + fmt.Fprintf(os.Stderr, "Missing FILE argument.\n") flag.Usage() os.Exit(2) } @@ -87,4 +124,5 @@ func main() { fmt.Fprintln(os.Stderr, err) } } + } diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 4cf25d840..b84bc638e 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -658,6 +658,8 @@ input ImageFilterType { id: IntCriterionInput "Filter by file checksum" checksum: StringCriterionInput + "Filter by file phash distance" + phash_distance: PhashDistanceCriterionInput "Filter by path" path: StringCriterionInput "Filter by file count" diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index c01858f64..66b74bb86 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -10,13 +10,18 @@ input GenerateMetadataInput { transcodes: Boolean "Generate transcodes even if not required" forceTranscodes: Boolean + "Generate video phashes during scan" phashes: Boolean interactiveHeatmapsSpeeds: Boolean + "Generate image phashes during scan" + imagePhashes: Boolean imageThumbnails: Boolean clipPreviews: Boolean "scene ids to generate for" sceneIDs: [ID!] + "image ids to generate for" + imageIDs: [ID!] "marker ids to generate for" markerIDs: [ID!] @@ -85,8 +90,10 @@ input ScanMetadataInput { scanGenerateImagePreviews: Boolean "Generate sprites during scan" scanGenerateSprites: Boolean - "Generate phashes during scan" + "Generate video phashes during scan" scanGeneratePhashes: Boolean + "Generate image phashes during scan" + scanGenerateImagePhashes: Boolean "Generate image thumbnails during scan" scanGenerateThumbnails: Boolean "Generate image clip previews during scan" @@ -107,8 +114,10 @@ type ScanMetadataOptions { scanGenerateImagePreviews: Boolean! "Generate sprites during scan" scanGenerateSprites: Boolean! - "Generate phashes during scan" + "Generate video phashes during scan" scanGeneratePhashes: Boolean! + "Generate image phashes during scan" + scanGenerateImagePhashes: Boolean "Generate image thumbnails during scan" scanGenerateThumbnails: Boolean! "Generate image clip previews during scan" diff --git a/internal/manager/config/tasks.go b/internal/manager/config/tasks.go index 0cfabef30..af7d5f674 100644 --- a/internal/manager/config/tasks.go +++ b/internal/manager/config/tasks.go @@ -11,8 +11,10 @@ type ScanMetadataOptions struct { ScanGenerateImagePreviews bool `json:"scanGenerateImagePreviews"` // Generate sprites during scan ScanGenerateSprites bool `json:"scanGenerateSprites"` - // Generate phashes during scan + // Generate video phashes during scan ScanGeneratePhashes bool `json:"scanGeneratePhashes"` + // Generate image phashes during scan + ScanGenerateImagePhashes bool `json:"scanGenerateImagePhashes"` // Generate image thumbnails during scan ScanGenerateThumbnails bool `json:"scanGenerateThumbnails"` // Generate image thumbnails during scan diff --git a/internal/manager/task_generate.go b/internal/manager/task_generate.go index 30ecd08bf..5631db87b 100644 --- a/internal/manager/task_generate.go +++ b/internal/manager/task_generate.go @@ -29,11 +29,14 @@ type GenerateMetadataInput struct { // Generate transcodes even if not required ForceTranscodes bool `json:"forceTranscodes"` Phashes bool `json:"phashes"` + ImagePhashes bool `json:"imagePhashes"` InteractiveHeatmapsSpeeds bool `json:"interactiveHeatmapsSpeeds"` ClipPreviews bool `json:"clipPreviews"` ImageThumbnails bool `json:"imageThumbnails"` // scene ids to generate for SceneIDs []string `json:"sceneIDs"` + // image ids to generate for + ImageIDs []string `json:"imageIDs"` // marker ids to generate for MarkerIDs []string `json:"markerIDs"` // overwrite existing media @@ -73,6 +76,7 @@ type totalsGenerate struct { markers int64 transcodes int64 phashes int64 + imagePhashes int64 interactiveHeatmapSpeeds int64 clipPreviews int64 imageThumbnails int64 @@ -82,8 +86,9 @@ type totalsGenerate struct { func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error { var scenes []*models.Scene - var err error var markers []*models.SceneMarker + var images []*models.Image + var err error j.overwrite = j.input.Overwrite j.fileNamingAlgo = config.GetInstance().GetVideoFileNamingAlgorithm() @@ -105,6 +110,10 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error if err != nil { logger.Error(err.Error()) } + imageIDs, err := stringslice.StringSliceToIntSlice(j.input.ImageIDs) + if err != nil { + logger.Error(err.Error()) + } g := &generate.Generator{ Encoder: instance.FFMpeg, @@ -118,7 +127,7 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error r := j.repository if err := r.WithReadTxn(ctx, func(ctx context.Context) error { qb := r.Scene - if len(j.input.SceneIDs) == 0 && len(j.input.MarkerIDs) == 0 { + if len(j.input.SceneIDs) == 0 && len(j.input.MarkerIDs) == 0 && len(j.input.ImageIDs) == 0 { j.queueTasks(ctx, g, queue) } else { if len(j.input.SceneIDs) > 0 { @@ -141,6 +150,17 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error j.queueMarkerJob(g, m, queue) } } + + if len(j.input.ImageIDs) > 0 { + images, err = r.Image.FindMany(ctx, imageIDs) + for _, i := range images { + if err := i.LoadFiles(ctx, r.Image); err != nil { + return err + } + + j.queueImageJob(g, i, queue) + } + } } return nil @@ -172,6 +192,9 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error if j.input.Phashes { logMsg += fmt.Sprintf(" %d phashes", totals.phashes) } + if j.input.ImagePhashes { + logMsg += fmt.Sprintf(" %d image phashes", totals.imagePhashes) + } if j.input.InteractiveHeatmapsSpeeds { logMsg += fmt.Sprintf(" %d heatmaps & speeds", totals.interactiveHeatmapSpeeds) } @@ -284,7 +307,7 @@ func (j *GenerateJob) queueImagesTasks(ctx context.Context, g *generate.Generato r := j.repository - for more := j.input.ClipPreviews || j.input.ImageThumbnails; more; { + for more := j.input.ClipPreviews || j.input.ImageThumbnails || j.input.ImagePhashes; more; { if job.IsCancelled(ctx) { return } @@ -525,4 +548,23 @@ func (j *GenerateJob) queueImageJob(g *generate.Generator, image *models.Image, queue <- task } } + + if j.input.ImagePhashes { + // generate for all files in image + for _, f := range image.Files.List() { + if imageFile, ok := f.(*models.ImageFile); ok { + task := &GenerateImagePhashTask{ + repository: j.repository, + File: imageFile, + Overwrite: j.overwrite, + } + + if task.required() { + j.totals.imagePhashes++ + j.totals.tasks++ + queue <- task + } + } + } + } } diff --git a/internal/manager/task_generate_image_phash.go b/internal/manager/task_generate_image_phash.go new file mode 100644 index 000000000..4c07ffadf --- /dev/null +++ b/internal/manager/task_generate_image_phash.go @@ -0,0 +1,103 @@ +package manager + +import ( + "context" + "fmt" + + "github.com/stashapp/stash/pkg/hash/imagephash" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" +) + +type GenerateImagePhashTask struct { + repository models.Repository + File *models.ImageFile + Overwrite bool +} + +func (t *GenerateImagePhashTask) GetDescription() string { + return fmt.Sprintf("Generating phash for %s", t.File.Path) +} + +func (t *GenerateImagePhashTask) Start(ctx context.Context) { + if !t.required() { + return + } + + var hash int64 + set := false + + // #4393 - if there is a file with the same md5, we can use the same phash + // only use this if we're not overwriting + if !t.Overwrite { + existing, err := t.findExistingPhash(ctx) + if err != nil { + logger.Warnf("Error finding existing phash: %v", err) + } else if existing != nil { + logger.Infof("Using existing phash for %s", t.File.Path) + hash = existing.(int64) + set = true + } + } + + if !set { + generated, err := imagephash.Generate(t.File) + if err != nil { + logger.Errorf("Error generating phash for %q: %v", t.File.Path, err) + logErrorOutput(err) + return + } + + hash = int64(*generated) + } + + r := t.repository + if err := r.WithTxn(ctx, func(ctx context.Context) error { + t.File.Fingerprints = t.File.Fingerprints.AppendUnique(models.Fingerprint{ + Type: models.FingerprintTypePhash, + Fingerprint: hash, + }) + + return r.File.Update(ctx, t.File) + }); err != nil && ctx.Err() == nil { + logger.Errorf("Error setting phash: %v", err) + } +} + +func (t *GenerateImagePhashTask) findExistingPhash(ctx context.Context) (interface{}, error) { + r := t.repository + var ret interface{} + if err := r.WithReadTxn(ctx, func(ctx context.Context) error { + md5 := t.File.Fingerprints.Get(models.FingerprintTypeMD5) + + // find other files with the same md5 + files, err := r.File.FindByFingerprint(ctx, models.Fingerprint{ + Type: models.FingerprintTypeMD5, + Fingerprint: md5, + }) + if err != nil { + return fmt.Errorf("finding files by md5: %w", err) + } + + // find the first file with a phash + for _, file := range files { + if phash := file.Base().Fingerprints.Get(models.FingerprintTypePhash); phash != nil { + ret = phash + return nil + } + } + return nil + }); err != nil { + return nil, err + } + + return ret, nil +} + +func (t *GenerateImagePhashTask) required() bool { + if t.Overwrite { + return true + } + + return t.File.Fingerprints.Get(models.FingerprintTypePhash) == nil +} diff --git a/internal/manager/task_generate_phash.go b/internal/manager/task_generate_phash.go index 54dc1a10b..5d35a8738 100644 --- a/internal/manager/task_generate_phash.go +++ b/internal/manager/task_generate_phash.go @@ -44,7 +44,7 @@ func (t *GeneratePhashTask) Start(ctx context.Context) { if !set { generated, err := videophash.Generate(instance.FFMpeg, t.File) if err != nil { - logger.Errorf("Error generating phash: %v", err) + logger.Errorf("Error generating phash for %q: %v", t.File.Path, err) logErrorOutput(err) return } diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index 6f7f34b3c..fc1a4770f 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -463,6 +463,29 @@ func (g *imageGenerators) Generate(ctx context.Context, i *models.Image, f model } } + if t.ScanGenerateImagePhashes { + progress.AddTotal(1) + phashFn := func(ctx context.Context) { + mgr := GetInstance() + // Only generate phash for image files, not video files + if imageFile, ok := f.(*models.ImageFile); ok { + taskPhash := GenerateImagePhashTask{ + repository: mgr.Repository, + File: imageFile, + Overwrite: overwrite, + } + taskPhash.Start(ctx) + } + progress.Increment() + } + + if g.sequentialScanning { + phashFn(ctx) + } else { + g.taskQueue.Add(fmt.Sprintf("Generating phash for %s", path), phashFn) + } + } + return nil } diff --git a/pkg/hash/imagephash/phash.go b/pkg/hash/imagephash/phash.go new file mode 100644 index 000000000..4cf6e9209 --- /dev/null +++ b/pkg/hash/imagephash/phash.go @@ -0,0 +1,48 @@ +package imagephash + +import ( + "bytes" + "fmt" + "image" + + "github.com/corona10/goimagehash" + "github.com/stashapp/stash/pkg/file" + "github.com/stashapp/stash/pkg/models" +) + +// Generate computes a perceptual hash for an image file. +func Generate(imageFile *models.ImageFile) (*uint64, error) { + img, err := loadImage(imageFile) + if err != nil { + return nil, fmt.Errorf("loading image: %w", err) + } + + hash, err := goimagehash.PerceptionHash(img) + if err != nil { + return nil, fmt.Errorf("computing phash from image: %w", err) + } + + hashValue := hash.GetHash() + return &hashValue, nil +} + +// loadImage loads an image from disk and decodes it. +func loadImage(imageFile *models.ImageFile) (image.Image, error) { + reader, err := imageFile.Open(&file.OsFS{}) + if err != nil { + return nil, err + } + defer reader.Close() + + buf := new(bytes.Buffer) + if _, err := buf.ReadFrom(reader); err != nil { + return nil, err + } + + img, _, err := image.Decode(buf) + if err != nil { + return nil, fmt.Errorf("decoding image: %w", err) + } + + return img, nil +} diff --git a/pkg/models/image.go b/pkg/models/image.go index 54d1e8a82..84be79360 100644 --- a/pkg/models/image.go +++ b/pkg/models/image.go @@ -11,6 +11,8 @@ type ImageFilterType struct { Photographer *StringCriterionInput `json:"photographer"` // Filter by file checksum Checksum *StringCriterionInput `json:"checksum"` + // Filter by phash distance + PhashDistance *PhashDistanceCriterionInput `json:"phash_distance"` // Filter by path Path *StringCriterionInput `json:"path"` // Filter by file count diff --git a/pkg/sqlite/criterion_handlers.go b/pkg/sqlite/criterion_handlers.go index 6fe9c7ce9..1496df71d 100644 --- a/pkg/sqlite/criterion_handlers.go +++ b/pkg/sqlite/criterion_handlers.go @@ -1126,3 +1126,40 @@ func (h *relatedFilterHandler) handle(ctx context.Context, f *filterBuilder) { f.addWhere(fmt.Sprintf("%s IN ("+subQuery.toSQL(false)+")", h.relatedIDCol), subQuery.args...) } + +type phashDistanceCriterionHandler struct { + // assumes that applicable fingerprints table is joined as fingerprints_phash + joinFn func(f *filterBuilder) + criterion *models.PhashDistanceCriterionInput +} + +func (h *phashDistanceCriterionHandler) handle(ctx context.Context, f *filterBuilder) { + phashDistance := h.criterion + if phashDistance == nil { + return + } + + h.joinFn(f) + + value, _ := utils.StringToPhash(phashDistance.Value) + distance := 0 + if phashDistance.Distance != nil { + distance = *phashDistance.Distance + } + + switch { + case phashDistance.Modifier == models.CriterionModifierEquals && distance > 0: + // needed to avoid a type mismatch + f.addWhere("typeof(fingerprints_phash.fingerprint) = 'integer'") + f.addWhere("phash_distance(fingerprints_phash.fingerprint, ?) < ?", value, distance) + case phashDistance.Modifier == models.CriterionModifierNotEquals && distance > 0: + // needed to avoid a type mismatch + f.addWhere("typeof(fingerprints_phash.fingerprint) = 'integer'") + f.addWhere("phash_distance(fingerprints_phash.fingerprint, ?) > ?", value, distance) + default: + intCriterionHandler(&models.IntCriterionInput{ + Value: int(value), + Modifier: phashDistance.Modifier, + }, "fingerprints_phash.fingerprint", nil)(ctx, f) + } +} diff --git a/pkg/sqlite/image_filter.go b/pkg/sqlite/image_filter.go index 1d119bfde..b56ade26d 100644 --- a/pkg/sqlite/image_filter.go +++ b/pkg/sqlite/image_filter.go @@ -62,6 +62,15 @@ func (qb *imageFilterHandler) criterionHandler() criterionHandler { stringCriterionHandler(imageFilter.Checksum, "fingerprints_md5.fingerprint")(ctx, f) }), + + &phashDistanceCriterionHandler{ + joinFn: func(f *filterBuilder) { + imageRepository.addImagesFilesTable(f) + f.addLeftJoin(fingerprintTable, "fingerprints_phash", "images_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") + }, + criterion: imageFilter.PhashDistance, + }, + stringCriterionHandler(imageFilter.Title, "images.title"), stringCriterionHandler(imageFilter.Code, "images.code"), stringCriterionHandler(imageFilter.Details, "images.details"), diff --git a/pkg/sqlite/scene_filter.go b/pkg/sqlite/scene_filter.go index 72c75eca5..1f856e3cd 100644 --- a/pkg/sqlite/scene_filter.go +++ b/pkg/sqlite/scene_filter.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/utils" ) type sceneFilterHandler struct { @@ -83,14 +82,27 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler { criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if sceneFilter.Phash != nil { // backwards compatibility - qb.phashDistanceCriterionHandler(&models.PhashDistanceCriterionInput{ - Value: sceneFilter.Phash.Value, - Modifier: sceneFilter.Phash.Modifier, - })(ctx, f) + h := phashDistanceCriterionHandler{ + joinFn: func(f *filterBuilder) { + qb.addSceneFilesTable(f) + f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") + }, + criterion: &models.PhashDistanceCriterionInput{ + Value: sceneFilter.Phash.Value, + Modifier: sceneFilter.Phash.Modifier, + }, + } + h.handle(ctx, f) } }), - qb.phashDistanceCriterionHandler(sceneFilter.PhashDistance), + &phashDistanceCriterionHandler{ + joinFn: func(f *filterBuilder) { + qb.addSceneFilesTable(f) + f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") + }, + criterion: sceneFilter.PhashDistance, + }, intCriterionHandler(sceneFilter.Rating100, "scenes.rating", nil), qb.oCountCriterionHandler(sceneFilter.OCounter), @@ -547,42 +559,3 @@ func (qb *sceneFilterHandler) performerTagsCriterionHandler(tags *models.Hierarc joinPrimaryKey: sceneIDColumn, } } - -func (qb *sceneFilterHandler) phashDistanceCriterionHandler(phashDistance *models.PhashDistanceCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if phashDistance != nil { - qb.addSceneFilesTable(f) - f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") - - value, _ := utils.StringToPhash(phashDistance.Value) - distance := 0 - if phashDistance.Distance != nil { - distance = *phashDistance.Distance - } - - if distance == 0 { - // use the default handler - intCriterionHandler(&models.IntCriterionInput{ - Value: int(value), - Modifier: phashDistance.Modifier, - }, "fingerprints_phash.fingerprint", nil)(ctx, f) - } - - switch { - case phashDistance.Modifier == models.CriterionModifierEquals && distance > 0: - // needed to avoid a type mismatch - f.addWhere("typeof(fingerprints_phash.fingerprint) = 'integer'") - f.addWhere("phash_distance(fingerprints_phash.fingerprint, ?) < ?", value, distance) - case phashDistance.Modifier == models.CriterionModifierNotEquals && distance > 0: - // needed to avoid a type mismatch - f.addWhere("typeof(fingerprints_phash.fingerprint) = 'integer'") - f.addWhere("phash_distance(fingerprints_phash.fingerprint, ?) > ?", value, distance) - default: - intCriterionHandler(&models.IntCriterionInput{ - Value: int(value), - Modifier: phashDistance.Modifier, - }, "fingerprints_phash.fingerprint", nil)(ctx, f) - } - } - } -} diff --git a/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx b/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx index 5afdb0b8e..c30073e48 100644 --- a/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx +++ b/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx @@ -17,7 +17,7 @@ import { SettingsContext } from "../Settings/context"; interface ISceneGenerateDialog { selectedIds?: string[]; onClose: () => void; - type: "scene"; // TODO - add image generate + type: "scene" | "image"; } export const GenerateDialog: React.FC = ({ @@ -25,6 +25,9 @@ export const GenerateDialog: React.FC = ({ onClose, type, }) => { + const sceneIDs = type === "scene" ? selectedIds : undefined; + const imageIDs = type === "image" ? selectedIds : undefined; + const { configuration } = useConfigurationContext(); function getDefaultOptions(): GQL.GenerateMetadataInput { @@ -141,7 +144,8 @@ export const GenerateDialog: React.FC = ({ try { await mutateMetadataGenerate({ ...options, - sceneIDs: selectedIds, + sceneIDs, + imageIDs, }); Toast.success( intl.formatMessage( diff --git a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx index 47de3971e..f79d95fca 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx @@ -36,6 +36,7 @@ import cx from "classnames"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { goBackOrReplace } from "src/utils/history"; import { FormattedDate } from "src/components/Shared/Date"; +import { GenerateDialog } from "src/components/Dialogs/GenerateDialog"; interface IProps { image: GQL.ImageDataFragment; @@ -62,6 +63,7 @@ const ImagePage: React.FC = ({ image }) => { const [activeTabKey, setActiveTabKey] = useState("image-details-panel"); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); + const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false); async function onSave(input: GQL.ImageUpdateInput) { await updateImage({ @@ -170,6 +172,20 @@ const ImagePage: React.FC = ({ image }) => { } } + function maybeRenderSceneGenerateDialog() { + if (isGenerateDialogOpen) { + return ( + { + setIsGenerateDialogOpen(false); + }} + type="image" + /> + ); + } + } + function renderOperations() { return ( @@ -189,6 +205,13 @@ const ImagePage: React.FC = ({ image }) => { > + setIsGenerateDialogOpen(true)} + > + … + = ({ image }) => { {maybeRenderDeleteDialog()} + {maybeRenderSceneGenerateDialog()}
diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx index 4e566a626..f247e062b 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx @@ -9,6 +9,7 @@ import { useToast } from "src/hooks/Toast"; import TextUtils from "src/utils/text"; import { TextField, URLField, URLsField } from "src/utils/field"; import { FileSize } from "src/components/Shared/FileSize"; +import NavUtils from "src/utils/navigation"; interface IFileInfoPanelProps { file: GQL.ImageFileDataFragment | GQL.VideoFileDataFragment; @@ -23,6 +24,7 @@ const FileInfoPanel: React.FC = ( props: IFileInfoPanelProps ) => { const checksum = props.file.fingerprints.find((f) => f.type === "md5"); + const phash = props.file.fingerprints.find((f) => f.type === "phash"); return (
@@ -36,6 +38,15 @@ const FileInfoPanel: React.FC = ( )} + = PatchComponent( const filterMode = GQL.FilterMode.Images; - const otherOperations = [ + const { modal, showModal, closeModal } = useModal(); + + const otherOperations: IItemListOperation[] = [ ...extraOperations, { text: intl.formatMessage({ id: "actions.view_random" }), onClick: viewRandom, }, + { + text: `${intl.formatMessage({ id: "actions.generate" })}…`, + onClick: (result, filter, selectedIds) => { + showModal( + closeModal()} + /> + ); + return Promise.resolve(); + }, + isDisplayed: showWhenSelected, + }, { text: intl.formatMessage({ id: "actions.export" }), onClick: onExport, @@ -497,6 +515,7 @@ export const ImageList: React.FC = PatchComponent( view={view} selectable > + {modal} = PatchComponent("ScenePage", (props) => { className="bg-secondary text-white" onClick={() => setIsGenerateDialogOpen(true)} > - + = ({ headingID="dialogs.scene_gen.image_thumbnails" onChange={(v) => setOptions({ imageThumbnails: v })} /> + setOptions({ imagePhashes: v })} + /> )} = ({ scanGenerateSprites, scanGeneratePhashes, scanGenerateThumbnails, + scanGenerateImagePhashes, scanGenerateClipPreviews, rescan, } = options; @@ -72,6 +73,13 @@ export const ScanOptions: React.FC = ({ headingID="config.tasks.generate_thumbnails_during_scan" onChange={(v) => setOptions({ scanGenerateThumbnails: v })} /> + setOptions({ scanGenerateImagePhashes: v })} + /> Preview Generation.", "overwrite": "Overwrite existing files", - "phash": "Perceptual hashes", + "phash": "Video perceptual hashes", "phash_tooltip": "For deduplication and scene identification", "preview_exclude_end_time_desc": "Exclude the last x seconds from scene previews. This can be a value in seconds, or a percentage (eg 2%) of the total scene duration.", "preview_exclude_end_time_head": "Exclude end time", diff --git a/ui/v2.5/src/models/list-filter/images.ts b/ui/v2.5/src/models/list-filter/images.ts index 0b2e06df0..2d3db8265 100644 --- a/ui/v2.5/src/models/list-filter/images.ts +++ b/ui/v2.5/src/models/list-filter/images.ts @@ -22,6 +22,7 @@ import { import { ListFilterOptions, MediaSortByOptions } from "./filter-options"; import { DisplayMode } from "./types"; import { GalleriesCriterionOption } from "./criteria/galleries"; +import { PhashCriterionOption } from "./criteria/phash"; const defaultSortBy = "path"; @@ -47,6 +48,7 @@ const criterionOptions = [ createStringCriterionOption("details"), createStringCriterionOption("photographer"), createMandatoryStringCriterionOption("checksum", "media_info.checksum"), + PhashCriterionOption, PathCriterionOption, GalleriesCriterionOption, OrganizedCriterionOption, diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index 581d079c7..17d9dfe6b 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -342,6 +342,15 @@ const makeScenesPHashMatchUrl = (phash: GQL.Maybe | undefined) => { return `/scenes?${filter.makeQueryParameters()}`; }; +const makeImagesPHashMatchUrl = (phash: GQL.Maybe | undefined) => { + if (!phash) return "#"; + const filter = new ListFilterModel(GQL.FilterMode.Images, undefined); + const criterion = new PhashCriterion(); + criterion.value = { value: phash }; + filter.criteria.push(criterion); + return `/images?${filter.makeQueryParameters()}`; +}; + const makeGalleryImagesUrl = ( gallery: Partial, extraCriteria?: ModifierCriterion[] @@ -493,6 +502,7 @@ const NavUtils = { makeTagGroupsUrl, makeScenesPHashMatchUrl, makeSceneMarkerUrl, + makeImagesPHashMatchUrl, makeGroupScenesUrl, makeChildStudiosUrl, makeGalleryImagesUrl, From b8c5e1521795f4e39dfa7d00071f0ebdfd0e6ee2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 Jan 2026 17:13:06 +1100 Subject: [PATCH 038/177] Bump lodash-es from 4.17.21 to 4.17.23 in /ui/v2.5 (#6511) Bumps [lodash-es](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23. - [Release notes](https://github.com/lodash/lodash/releases) - [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23) --- updated-dependencies: - dependency-name: lodash-es dependency-version: 4.17.23 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- ui/v2.5/package.json | 2 +- ui/v2.5/pnpm-lock.yaml | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/ui/v2.5/package.json b/ui/v2.5/package.json index f774aedbd..e024a0053 100644 --- a/ui/v2.5/package.json +++ b/ui/v2.5/package.json @@ -50,7 +50,7 @@ "graphql-ws": "^5.14.3", "i18n-iso-countries": "^7.5.0", "localforage": "^1.10.0", - "lodash-es": "^4.17.21", + "lodash-es": "^4.17.23", "moment": "^2.30.1", "mousetrap": "^1.6.5", "mousetrap-pause": "^1.0.0", diff --git a/ui/v2.5/pnpm-lock.yaml b/ui/v2.5/pnpm-lock.yaml index 27b993864..02033c41f 100644 --- a/ui/v2.5/pnpm-lock.yaml +++ b/ui/v2.5/pnpm-lock.yaml @@ -96,8 +96,8 @@ importers: specifier: ^1.10.0 version: 1.10.0 lodash-es: - specifier: ^4.17.21 - version: 4.17.21 + specifier: ^4.17.23 + version: 4.17.23 moment: specifier: ^2.30.1 version: 2.30.1 @@ -1215,6 +1215,7 @@ packages: '@formatjs/intl-enumerator@1.4.6': resolution: {integrity: sha512-O2YMcE3SuBy4jL8r6YNq/8hvFrQ92QGLawdmzFbOi8D1r3VOfEMr8ifnOMp3zt8XemfTLrma+aF6yRCVeEbVLw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. '@formatjs/intl-getcanonicallocales@2.3.0': resolution: {integrity: sha512-BOXbLwqQ7nKua/l7tKqDLRN84WupDXFDhGJQMFvsMVA2dKuOdRaWTxWpL3cJ7qPkoNw11Jf+Xpj4OSPBBvW0eQ==} @@ -2373,6 +2374,7 @@ packages: bootstrap@4.6.2: resolution: {integrity: sha512-51Bbp/Uxr9aTuy6ca/8FbFloBUJZLHwnhTcnjIeRn2suQWsWzcuJhGjKDB5eppVte/8oCdOL3VuwxvZDUggwGQ==} + deprecated: This version of Bootstrap is no longer supported. Please upgrade to the latest version. peerDependencies: jquery: 1.9.1 - 3 popper.js: ^1.16.1 @@ -3699,8 +3701,8 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - lodash-es@4.17.21: - resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -8788,7 +8790,7 @@ snapshots: deepmerge: 2.2.1 hoist-non-react-statics: 3.3.2 lodash: 4.17.21 - lodash-es: 4.17.21 + lodash-es: 4.17.23 react: 17.0.2 react-fast-compare: 2.0.4 tiny-warning: 1.0.3 @@ -9443,7 +9445,7 @@ snapshots: dependencies: p-locate: 5.0.0 - lodash-es@4.17.21: {} + lodash-es@4.17.23: {} lodash.debounce@4.0.8: {} From 6f5a7d1f0a2f63fc35c8a3f3ef9e034c8db3cbc8 Mon Sep 17 00:00:00 2001 From: WeedLordVegeta420 <81525421+WeedLordVegeta420@users.noreply.github.com> Date: Tue, 27 Jan 2026 01:24:14 -0500 Subject: [PATCH 039/177] Add latest scene sort for performers and studios. (#6501) --- pkg/sqlite/performer.go | 25 ++++++++++++++++++++ pkg/sqlite/scene.go | 1 + pkg/sqlite/studio.go | 23 ++++++++++++++++++ ui/v2.5/src/locales/en-GB.json | 1 + ui/v2.5/src/models/list-filter/performers.ts | 1 + ui/v2.5/src/models/list-filter/studios.ts | 1 + 6 files changed, 52 insertions(+) diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 4e06b5b29..bc4461f5f 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -706,6 +706,28 @@ func (qb *PerformerStore) sortByLastOAt(direction string) string { return " ORDER BY (" + selectPerformerLastOAtSQL + ") " + direction } +// used for sorting on performer latest scene +var selectPerformerLatestSceneSQL = utils.StrFormat( + "SELECT MAX(date) FROM ("+ + "SELECT {date} FROM {performers_scenes} s "+ + "LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} "+ + "WHERE s.{performer_id} = {performers}.id"+ + ")", + map[string]interface{}{ + "performer_id": performerIDColumn, + "performers": performerTable, + "performers_scenes": performersScenesTable, + "scenes": sceneTable, + "scene_id": sceneIDColumn, + "date": sceneDateColumn, + }, +) + +func (qb *PerformerStore) sortByLatestScene(direction string) string { + // need to get the latest date from scenes + return " ORDER BY (" + selectPerformerLatestSceneSQL + ") " + direction +} + // used for sorting on performer last view_date var selectPerformerLastPlayedAtSQL = utils.StrFormat( "SELECT MAX(view_date) FROM ("+ @@ -762,6 +784,7 @@ var performerSortOptions = sortOptions{ "images_count", "last_o_at", "last_played_at", + "latest_scene", "measurements", "name", "o_counter", @@ -812,6 +835,8 @@ func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) (s sortQuery += qb.sortByLastPlayedAt(direction) case "last_o_at": sortQuery += qb.sortByLastOAt(direction) + case "latest_scene": + sortQuery += qb.sortByLatestScene(direction) default: sortQuery += getSort(sort, direction, "performers") } diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index a0b9005a5..d92800317 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -26,6 +26,7 @@ const ( sceneTable = "scenes" scenesFilesTable = "scenes_files" sceneIDColumn = "scene_id" + sceneDateColumn = "date" performersScenesTable = "performers_scenes" scenesTagsTable = "scenes_tags" scenesGalleriesTable = "scenes_galleries" diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index 1a05be6f3..e328818da 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -15,6 +15,7 @@ import ( "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/studio" + "github.com/stashapp/stash/pkg/utils" ) const ( @@ -601,12 +602,32 @@ func (qb *StudioStore) sortByScenesDuration(direction string) string { ) %s`, sceneTable, scenesFilesTable, scenesFilesTable, sceneIDColumn, sceneTable, scenesFilesTable, sceneTable, studioIDColumn, studioTable, getSortDirection(direction)) } +// used for sorting on performer latest scene +var selectStudioLatestSceneSQL = utils.StrFormat( + "SELECT MAX(date) FROM ("+ + "SELECT {date} FROM {scenes} s "+ + "WHERE s.{studio_id} = {studios}.id"+ + ")", + map[string]interface{}{ + "scenes": sceneTable, + "studios": studioTable, + "studio_id": studioIDColumn, + "date": sceneDateColumn, + }, +) + +func (qb *StudioStore) sortByLatestScene(direction string) string { + // need to get the latest date from scenes + return " ORDER BY (" + selectStudioLatestSceneSQL + ") " + direction +} + var studioSortOptions = sortOptions{ "child_count", "created_at", "galleries_count", "id", "images_count", + "latest_scene", "name", "scenes_count", "scenes_duration", @@ -646,6 +667,8 @@ func (qb *StudioStore) getStudioSort(findFilter *models.FindFilterType) (string, sortQuery += getCountSort(studioTable, galleryTable, studioIDColumn, direction) case "child_count": sortQuery += getCountSort(studioTable, studioTable, studioParentIDColumn, direction) + case "latest_scene": + sortQuery += qb.sortByLatestScene(direction) default: sortQuery += getSort(sort, direction, "studios") } diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index e417e8cbb..b4eb8a6bc 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1203,6 +1203,7 @@ "last_o_at": "Last O At", "last_o_at_sfw": "Last Like At", "last_played_at": "Last Played At", + "latest_scene": "Latest Scene", "library": "Library", "loading": { "generic": "Loading…", diff --git a/ui/v2.5/src/models/list-filter/performers.ts b/ui/v2.5/src/models/list-filter/performers.ts index fcc152d01..c0bcb3bba 100644 --- a/ui/v2.5/src/models/list-filter/performers.ts +++ b/ui/v2.5/src/models/list-filter/performers.ts @@ -31,6 +31,7 @@ const sortByOptions = [ "penis_length", "play_count", "last_played_at", + "latest_scene", "career_length", "weight", "measurements", diff --git a/ui/v2.5/src/models/list-filter/studios.ts b/ui/v2.5/src/models/list-filter/studios.ts index 02dfae2f6..42ac1b4dc 100644 --- a/ui/v2.5/src/models/list-filter/studios.ts +++ b/ui/v2.5/src/models/list-filter/studios.ts @@ -21,6 +21,7 @@ const sortByOptions = [ "random", "rating", "scenes_duration", + "latest_scene", ] .map(ListFilterOptions.createSortBy) .concat([ From 244d70e20e24e21d63ede318877136e19f495301 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:26:42 -0800 Subject: [PATCH 040/177] Feature: Stash ID Count Filter (#6347) Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/schema/types/filters.graphql | 2 ++ pkg/models/scene.go | 2 ++ pkg/sqlite/scene_filter.go | 12 +++++++++++ pkg/sqlite/scene_test.go | 26 ++++++++++++++++++++++++ pkg/sqlite/setup_test.go | 11 +++++++--- ui/v2.5/src/locales/en-GB.json | 1 + ui/v2.5/src/models/list-filter/scenes.ts | 1 + ui/v2.5/src/models/list-filter/types.ts | 1 + 8 files changed, 53 insertions(+), 3 deletions(-) diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index b84bc638e..4e70b7353 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -308,6 +308,8 @@ input SceneFilterType { @deprecated(reason: "use stash_ids_endpoint instead") "Filter by StashIDs" stash_ids_endpoint: StashIDsCriterionInput + "Filter by StashID count" + stash_id_count: IntCriterionInput "Filter by url" url: StringCriterionInput "Filter by interactive" diff --git a/pkg/models/scene.go b/pkg/models/scene.go index f50709356..434659cbe 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -81,6 +81,8 @@ type SceneFilterType struct { StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"` // Filter by StashIDs Endpoint StashIDsEndpoint *StashIDsCriterionInput `json:"stash_ids_endpoint"` + // Filter by StashID count + StashIDCount *IntCriterionInput `json:"stash_id_count"` // Filter by url URL *StringCriterionInput `json:"url"` // Filter by interactive diff --git a/pkg/sqlite/scene_filter.go b/pkg/sqlite/scene_filter.go index 1f856e3cd..aa0d349df 100644 --- a/pkg/sqlite/scene_filter.go +++ b/pkg/sqlite/scene_filter.go @@ -139,6 +139,8 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler { parentIDCol: "scenes.id", }, + qb.stashIDCountCriterionHandler(sceneFilter.StashIDCount), + boolCriterionHandler(sceneFilter.Interactive, "video_files.interactive", qb.addVideoFilesTable), intCriterionHandler(sceneFilter.InteractiveSpeed, "video_files.interactive_speed", qb.addVideoFilesTable), @@ -453,6 +455,16 @@ func (qb *sceneFilterHandler) tagCountCriterionHandler(tagCount *models.IntCrite return h.handler(tagCount) } +func (qb *sceneFilterHandler) stashIDCountCriterionHandler(stashIDCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: sceneTable, + joinTable: "scene_stash_ids", + primaryFK: sceneIDColumn, + } + + return h.handler(stashIDCount) +} + func (qb *sceneFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc { h := joinedMultiCriterionHandlerBuilder{ primaryTable: sceneTable, diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index df6676a0f..ae9ba56cf 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -2273,6 +2273,32 @@ func TestSceneQuery(t *testing.T) { nil, false, }, + { + "single stash id", + nil, + &models.SceneFilterType{ + StashIDCount: &models.IntCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: 1, + }, + }, + []int{sceneIdxWithGallery, sceneIdxWithPerformer}, + []int{sceneIdxWithGroup}, + false, + }, + { + "less than one stash id", + nil, + &models.SceneFilterType{ + StashIDCount: &models.IntCriterionInput{ + Modifier: models.CriterionModifierLessThan, + Value: 1, + }, + }, + []int{sceneIdxWithGroup}, + []int{sceneIdxWithGallery, sceneIdxWithPerformer}, + false, + }, } for _, tt := range tests { diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 7e6f821d1..843b8b4c2 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1076,6 +1076,13 @@ func getObjectDate(index int) *models.Date { return &ret } +func sceneStashIDs(i int) []models.StashID { + if i%5 == 0 { + return nil + } + return []models.StashID{sceneStashID(i)} +} + func sceneStashID(i int) models.StashID { return models.StashID{ StashID: getSceneStringValue(i, "stashid"), @@ -1174,9 +1181,7 @@ func makeScene(i int) *models.Scene { PerformerIDs: models.NewRelatedIDs(pids), TagIDs: models.NewRelatedIDs(tids), Groups: models.NewRelatedGroups(groups), - StashIDs: models.NewRelatedStashIDs([]models.StashID{ - sceneStashID(i), - }), + StashIDs: models.NewRelatedStashIDs(sceneStashIDs(i)), PlayDuration: getScenePlayDuration(i), ResumeTime: getSceneResumeTime(i), } diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index b4eb8a6bc..76df6cf33 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1481,6 +1481,7 @@ "welcome_to_stash": "Welcome to Stash" }, "stash_id": "Stash ID", + "stash_id_count": "Stash ID Count", "stash_id_endpoint": "Stash ID Endpoint URL", "stash_ids": "Stash IDs", "stashbox_search": { diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index 5fdb6a770..251e2592d 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -133,6 +133,7 @@ const criterionOptions = [ GalleriesCriterionOption, createStringCriterionOption("url"), StashIDCriterionOption, + createMandatoryNumberCriterionOption("stash_id_count"), InteractiveCriterionOption, CaptionsCriterionOption, createMandatoryNumberCriterionOption("interactive_speed"), diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 83ebaa010..bf5fff4d9 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -200,6 +200,7 @@ export type CriterionType = | "ignore_auto_tag" | "file_count" | "stash_id_endpoint" + | "stash_id_count" | "date" | "created_at" | "updated_at" From d252a416d0aa378612791e06f8ac43107d60f901 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 27 Jan 2026 17:42:15 +1100 Subject: [PATCH 041/177] Refactor file scanning and handling logic (#6498) - Moved directory walking and queuing functionality into scan task code --- internal/manager/manager_tasks.go | 6 + internal/manager/task_scan.go | 289 ++++++++++++++- pkg/file/file.go | 24 ++ pkg/file/folder_rename_detect.go | 10 +- pkg/file/scan.go | 594 ++++++------------------------ pkg/file/walk.go | 4 +- pkg/file/zip.go | 4 +- 7 files changed, 431 insertions(+), 500 deletions(-) diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index 1e66433be..bac726c1b 100644 --- a/internal/manager/manager_tasks.go +++ b/internal/manager/manager_tasks.go @@ -100,6 +100,8 @@ func (s *Manager) Scan(ctx context.Context, input ScanMetadataInput) (int, error return 0, err } + cfg := config.GetInstance() + scanner := &file.Scanner{ Repository: file.NewRepository(s.Repository), FileDecorators: []file.Decorator{ @@ -118,6 +120,10 @@ func (s *Manager) Scan(ctx context.Context, input ScanMetadataInput) (int, error }, FingerprintCalculator: &fingerprintCalculator{s.Config}, FS: &file.OsFS{}, + ZipFileExtensions: cfg.GetGalleryExtensions(), + // ScanFilters is set in ScanJob.Execute + // HandlerRequiredFilters is set in ScanJob.Execute + Rescan: input.Rescan, } scanJob := ScanJob{ diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index fc1a4770f..d09765577 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -2,13 +2,17 @@ package manager import ( "context" + "errors" "fmt" "io/fs" "path/filepath" "regexp" + "runtime/debug" + "sync" "time" "github.com/99designs/gqlgen/graphql/handler/lru" + "github.com/remeh/sizedwaitgroup" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/file/video" @@ -24,14 +28,13 @@ import ( "github.com/stashapp/stash/pkg/txn" ) -type scanner interface { - Scan(ctx context.Context, handlers []file.Handler, options file.ScanOptions, progressReporter file.ProgressReporter) -} - type ScanJob struct { - scanner scanner + scanner *file.Scanner input ScanMetadataInput subscriptions *subscriptionManager + + fileQueue chan file.ScannedFile + count int } func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error { @@ -55,22 +58,22 @@ func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error { start := time.Now() + nTasks := cfg.GetParallelTasksWithAutoDetection() + const taskQueueSize = 200000 - taskQueue := job.NewTaskQueue(ctx, progress, taskQueueSize, cfg.GetParallelTasksWithAutoDetection()) + taskQueue := job.NewTaskQueue(ctx, progress, taskQueueSize, nTasks) var minModTime time.Time if j.input.Filter != nil && j.input.Filter.MinModTime != nil { minModTime = *j.input.Filter.MinModTime } - j.scanner.Scan(ctx, getScanHandlers(j.input, taskQueue, progress), file.ScanOptions{ - Paths: paths, - ScanFilters: []file.PathFilter{newScanFilter(c, repo, minModTime)}, - ZipFileExtensions: cfg.GetGalleryExtensions(), - ParallelTasks: cfg.GetParallelTasksWithAutoDetection(), - HandlerRequiredFilters: []file.Filter{newHandlerRequiredFilter(cfg, repo)}, - Rescan: j.input.Rescan, - }, progress) + // HACK - these should really be set in the scanner initialization + j.scanner.FileHandlers = getScanHandlers(j.input, taskQueue, progress) + j.scanner.ScanFilters = []file.PathFilter{newScanFilter(c, repo, minModTime)} + j.scanner.HandlerRequiredFilters = []file.Filter{newHandlerRequiredFilter(cfg, repo)} + + j.runJob(ctx, paths, nTasks, progress) taskQueue.Close() @@ -86,6 +89,264 @@ func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error { return nil } +func (j *ScanJob) runJob(ctx context.Context, paths []string, nTasks int, progress *job.Progress) { + var wg sync.WaitGroup + wg.Add(1) + + j.fileQueue = make(chan file.ScannedFile, scanQueueSize) + + go func() { + defer func() { + wg.Done() + + // handle panics in goroutine + if p := recover(); p != nil { + logger.Errorf("panic while queuing files for scan: %v", p) + logger.Errorf(string(debug.Stack())) + } + }() + + if err := j.queueFiles(ctx, paths, progress); err != nil { + if errors.Is(err, context.Canceled) { + return + } + + logger.Errorf("error queuing files for scan: %v", err) + return + } + + logger.Infof("Finished adding files to queue. %d files queued", j.count) + }() + + defer wg.Wait() + + j.processQueue(ctx, nTasks, progress) +} + +const scanQueueSize = 200000 + +func (j *ScanJob) queueFiles(ctx context.Context, paths []string, progress *job.Progress) error { + fs := &file.OsFS{} + + defer func() { + close(j.fileQueue) + + progress.AddTotal(j.count) + progress.Definite() + }() + + var err error + progress.ExecuteTask("Walking directory tree", func() { + for _, p := range paths { + err = file.SymWalk(fs, p, j.queueFileFunc(ctx, fs, nil, progress)) + if err != nil { + return + } + } + }) + + return err +} + +func (j *ScanJob) queueFileFunc(ctx context.Context, f models.FS, zipFile *file.ScannedFile, progress *job.Progress) fs.WalkDirFunc { + return func(path string, d fs.DirEntry, err error) error { + if err != nil { + // don't let errors prevent scanning + logger.Errorf("error scanning %s: %v", path, err) + return nil + } + + if err = ctx.Err(); err != nil { + return err + } + + info, err := d.Info() + if err != nil { + logger.Errorf("reading info for %q: %v", path, err) + return nil + } + + if !j.scanner.AcceptEntry(ctx, path, info) { + if info.IsDir() { + logger.Debugf("Skipping directory %s", path) + return fs.SkipDir + } + + logger.Debugf("Skipping file %s", path) + return nil + } + + size, err := file.GetFileSize(f, path, info) + if err != nil { + return err + } + + ff := file.ScannedFile{ + BaseFile: &models.BaseFile{ + DirEntry: models.DirEntry{ + ModTime: file.ModTime(info), + }, + Path: path, + Basename: filepath.Base(path), + Size: size, + }, + FS: f, + Info: info, + } + + if zipFile != nil { + ff.ZipFileID = &zipFile.ID + ff.ZipFile = zipFile + } + + if info.IsDir() { + // handle folders immediately + if err := j.handleFolder(ctx, ff, progress); err != nil { + if !errors.Is(err, context.Canceled) { + logger.Errorf("error processing %q: %v", path, err) + } + + // skip the directory since we won't be able to process the files anyway + return fs.SkipDir + } + + return nil + } + + // if zip file is present, we handle immediately + if zipFile != nil { + progress.ExecuteTask("Scanning "+path, func() { + // don't increment progress in zip files + if err := j.handleFile(ctx, ff, nil); err != nil { + if !errors.Is(err, context.Canceled) { + logger.Errorf("error processing %q: %v", path, err) + } + // don't return an error, just skip the file + } + }) + + return nil + } + + logger.Tracef("Queueing file %s for scanning", path) + j.fileQueue <- ff + + j.count++ + + return nil + } +} + +func (j *ScanJob) processQueue(ctx context.Context, parallelTasks int, progress *job.Progress) { + if parallelTasks < 1 { + parallelTasks = 1 + } + + wg := sizedwaitgroup.New(parallelTasks) + + func() { + defer func() { + wg.Wait() + + // handle panics in goroutine + if p := recover(); p != nil { + logger.Errorf("panic while scanning files: %v", p) + logger.Errorf(string(debug.Stack())) + } + }() + + for f := range j.fileQueue { + logger.Tracef("Processing queued file %s", f.Path) + if err := ctx.Err(); err != nil { + return + } + + wg.Add() + ff := f + go func() { + defer wg.Done() + j.processQueueItem(ctx, ff, progress) + }() + } + }() +} + +func (j *ScanJob) processQueueItem(ctx context.Context, f file.ScannedFile, progress *job.Progress) { + progress.ExecuteTask("Scanning "+f.Path, func() { + var err error + if f.Info.IsDir() { + err = j.handleFolder(ctx, f, progress) + } else { + err = j.handleFile(ctx, f, progress) + } + + if err != nil && !errors.Is(err, context.Canceled) { + logger.Errorf("error processing %q: %v", f.Path, err) + } + }) +} + +func (j *ScanJob) handleFolder(ctx context.Context, f file.ScannedFile, progress *job.Progress) error { + if progress != nil { + defer progress.Increment() + } + + _, err := j.scanner.ScanFolder(ctx, f) + if err != nil { + return err + } + + return nil +} + +func (j *ScanJob) handleFile(ctx context.Context, f file.ScannedFile, progress *job.Progress) error { + if progress != nil { + defer progress.Increment() + } + + r, err := j.scanner.ScanFile(ctx, f) + if err != nil { + return err + } + + // handle rename should have already handled the contents of the zip file + // so shouldn't need to scan it again + + if (r.New || r.Updated) && j.scanner.IsZipFile(f.Info.Name()) { + ff := r.File + f.BaseFile = ff.Base() + + // scan zip files with a different context that is not cancellable + // cancelling while scanning zip file contents results in the scan + // contents being partially completed + zipCtx := context.WithoutCancel(ctx) + + if err := j.scanZipFile(zipCtx, f, progress); err != nil { + logger.Errorf("Error scanning zip file %q: %v", f.Path, err) + } + } + + return nil +} + +func (j *ScanJob) scanZipFile(ctx context.Context, f file.ScannedFile, progress *job.Progress) error { + zipFS, err := f.FS.OpenZip(f.Path, f.Size) + if err != nil { + if errors.Is(err, file.ErrNotReaderAt) { + // can't walk the zip file + // just return + logger.Debugf("Skipping zip file %q as it cannot be opened for walking", f.Path) + return nil + } + + return err + } + + defer zipFS.Close() + + return file.SymWalk(zipFS, f.Path, j.queueFileFunc(ctx, zipFS, &f, progress)) +} + type extensionConfig struct { vidExt []string imgExt []string diff --git a/pkg/file/file.go b/pkg/file/file.go index 407949ba1..b93083b35 100644 --- a/pkg/file/file.go +++ b/pkg/file/file.go @@ -3,6 +3,10 @@ package file import ( "context" + "fmt" + "io/fs" + "os" + "time" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/txn" @@ -35,3 +39,23 @@ func (r *Repository) WithReadTxn(ctx context.Context, fn txn.TxnFunc) error { func (r *Repository) WithDB(ctx context.Context, fn txn.TxnFunc) error { return txn.WithDatabase(ctx, r.TxnManager, fn) } + +// ModTime returns the modification time truncated to seconds. +func ModTime(info fs.FileInfo) time.Time { + // truncate to seconds, since we don't store beyond that in the database + return info.ModTime().Truncate(time.Second) +} + +// GetFileSize gets the size of the file, taking into account symlinks. +func GetFileSize(f models.FS, path string, info fs.FileInfo) (int64, error) { + // #2196/#3042 - replace size with target size if file is a symlink + if info.Mode()&os.ModeSymlink == os.ModeSymlink { + targetInfo, err := f.Stat(path) + if err != nil { + return 0, fmt.Errorf("reading info for symlink %q: %w", path, err) + } + return targetInfo.Size(), nil + } + + return info.Size(), nil +} diff --git a/pkg/file/folder_rename_detect.go b/pkg/file/folder_rename_detect.go index 4c057461b..cfae7e4fb 100644 --- a/pkg/file/folder_rename_detect.go +++ b/pkg/file/folder_rename_detect.go @@ -75,7 +75,7 @@ func (d *folderRenameDetector) bestCandidate() *models.Folder { return best.folder } -func (s *scanJob) detectFolderMove(ctx context.Context, file scanFile) (*models.Folder, error) { +func (s *Scanner) detectFolderMove(ctx context.Context, file ScannedFile) (*models.Folder, error) { // in order for a folder to be considered moved, the existing folder must be // missing, and the majority of the old folder's files must be present, unchanged, // in the new folder. @@ -88,7 +88,7 @@ func (s *scanJob) detectFolderMove(ctx context.Context, file scanFile) (*models. r := s.Repository - if err := symWalk(file.fs, file.Path, func(path string, d fs.DirEntry, err error) error { + if err := SymWalk(file.FS, file.Path, func(path string, d fs.DirEntry, err error) error { if err != nil { // don't let errors prevent scanning logger.Errorf("error scanning %s: %v", path, err) @@ -111,11 +111,11 @@ func (s *scanJob) detectFolderMove(ctx context.Context, file scanFile) (*models. return nil } - if !s.acceptEntry(ctx, path, info) { + if !s.AcceptEntry(ctx, path, info) { return nil } - size, err := getFileSize(file.fs, path, info) + size, err := GetFileSize(file.FS, path, info) if err != nil { return fmt.Errorf("getting file size for %q: %w", path, err) } @@ -154,7 +154,7 @@ func (s *scanJob) detectFolderMove(ctx context.Context, file scanFile) (*models. } // parent folder must be missing - _, err = file.fs.Lstat(pf.Path) + _, err = file.FS.Lstat(pf.Path) if err == nil { // parent folder exists, not a candidate detector.reject(parentFolderID) diff --git a/pkg/file/scan.go b/pkg/file/scan.go index 36b409c89..d9a58ad44 100644 --- a/pkg/file/scan.go +++ b/pkg/file/scan.go @@ -2,29 +2,18 @@ package file import ( "context" - "errors" "fmt" "io/fs" - "os" "path/filepath" - "runtime/debug" "strings" "sync" "time" - "github.com/remeh/sizedwaitgroup" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/txn" ) -const ( - scanQueueSize = 200000 - // maximum number of times to retry in the event of a locked database - // use -1 to retry forever - maxRetries = -1 -) - // Scanner scans files into the database. // // The scan process works using two goroutines. The first walks through the provided paths @@ -55,8 +44,26 @@ type Scanner struct { Repository Repository FingerprintCalculator FingerprintCalculator + // ZipFileExtensions is a list of file extensions that are considered zip files. + // Extension does not include the . character. + ZipFileExtensions []string + + // ScanFilters are used to determine if a file should be scanned. + ScanFilters []PathFilter + + // HandlerRequiredFilters are used to determine if an unchanged file needs to be handled + HandlerRequiredFilters []Filter + // FileDecorators are applied to files as they are scanned. FileDecorators []Decorator + + // handlers are called after a file has been scanned. + FileHandlers []Handler + + // Rescan indicates whether files should be rescanned even if they haven't changed. + Rescan bool + + folderPathToID sync.Map } // FingerprintCalculator calculates a fingerprint for the provided file. @@ -91,257 +98,18 @@ func (d *FilteredDecorator) IsMissingMetadata(ctx context.Context, fs models.FS, return false } -// ProgressReporter is used to report progress of the scan. -type ProgressReporter interface { - AddTotal(total int) - Increment() - Definite() - ExecuteTask(description string, fn func()) -} - -type scanJob struct { - *Scanner - - // handlers are called after a file has been scanned. - handlers []Handler - - ProgressReports ProgressReporter - options ScanOptions - - startTime time.Time - fileQueue chan scanFile - retryList []scanFile - retrying bool - folderPathToID sync.Map - zipPathToID sync.Map - count int - - txnRetryer txn.Retryer -} - -// ScanOptions provides options for scanning files. -type ScanOptions struct { - Paths []string - - // ZipFileExtensions is a list of file extensions that are considered zip files. - // Extension does not include the . character. - ZipFileExtensions []string - - // ScanFilters are used to determine if a file should be scanned. - ScanFilters []PathFilter - - // HandlerRequiredFilters are used to determine if an unchanged file needs to be handled - HandlerRequiredFilters []Filter - - ParallelTasks int - - // When true files in path will be rescanned even if they haven't changed - Rescan bool -} - -// Scan starts the scanning process. -func (s *Scanner) Scan(ctx context.Context, handlers []Handler, options ScanOptions, progressReporter ProgressReporter) { - job := &scanJob{ - Scanner: s, - handlers: handlers, - ProgressReports: progressReporter, - options: options, - txnRetryer: txn.Retryer{ - Manager: s.Repository.TxnManager, - Retries: maxRetries, - }, - } - - job.execute(ctx) -} - -type scanFile struct { +// ScannedFile represents a file being scanned. +type ScannedFile struct { *models.BaseFile - fs models.FS - info fs.FileInfo + FS models.FS + Info fs.FileInfo } -func (s *scanJob) withTxn(ctx context.Context, fn func(ctx context.Context) error) error { - return s.txnRetryer.WithTxn(ctx, fn) -} - -func (s *scanJob) withDB(ctx context.Context, fn func(ctx context.Context) error) error { - return s.Repository.WithDB(ctx, fn) -} - -func (s *scanJob) execute(ctx context.Context) { - paths := s.options.Paths - logger.Infof("scanning %d paths", len(paths)) - s.startTime = time.Now() - - s.fileQueue = make(chan scanFile, scanQueueSize) - var wg sync.WaitGroup - wg.Add(1) - - go func() { - defer func() { - wg.Done() - - // handle panics in goroutine - if p := recover(); p != nil { - logger.Errorf("panic while queuing files for scan: %v", p) - logger.Errorf(string(debug.Stack())) - } - }() - - if err := s.queueFiles(ctx, paths); err != nil { - if errors.Is(err, context.Canceled) { - return - } - - logger.Errorf("error queuing files for scan: %v", err) - return - } - - logger.Infof("Finished adding files to queue. %d files queued", s.count) - }() - - defer wg.Wait() - - if err := s.processQueue(ctx); err != nil { - if errors.Is(err, context.Canceled) { - return - } - - logger.Errorf("error scanning files: %v", err) - return - } -} - -func (s *scanJob) queueFiles(ctx context.Context, paths []string) error { - defer func() { - close(s.fileQueue) - - if s.ProgressReports != nil { - s.ProgressReports.AddTotal(s.count) - s.ProgressReports.Definite() - } - }() - - var err error - s.ProgressReports.ExecuteTask("Walking directory tree", func() { - for _, p := range paths { - err = symWalk(s.FS, p, s.queueFileFunc(ctx, s.FS, nil)) - if err != nil { - return - } - } - }) - - return err -} - -func (s *scanJob) queueFileFunc(ctx context.Context, f models.FS, zipFile *scanFile) fs.WalkDirFunc { - return func(path string, d fs.DirEntry, err error) error { - if err != nil { - // don't let errors prevent scanning - logger.Errorf("error scanning %s: %v", path, err) - return nil - } - - if err = ctx.Err(); err != nil { - return err - } - - info, err := d.Info() - if err != nil { - logger.Errorf("reading info for %q: %v", path, err) - return nil - } - - if !s.acceptEntry(ctx, path, info) { - if info.IsDir() { - return fs.SkipDir - } - - return nil - } - - size, err := getFileSize(f, path, info) - if err != nil { - return err - } - - ff := scanFile{ - BaseFile: &models.BaseFile{ - DirEntry: models.DirEntry{ - ModTime: modTime(info), - }, - Path: path, - Basename: filepath.Base(path), - Size: size, - }, - fs: f, - info: info, - } - - if zipFile != nil { - zipFileID, err := s.getZipFileID(ctx, zipFile) - if err != nil { - return err - } - ff.ZipFileID = zipFileID - ff.ZipFile = zipFile - } - - if info.IsDir() { - // handle folders immediately - if err := s.handleFolder(ctx, ff); err != nil { - if !errors.Is(err, context.Canceled) { - logger.Errorf("error processing %q: %v", path, err) - } - - // skip the directory since we won't be able to process the files anyway - return fs.SkipDir - } - - return nil - } - - // if zip file is present, we handle immediately - if zipFile != nil { - s.ProgressReports.ExecuteTask("Scanning "+path, func() { - if err := s.handleFile(ctx, ff); err != nil { - if !errors.Is(err, context.Canceled) { - logger.Errorf("error processing %q: %v", path, err) - } - // don't return an error, just skip the file - } - }) - - return nil - } - - s.fileQueue <- ff - - s.count++ - - return nil - } -} - -func getFileSize(f models.FS, path string, info fs.FileInfo) (int64, error) { - // #2196/#3042 - replace size with target size if file is a symlink - if info.Mode()&os.ModeSymlink == os.ModeSymlink { - targetInfo, err := f.Stat(path) - if err != nil { - return 0, fmt.Errorf("reading info for symlink %q: %w", path, err) - } - return targetInfo.Size(), nil - } - - return info.Size(), nil -} - -func (s *scanJob) acceptEntry(ctx context.Context, path string, info fs.FileInfo) bool { +// AcceptEntry determines if the file entry should be accepted for scanning +func (s *Scanner) AcceptEntry(ctx context.Context, path string, info fs.FileInfo) bool { // always accept if there's no filters - accept := len(s.options.ScanFilters) == 0 - for _, filter := range s.options.ScanFilters { + accept := len(s.ScanFilters) == 0 + for _, filter := range s.ScanFilters { // accept if any filter accepts the file if filter.Accept(ctx, path, info) { accept = true @@ -352,102 +120,7 @@ func (s *scanJob) acceptEntry(ctx context.Context, path string, info fs.FileInfo return accept } -func (s *scanJob) scanZipFile(ctx context.Context, f scanFile) error { - zipFS, err := f.fs.OpenZip(f.Path, f.Size) - if err != nil { - if errors.Is(err, errNotReaderAt) { - // can't walk the zip file - // just return - return nil - } - - return err - } - - defer zipFS.Close() - - return symWalk(zipFS, f.Path, s.queueFileFunc(ctx, zipFS, &f)) -} - -func (s *scanJob) processQueue(ctx context.Context) error { - parallelTasks := s.options.ParallelTasks - if parallelTasks < 1 { - parallelTasks = 1 - } - - wg := sizedwaitgroup.New(parallelTasks) - - if err := func() error { - defer wg.Wait() - - for f := range s.fileQueue { - if err := ctx.Err(); err != nil { - return err - } - - wg.Add() - ff := f - go func() { - defer wg.Done() - s.processQueueItem(ctx, ff) - }() - } - - return nil - }(); err != nil { - return err - } - - s.retrying = true - - if err := func() error { - defer wg.Wait() - - for _, f := range s.retryList { - if err := ctx.Err(); err != nil { - return err - } - - wg.Add() - ff := f - go func() { - defer wg.Done() - s.processQueueItem(ctx, ff) - }() - } - - return nil - }(); err != nil { - return err - } - - return nil -} - -func (s *scanJob) incrementProgress(f scanFile) { - // don't increment for files inside zip files since these aren't - // counted during the initial walking - if s.ProgressReports != nil && f.ZipFile == nil { - s.ProgressReports.Increment() - } -} - -func (s *scanJob) processQueueItem(ctx context.Context, f scanFile) { - s.ProgressReports.ExecuteTask("Scanning "+f.Path, func() { - var err error - if f.info.IsDir() { - err = s.handleFolder(ctx, f) - } else { - err = s.handleFile(ctx, f) - } - - if err != nil && !errors.Is(err, context.Canceled) { - logger.Errorf("error processing %q: %v", f.Path, err) - } - }) -} - -func (s *scanJob) getFolderID(ctx context.Context, path string) (*models.FolderID, error) { +func (s *Scanner) getFolderID(ctx context.Context, path string) (*models.FolderID, error) { // check the folder cache first if f, ok := s.folderPathToID.Load(path); ok { v := f.(models.FolderID) @@ -470,48 +143,17 @@ func (s *scanJob) getFolderID(ctx context.Context, path string) (*models.FolderI return &ret.ID, nil } -func (s *scanJob) getZipFileID(ctx context.Context, zipFile *scanFile) (*models.FileID, error) { - if zipFile == nil { - return nil, nil - } - - if zipFile.ID != 0 { - return &zipFile.ID, nil - } - - path := zipFile.Path - - // check the folder cache first - if f, ok := s.zipPathToID.Load(path); ok { - v := f.(models.FileID) - return &v, nil - } - - // assume case sensitive when searching for the zip file - const caseSensitive = true - - ret, err := s.Repository.File.FindByPath(ctx, path, caseSensitive) - if err != nil { - return nil, fmt.Errorf("getting zip file ID for %q: %w", path, err) - } - - if ret == nil { - return nil, fmt.Errorf("zip file %q doesn't exist in database", zipFile.Path) - } - - s.zipPathToID.Store(path, ret.Base().ID) - return &ret.Base().ID, nil -} - -func (s *scanJob) handleFolder(ctx context.Context, file scanFile) error { +// ScanFolder scans the provided folder into the database, returning the folder entry. +// If the folder already exists, it is updated if necessary. +func (s *Scanner) ScanFolder(ctx context.Context, file ScannedFile) (*models.Folder, error) { + var f *models.Folder + var err error path := file.Path - return s.withTxn(ctx, func(ctx context.Context) error { - defer s.incrementProgress(file) - + err = s.Repository.WithTxn(ctx, func(ctx context.Context) error { // determine if folder already exists in data store (by path) // assume case sensitive by default - f, err := s.Repository.Folder.FindByPath(ctx, path, true) + f, err = s.Repository.Folder.FindByPath(ctx, path, true) if err != nil { return fmt.Errorf("checking for existing folder %q: %w", path, err) } @@ -520,7 +162,7 @@ func (s *scanJob) handleFolder(ctx context.Context, file scanFile) error { // case insensitive searching // assume case sensitive if in zip if f == nil && file.ZipFileID == nil { - caseSensitive, _ := file.fs.IsPathCaseSensitive(file.Path) + caseSensitive, _ := file.FS.IsPathCaseSensitive(file.Path) if !caseSensitive { f, err = s.Repository.Folder.FindByPath(ctx, path, false) @@ -547,9 +189,11 @@ func (s *scanJob) handleFolder(ctx context.Context, file scanFile) error { return nil }) + + return f, err } -func (s *scanJob) onNewFolder(ctx context.Context, file scanFile) (*models.Folder, error) { +func (s *Scanner) onNewFolder(ctx context.Context, file ScannedFile) (*models.Folder, error) { renamed, err := s.handleFolderRename(ctx, file) if err != nil { return nil, err @@ -596,7 +240,7 @@ func (s *scanJob) onNewFolder(ctx context.Context, file scanFile) (*models.Folde return toCreate, nil } -func (s *scanJob) handleFolderRename(ctx context.Context, file scanFile) (*models.Folder, error) { +func (s *Scanner) handleFolderRename(ctx context.Context, file ScannedFile) (*models.Folder, error) { // ignore folders in zip files if file.ZipFileID != nil { return nil, nil @@ -637,7 +281,7 @@ func (s *scanJob) handleFolderRename(ctx context.Context, file scanFile) (*model return renamedFrom, nil } -func (s *scanJob) onExistingFolder(ctx context.Context, f scanFile, existing *models.Folder) (*models.Folder, error) { +func (s *Scanner) onExistingFolder(ctx context.Context, f ScannedFile, existing *models.Folder) (*models.Folder, error) { update := false // update if mod time is changed @@ -678,22 +322,22 @@ func (s *scanJob) onExistingFolder(ctx context.Context, f scanFile, existing *mo return existing, nil } -func modTime(info fs.FileInfo) time.Time { - // truncate to seconds, since we don't store beyond that in the database - return info.ModTime().Truncate(time.Second) +type ScanFileResult struct { + File models.File + New bool + Renamed bool + Updated bool } -func (s *scanJob) handleFile(ctx context.Context, f scanFile) error { - defer s.incrementProgress(f) - - var ff models.File +// ScanFile scans the provided file into the database, returning the scan result. +func (s *Scanner) ScanFile(ctx context.Context, f ScannedFile) (*ScanFileResult, error) { + var r *ScanFileResult // don't use a transaction to check if new or existing - if err := s.withDB(ctx, func(ctx context.Context) error { + if err := s.Repository.WithDB(ctx, func(ctx context.Context) error { // determine if file already exists in data store // assume case sensitive when searching for the file to begin with - var err error - ff, err = s.Repository.File.FindByPath(ctx, f.Path, true) + ff, err := s.Repository.File.FindByPath(ctx, f.Path, true) if err != nil { return fmt.Errorf("checking for existing file %q: %w", f.Path, err) } @@ -702,7 +346,7 @@ func (s *scanJob) handleFile(ctx context.Context, f scanFile) error { // case insensitive search // assume case sensitive if in zip if ff == nil && f.ZipFileID != nil { - caseSensitive, _ := f.fs.IsPathCaseSensitive(f.Path) + caseSensitive, _ := f.FS.IsPathCaseSensitive(f.Path) if !caseSensitive { ff, err = s.Repository.File.FindByPath(ctx, f.Path, false) @@ -714,35 +358,23 @@ func (s *scanJob) handleFile(ctx context.Context, f scanFile) error { if ff == nil { // returns a file only if it is actually new - ff, err = s.onNewFile(ctx, f) + r, err = s.onNewFile(ctx, f) return err } - ff, err = s.onExistingFile(ctx, f, ff) + r, err = s.onExistingFile(ctx, f, ff) return err }); err != nil { - return err + return nil, err } - if ff != nil && s.isZipFile(f.info.Name()) { - f.BaseFile = ff.Base() - - // scan zip files with a different context that is not cancellable - // cancelling while scanning zip file contents results in the scan - // contents being partially completed - zipCtx := context.WithoutCancel(ctx) - - if err := s.scanZipFile(zipCtx, f); err != nil { - logger.Errorf("Error scanning zip file %q: %v", f.Path, err) - } - } - - return nil + return r, nil } -func (s *scanJob) isZipFile(path string) bool { +// IsZipFile determines if the provided path is a zip file based on its extension. +func (s *Scanner) IsZipFile(path string) bool { fExt := filepath.Ext(path) - for _, ext := range s.options.ZipFileExtensions { + for _, ext := range s.ZipFileExtensions { if strings.EqualFold(fExt, "."+ext) { return true } @@ -751,7 +383,7 @@ func (s *scanJob) isZipFile(path string) bool { return false } -func (s *scanJob) onNewFile(ctx context.Context, f scanFile) (models.File, error) { +func (s *Scanner) onNewFile(ctx context.Context, f ScannedFile) (*ScanFileResult, error) { now := time.Now() baseFile := f.BaseFile @@ -767,28 +399,20 @@ func (s *scanJob) onNewFile(ctx context.Context, f scanFile) (models.File, error } if parentFolderID == nil { - // if parent folder doesn't exist, assume it's not yet created - // add this file to the queue to be created later - if s.retrying { - // if we're retrying and the folder still doesn't exist, then it's a problem - return nil, fmt.Errorf("parent folder for %q doesn't exist", path) - } - - s.retryList = append(s.retryList, f) - return nil, nil + return nil, fmt.Errorf("parent folder for %q doesn't exist", path) } baseFile.ParentFolderID = *parentFolderID const useExisting = false - fp, err := s.calculateFingerprints(f.fs, baseFile, path, useExisting) + fp, err := s.calculateFingerprints(f.FS, baseFile, path, useExisting) if err != nil { return nil, err } baseFile.SetFingerprints(fp) - file, err := s.fireDecorators(ctx, f.fs, baseFile) + file, err := s.fireDecorators(ctx, f.FS, baseFile) if err != nil { return nil, err } @@ -801,14 +425,17 @@ func (s *scanJob) onNewFile(ctx context.Context, f scanFile) (models.File, error } if renamed != nil { + return &ScanFileResult{ + File: renamed, + Renamed: true, + }, nil // handle rename should have already handled the contents of the zip file // so shouldn't need to scan it again // return nil so it doesn't - return nil, nil } // if not renamed, queue file for creation - if err := s.withTxn(ctx, func(ctx context.Context) error { + if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { if err := s.Repository.File.Create(ctx, file); err != nil { return fmt.Errorf("creating file %q: %w", path, err) } @@ -822,10 +449,13 @@ func (s *scanJob) onNewFile(ctx context.Context, f scanFile) (models.File, error return nil, err } - return file, nil + return &ScanFileResult{ + File: file, + New: true, + }, nil } -func (s *scanJob) fireDecorators(ctx context.Context, fs models.FS, f models.File) (models.File, error) { +func (s *Scanner) fireDecorators(ctx context.Context, fs models.FS, f models.File) (models.File, error) { for _, h := range s.FileDecorators { var err error f, err = h.Decorate(ctx, fs, f) @@ -837,8 +467,8 @@ func (s *scanJob) fireDecorators(ctx context.Context, fs models.FS, f models.Fil return f, nil } -func (s *scanJob) fireHandlers(ctx context.Context, f models.File, oldFile models.File) error { - for _, h := range s.handlers { +func (s *Scanner) fireHandlers(ctx context.Context, f models.File, oldFile models.File) error { + for _, h := range s.FileHandlers { if err := h.Handle(ctx, f, oldFile); err != nil { return err } @@ -847,7 +477,7 @@ func (s *scanJob) fireHandlers(ctx context.Context, f models.File, oldFile model return nil } -func (s *scanJob) calculateFingerprints(fs models.FS, f *models.BaseFile, path string, useExisting bool) (models.Fingerprints, error) { +func (s *Scanner) calculateFingerprints(fs models.FS, f *models.BaseFile, path string, useExisting bool) (models.Fingerprints, error) { // only log if we're (re)calculating fingerprints if !useExisting { logger.Infof("Calculating fingerprints for %s ...", path) @@ -884,7 +514,7 @@ func appendFileUnique(v []models.File, toAdd []models.File) []models.File { return v } -func (s *scanJob) getFileFS(f *models.BaseFile) (models.FS, error) { +func (s *Scanner) getFileFS(f *models.BaseFile) (models.FS, error) { if f.ZipFile == nil { return s.FS, nil } @@ -899,7 +529,7 @@ func (s *scanJob) getFileFS(f *models.BaseFile) (models.FS, error) { return fs.OpenZip(zipPath, zipSize) } -func (s *scanJob) handleRename(ctx context.Context, f models.File, fp []models.Fingerprint) (models.File, error) { +func (s *Scanner) handleRename(ctx context.Context, f models.File, fp []models.Fingerprint) (models.File, error) { var others []models.File for _, tfp := range fp { @@ -941,7 +571,7 @@ func (s *scanJob) handleRename(ctx context.Context, f models.File, fp []models.F // treat as a move missing = append(missing, other) } - case !s.acceptEntry(ctx, other.Base().Path, info): + case !s.AcceptEntry(ctx, other.Base().Path, info): // #4393 - if the file is no longer in the configured library paths, treat it as a move logger.Debugf("File %q no longer in library paths. Treating as a move.", other.Base().Path) missing = append(missing, other) @@ -974,12 +604,12 @@ func (s *scanJob) handleRename(ctx context.Context, f models.File, fp []models.F fBaseCopy.Fingerprints = updatedBase.Fingerprints *updatedBase = fBaseCopy - if err := s.withTxn(ctx, func(ctx context.Context) error { + if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { if err := s.Repository.File.Update(ctx, updated); err != nil { return fmt.Errorf("updating file for rename %q: %w", newPath, err) } - if s.isZipFile(updatedBase.Basename) { + if s.IsZipFile(updatedBase.Basename) { if err := transferZipHierarchy(ctx, s.Repository.Folder, s.Repository.File, updatedBase.ID, oldPath, newPath); err != nil { return fmt.Errorf("moving zip hierarchy for renamed zip file %q: %w", newPath, err) } @@ -997,9 +627,9 @@ func (s *scanJob) handleRename(ctx context.Context, f models.File, fp []models.F return updated, nil } -func (s *scanJob) isHandlerRequired(ctx context.Context, f models.File) bool { - accept := len(s.options.HandlerRequiredFilters) == 0 - for _, filter := range s.options.HandlerRequiredFilters { +func (s *Scanner) isHandlerRequired(ctx context.Context, f models.File) bool { + accept := len(s.HandlerRequiredFilters) == 0 + for _, filter := range s.HandlerRequiredFilters { // accept if any filter accepts the file if filter.Accept(ctx, f) { accept = true @@ -1018,9 +648,9 @@ func (s *scanJob) isHandlerRequired(ctx context.Context, f models.File) bool { // - file size // - image format, width or height // - video codec, audio codec, format, width, height, framerate or bitrate -func (s *scanJob) isMissingMetadata(ctx context.Context, f scanFile, existing models.File) bool { +func (s *Scanner) isMissingMetadata(ctx context.Context, f ScannedFile, existing models.File) bool { for _, h := range s.FileDecorators { - if h.IsMissingMetadata(ctx, f.fs, existing) { + if h.IsMissingMetadata(ctx, f.FS, existing) { return true } } @@ -1028,20 +658,20 @@ func (s *scanJob) isMissingMetadata(ctx context.Context, f scanFile, existing mo return false } -func (s *scanJob) setMissingMetadata(ctx context.Context, f scanFile, existing models.File) (models.File, error) { +func (s *Scanner) setMissingMetadata(ctx context.Context, f ScannedFile, existing models.File) (models.File, error) { path := existing.Base().Path logger.Infof("Updating metadata for %s", path) existing.Base().Size = f.Size var err error - existing, err = s.fireDecorators(ctx, f.fs, existing) + existing, err = s.fireDecorators(ctx, f.FS, existing) if err != nil { return nil, err } // queue file for update - if err := s.withTxn(ctx, func(ctx context.Context) error { + if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { if err := s.Repository.File.Update(ctx, existing); err != nil { return fmt.Errorf("updating file %q: %w", path, err) } @@ -1054,9 +684,9 @@ func (s *scanJob) setMissingMetadata(ctx context.Context, f scanFile, existing m return existing, nil } -func (s *scanJob) setMissingFingerprints(ctx context.Context, f scanFile, existing models.File) (models.File, error) { +func (s *Scanner) setMissingFingerprints(ctx context.Context, f ScannedFile, existing models.File) (models.File, error) { const useExisting = true - fp, err := s.calculateFingerprints(f.fs, existing.Base(), f.Path, useExisting) + fp, err := s.calculateFingerprints(f.FS, existing.Base(), f.Path, useExisting) if err != nil { return nil, err } @@ -1064,7 +694,7 @@ func (s *scanJob) setMissingFingerprints(ctx context.Context, f scanFile, existi if fp.ContentsChanged(existing.Base().Fingerprints) { existing.SetFingerprints(fp) - if err := s.withTxn(ctx, func(ctx context.Context) error { + if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { if err := s.Repository.File.Update(ctx, existing); err != nil { return fmt.Errorf("updating file %q: %w", f.Path, err) } @@ -1079,14 +709,14 @@ func (s *scanJob) setMissingFingerprints(ctx context.Context, f scanFile, existi } // returns a file only if it was updated -func (s *scanJob) onExistingFile(ctx context.Context, f scanFile, existing models.File) (models.File, error) { +func (s *Scanner) onExistingFile(ctx context.Context, f ScannedFile, existing models.File) (*ScanFileResult, error) { base := existing.Base() path := base.Path fileModTime := f.ModTime // #6326 - also force a rescan if the basename changed updated := !fileModTime.Equal(base.ModTime) || base.Basename != f.Basename - forceRescan := s.options.Rescan + forceRescan := s.Rescan if !updated && !forceRescan { return s.onUnchangedFile(ctx, f, existing) @@ -1108,7 +738,7 @@ func (s *scanJob) onExistingFile(ctx context.Context, f scanFile, existing model // calculate and update fingerprints for the file const useExisting = false - fp, err := s.calculateFingerprints(f.fs, base, path, useExisting) + fp, err := s.calculateFingerprints(f.FS, base, path, useExisting) if err != nil { return nil, err } @@ -1116,13 +746,13 @@ func (s *scanJob) onExistingFile(ctx context.Context, f scanFile, existing model s.removeOutdatedFingerprints(existing, fp) existing.SetFingerprints(fp) - existing, err = s.fireDecorators(ctx, f.fs, existing) + existing, err = s.fireDecorators(ctx, f.FS, existing) if err != nil { return nil, err } // queue file for update - if err := s.withTxn(ctx, func(ctx context.Context) error { + if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { if err := s.Repository.File.Update(ctx, existing); err != nil { return fmt.Errorf("updating file %q: %w", path, err) } @@ -1135,11 +765,13 @@ func (s *scanJob) onExistingFile(ctx context.Context, f scanFile, existing model }); err != nil { return nil, err } - - return existing, nil + return &ScanFileResult{ + File: existing, + Updated: true, + }, nil } -func (s *scanJob) removeOutdatedFingerprints(existing models.File, fp models.Fingerprints) { +func (s *Scanner) removeOutdatedFingerprints(existing models.File, fp models.Fingerprints) { // HACK - if no MD5 fingerprint was returned, and the oshash is changed // then remove the MD5 fingerprint oshash := fp.For(models.FingerprintTypeOshash) @@ -1167,7 +799,7 @@ func (s *scanJob) removeOutdatedFingerprints(existing models.File, fp models.Fin } // returns a file only if it was updated -func (s *scanJob) onUnchangedFile(ctx context.Context, f scanFile, existing models.File) (models.File, error) { +func (s *Scanner) onUnchangedFile(ctx context.Context, f ScannedFile, existing models.File) (*ScanFileResult, error) { var err error isMissingMetdata := s.isMissingMetadata(ctx, f, existing) @@ -1186,7 +818,7 @@ func (s *scanJob) onUnchangedFile(ctx context.Context, f scanFile, existing mode } handlerRequired := false - if err := s.withDB(ctx, func(ctx context.Context) error { + if err := s.Repository.WithDB(ctx, func(ctx context.Context) error { // check if the handler needs to be run handlerRequired = s.isHandlerRequired(ctx, existing) return nil @@ -1196,15 +828,20 @@ func (s *scanJob) onUnchangedFile(ctx context.Context, f scanFile, existing mode if !handlerRequired { // if this file is a zip file, then we need to rescan the contents - // as well. We do this by returning the file, instead of nil. + // as well. We do this by indicating that the file is updated. if isMissingMetdata { - return existing, nil + return &ScanFileResult{ + File: existing, + Updated: true, + }, nil } - return nil, nil + return &ScanFileResult{ + File: existing, + }, nil } - if err := s.withTxn(ctx, func(ctx context.Context) error { + if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { if err := s.fireHandlers(ctx, existing, nil); err != nil { return err } @@ -1215,6 +852,9 @@ func (s *scanJob) onUnchangedFile(ctx context.Context, f scanFile, existing mode } // if this file is a zip file, then we need to rescan the contents - // as well. We do this by returning the file, instead of nil. - return existing, nil + // as well. We do this by indicating that the file is updated. + return &ScanFileResult{ + File: existing, + Updated: true, + }, nil } diff --git a/pkg/file/walk.go b/pkg/file/walk.go index 3c6a157b7..bd33f42c3 100644 --- a/pkg/file/walk.go +++ b/pkg/file/walk.go @@ -81,8 +81,8 @@ func walkSym(f models.FS, filename string, linkDirname string, walkFn fs.WalkDir return fsWalk(f, filename, symWalkFunc) } -// symWalk extends filepath.Walk to also follow symlinks -func symWalk(fs models.FS, path string, walkFn fs.WalkDirFunc) error { +// SymWalk extends filepath.Walk to also follow symlinks +func SymWalk(fs models.FS, path string, walkFn fs.WalkDirFunc) error { return walkSym(fs, path, path, walkFn) } diff --git a/pkg/file/zip.go b/pkg/file/zip.go index 4df2453dc..5afcd5329 100644 --- a/pkg/file/zip.go +++ b/pkg/file/zip.go @@ -18,7 +18,7 @@ import ( ) var ( - errNotReaderAt = errors.New("not a ReaderAt") + ErrNotReaderAt = errors.New("invalid reader: does not implement io.ReaderAt") errZipFSOpenZip = errors.New("cannot open zip file inside zip file") ) @@ -38,7 +38,7 @@ func newZipFS(fs models.FS, path string, size int64) (*zipFS, error) { asReaderAt, _ := reader.(io.ReaderAt) if asReaderAt == nil { reader.Close() - return nil, errNotReaderAt + return nil, ErrNotReaderAt } zipReader, err := zip.NewReader(asReaderAt, size) From fe85b1eff98d9cbf2ae62e4545a2809ebb5ab999 Mon Sep 17 00:00:00 2001 From: Valkyr-JS <154020147+Valkyr-JS@users.noreply.github.com> Date: Tue, 27 Jan 2026 06:42:58 +0000 Subject: [PATCH 042/177] Image count added to gallery data fragment (#6527) --- ui/v2.5/graphql/data/gallery.graphql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/graphql/data/gallery.graphql b/ui/v2.5/graphql/data/gallery.graphql index c41f3e2b2..89f3ed44c 100644 --- a/ui/v2.5/graphql/data/gallery.graphql +++ b/ui/v2.5/graphql/data/gallery.graphql @@ -22,7 +22,7 @@ fragment GalleryData on Gallery { folder { ...FolderData } - + image_count chapters { ...GalleryChapterData } From 0e54a5ceb0840327bc30730b1e3c7be3b96184fa Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Tue, 27 Jan 2026 08:53:39 +0200 Subject: [PATCH 043/177] docs: add warning emojis to important notes across multiple documentation files (#6531) --- ui/v2.5/src/docs/en/Manual/AutoTagging.md | 2 +- ui/v2.5/src/docs/en/Manual/Captions.md | 2 +- ui/v2.5/src/docs/en/Manual/Configuration.md | 6 +++--- ui/v2.5/src/docs/en/Manual/Deduplication.md | 6 ++++-- ui/v2.5/src/docs/en/Manual/EmbeddedPlugins.md | 4 +++- ui/v2.5/src/docs/en/Manual/Images.md | 2 +- ui/v2.5/src/docs/en/Manual/Introduction.md | 4 +++- ui/v2.5/src/docs/en/Manual/JSONSpec.md | 2 +- ui/v2.5/src/docs/en/Manual/Plugins.md | 2 +- .../src/docs/en/Manual/ScraperDevelopment.md | 17 +++++++++-------- ui/v2.5/src/docs/en/Manual/Tagger.md | 4 ++-- 11 files changed, 29 insertions(+), 22 deletions(-) diff --git a/ui/v2.5/src/docs/en/Manual/AutoTagging.md b/ui/v2.5/src/docs/en/Manual/AutoTagging.md index 4b1cbb813..4c4265e19 100644 --- a/ui/v2.5/src/docs/en/Manual/AutoTagging.md +++ b/ui/v2.5/src/docs/en/Manual/AutoTagging.md @@ -6,7 +6,7 @@ This task is part of the advanced settings mode. ## Rules -> **Important:** Auto Tag only works for names that already exist in your Stash database. It does not create new Performers, Studios, or Tags. +> **⚠️ Important:** Auto Tag only works for names that already exist in your Stash database. It does not create new Performers, Studios, or Tags. - 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. diff --git a/ui/v2.5/src/docs/en/Manual/Captions.md b/ui/v2.5/src/docs/en/Manual/Captions.md index df2bee8bc..4e3849fac 100644 --- a/ui/v2.5/src/docs/en/Manual/Captions.md +++ b/ui/v2.5/src/docs/en/Manual/Captions.md @@ -15,4 +15,4 @@ Where `{language_code}` is defined by the [ISO-6399-1](https://en.wikipedia.org/ Scenes with captions can be filtered with the `captions` criterion. -**Note:** If the caption file was added after the scene was initially added during scan, you will need to run a Selective Scan task for it to show up. +> **⚠️ Note:** If the caption file was added after the scene was initially added during scan, you will need to run a Selective Scan task for it to show up. diff --git a/ui/v2.5/src/docs/en/Manual/Configuration.md b/ui/v2.5/src/docs/en/Manual/Configuration.md index 76464facf..d5841b559 100644 --- a/ui/v2.5/src/docs/en/Manual/Configuration.md +++ b/ui/v2.5/src/docs/en/Manual/Configuration.md @@ -31,7 +31,7 @@ Some examples: - `"^/stash/videos/exclude/"` will exclude all directories that match `/stash/videos/exclude/` pattern. - `"\\\\stash\\network\\share\\excl\\"` will exclude specific Windows network path `\\stash\network\share\excl\`. -> **Note:** If a directory is excluded for images and videos, then the directory will be excluded from scans completely. +> **⚠️ Note:** If a directory is excluded for images and videos, then the directory will be excluded from scans completely. _There is a useful [regex101](https://regex101.com/) site that can help test and experiment with regexps._ @@ -87,7 +87,7 @@ This setting can be used to increase/decrease overall CPU utilisation in two sce 1. High performance 4+ core cpus. 2. Media files stored on remote/cloud filesystem. -Note: If this is set too high it will decrease overall performance and causes failures (out of memory). +> **⚠️ Note:** If this is set too high it will decrease overall performance and causes failures (out of memory). ## Hardware accelerated live transcoding @@ -117,7 +117,7 @@ Some scrapers require a Chrome instance to function correctly. If left empty, st `Chrome CDP path` can be set to a path to the chrome executable, or an http(s) address to remote chrome instance (for example: `http://localhost:9222/json/version`). -> **Important**: As of Chrome 136 you need to specify `--user-data-dir` alongside `--remote-debugging-port`. Read more on their [official post](https://developer.chrome.com/blog/remote-debugging-port). +> **⚠️ Important:** As of Chrome 136 you need to specify `--user-data-dir` alongside `--remote-debugging-port`. Read more on their [official post](https://developer.chrome.com/blog/remote-debugging-port). ## Authentication diff --git a/ui/v2.5/src/docs/en/Manual/Deduplication.md b/ui/v2.5/src/docs/en/Manual/Deduplication.md index 24c0fb391..d842fcc68 100644 --- a/ui/v2.5/src/docs/en/Manual/Deduplication.md +++ b/ui/v2.5/src/docs/en/Manual/Deduplication.md @@ -2,8 +2,10 @@ [The dupe checker](/sceneDuplicateChecker) searches your collection for scenes that are perceptually similar. This means that the files don't need to be identical, and will be identified even with different bitrates, resolutions, and intros/outros. -To achieve this stash needs to generate what's called a phash, or perceptual hash. Similar to sprite generation stash will generate a set of 25 images from fixed points in the scene. These images will be stitched together, and then hashed using the phash algorithm. The phash can then be used to find scenes that are the same or similar to others in the database. Phash generation can be run during scan, or as a separate task. Note that generation can take a while due to the work involved with extracting screenshots. +To achieve this stash needs to generate what's called a phash, or perceptual hash. Similar to sprite generation stash will generate a set of 25 images from fixed points in the scene. These images will be stitched together, and then hashed using the phash algorithm. The phash can then be used to find scenes that are the same or similar to others in the database. Phash generation can be run during scan, or as a separate task. + +> **⚠️ Note:** Generation can take a while due to the work involved with extracting screenshots. The dupe checker can be run with four different levels of accuracy. `Exact` looks for scenes that have exactly the same phash. This is a fast and accurate operation that should not yield any false positives except in very rare cases. The other accuracy levels look for duplicate files within a set distance of each other. This means the scenes don't have exactly the same phash, but are very similar. `High` and `Medium` should still yield very good results with few or no false positives. `Low` is likely to produce some false positives, but might still be useful for finding dupes. -Note that to generate a phash stash requires an uncorrupted file. If any errors are encountered during sprite generation the phash will not be generated. This is to prevent false positives. +> **⚠️ Note:** To generate a pHash Stash requires an uncorrupted file. If any errors are encountered during sprite generation the pHash will not be generated. This is to prevent false positives. \ No newline at end of file diff --git a/ui/v2.5/src/docs/en/Manual/EmbeddedPlugins.md b/ui/v2.5/src/docs/en/Manual/EmbeddedPlugins.md index 1fc217ffc..9d54010e6 100644 --- a/ui/v2.5/src/docs/en/Manual/EmbeddedPlugins.md +++ b/ui/v2.5/src/docs/en/Manual/EmbeddedPlugins.md @@ -10,7 +10,9 @@ Stash currently supports Javascript embedded plugin tasks using [goja](https://g ### Plugin input -The input is provided to Javascript plugin tasks using the `input` global variable, and is an object based on the structure provided in the `Plugin input` section of the [Plugins](/help/Plugins.md) page. Note that the `server_connection` field should not be necessary in most embedded plugins. +The input is provided to Javascript plugin tasks using the `input` global variable, and is an object based on the structure provided in the `Plugin input` section of the [Plugins](/help/Plugins.md) page. + +> **⚠️ Note:** `server_connection` field should not be necessary in most embedded plugins. ### Plugin output diff --git a/ui/v2.5/src/docs/en/Manual/Images.md b/ui/v2.5/src/docs/en/Manual/Images.md index ede9b3457..f08f5241c 100644 --- a/ui/v2.5/src/docs/en/Manual/Images.md +++ b/ui/v2.5/src/docs/en/Manual/Images.md @@ -11,7 +11,7 @@ You can add images to every gallery manually in the gallery detail page. Deletin For best results, images in zip file should be stored without compression (copy, store or no compression options depending on the software you use. Eg on linux: `zip -0 -r gallery.zip foldertozip/`). This impacts **heavily** on the zip read performance. -> **:warning: Note:** AVIF files in ZIP archives are currently unsupported. +> **⚠️ Note:** AVIF files in ZIP archives are currently unsupported. If a filename of an image in the gallery zip file ends with `cover.jpg`, it will be treated like a cover and presented first in the gallery view page and as a gallery cover in the gallery list view. If more than one images match the name the first one found in natural sort order is selected. diff --git a/ui/v2.5/src/docs/en/Manual/Introduction.md b/ui/v2.5/src/docs/en/Manual/Introduction.md index 1496ad2b1..f32b84681 100644 --- a/ui/v2.5/src/docs/en/Manual/Introduction.md +++ b/ui/v2.5/src/docs/en/Manual/Introduction.md @@ -2,6 +2,8 @@ Stash works by cataloging your media using the paths that you provide. Once you have [configured](/settings?tab=library) the locations where your media is stored, you can click the Scan button in [`Settings -> Tasks`](/settings?tab=tasks) and stash will begin scanning and importing your media into its library. -For the best experience, it is recommended that after a scan is finished, that video previews and sprites are generated. You can do this in [`Settings -> Tasks`](/settings?tab=tasks). Note that currently it is only possible to perform one task at a time and but there is a task queue, so the generate tasks should be performed after scan is complete. +For the best experience, it is recommended that after a scan is finished, that video previews and sprites are generated. You can do this in [`Settings -> Tasks`](/settings?tab=tasks). + +> **⚠️ Note:** Currently it is only possible to perform one task at a time and but there is a task queue, so the generate tasks should be performed after scan is complete. Once your media is imported, you are ready to begin creating Performers, Studios and Tags, and curating your content! \ No newline at end of file diff --git a/ui/v2.5/src/docs/en/Manual/JSONSpec.md b/ui/v2.5/src/docs/en/Manual/JSONSpec.md index 0a53d09f2..b071f26cc 100644 --- a/ui/v2.5/src/docs/en/Manual/JSONSpec.md +++ b/ui/v2.5/src/docs/en/Manual/JSONSpec.md @@ -24,7 +24,7 @@ When exported, files are named with different formats depending on the object ty | Studios | `.json` | | Groups | `.json` | -Note that the file naming is not significant when importing. All json files will be read from the subdirectories. +> **⚠️ Note:** The file naming is not significant when importing. All json files will be read from the subdirectories. ## Content of the json files diff --git a/ui/v2.5/src/docs/en/Manual/Plugins.md b/ui/v2.5/src/docs/en/Manual/Plugins.md index cd24e0d4a..5e403af92 100644 --- a/ui/v2.5/src/docs/en/Manual/Plugins.md +++ b/ui/v2.5/src/docs/en/Manual/Plugins.md @@ -240,7 +240,7 @@ hooks: argKey: argValue ``` -**Note:** it is possible for hooks to trigger eachother or themselves if they perform mutations. For safety, hooks will not be triggered if they have already been triggered in the context of the operation. Stash uses cookies to track this context, so it's important for plugins to send cookies when performing operations. +**⚠️ Note:** It is possible for hooks to trigger eachother or themselves if they perform mutations. For safety, hooks will not be triggered if they have already been triggered in the context of the operation. Stash uses cookies to track this context, so it's important for plugins to send cookies when performing operations. #### Trigger types diff --git a/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md b/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md index 1f52028f8..4c97e3fcf 100644 --- a/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md +++ b/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md @@ -375,7 +375,7 @@ scene: selector: //div[@data-host="{inputHostname}"]//span[@class="site-name"] ``` -> **Note:** These placeholders represent the actual URL used to fetch the content, after any URL replacements have been applied. +> **⚠️ Note:** These placeholders represent the actual URL used to fetch the content, after any URL replacements have been applied. ### Common fragments @@ -391,6 +391,7 @@ performer: The `Measurements` xpath string will replace `$infoPiece` with `//div[@class="infoPiece"]/span`, resulting in: `//div[@class="infoPiece"]/span[text() = 'Measurements:']/../span[@class="smallInfo"]`. > **⚠️ Note:** Recursive common fragments are **not** supported. + Referencing a common fragment within another common fragment will cause an error. For example: ```yaml common: @@ -881,7 +882,7 @@ Title URLs ``` -> **Important**: `Title` field is required. +> **⚠️ Important:** `Title` field is required. ### Group @@ -900,7 +901,7 @@ Tags (see Tag fields) URLs ``` -> **Important**: `Name` field is required. +> **⚠️ Important:** `Name` field is required. ### Image @@ -944,9 +945,9 @@ URLs Weight ``` -> **Important**: `Name` field is required. +> **⚠️ Important:** `Name` field is required. -> **Note:** - `Gender` must be one of `male`, `female`, `transgender_male`, `transgender_female`, `intersex`, `non_binary` (case insensitive). +> **⚠️ Note:** `Gender` must be one of `male`, `female`, `transgender_male`, `transgender_female`, `intersex`, `non_binary` (case insensitive). ### Scene @@ -964,7 +965,7 @@ Title URLs ``` -> **Important**: `Title` field is required only if fileless. +> **⚠️ Important:** `Title` field is required only if fileless. ### Studio @@ -976,7 +977,7 @@ Tags (see Tag fields) URL ``` -> **Important**: `Name` field is required. +> **⚠️ Important:** `Name` field is required. ### Tag @@ -984,4 +985,4 @@ URL Name ``` -> **Important**: `Name` field is required. +> **⚠️ Important:** `Name` field is required. diff --git a/ui/v2.5/src/docs/en/Manual/Tagger.md b/ui/v2.5/src/docs/en/Manual/Tagger.md index ba9e5f17a..7c2d12a87 100644 --- a/ui/v2.5/src/docs/en/Manual/Tagger.md +++ b/ui/v2.5/src/docs/en/Manual/Tagger.md @@ -4,9 +4,9 @@ Stash can be integrated with stash-box which acts as a centralized metadata data ## Searching -The fingerprint search matches your current selection of files against the remote stash-box instance. Any scenes with a matching fingerprint will be returned, although there is currently no validation of fingerprints so it’s recommended to double-check the validity before saving. +The fingerprint search matches your current selection of files against the remote stash-box instance. Any scenes with a matching fingerprint will be returned, although there is currently no validation of fingerprints so it's recommended to double-check the validity before saving. -If no fingerprint match is found it’s possible to search by keywords. The search works by matching the query against a scene’s _title_, _release date_, _studio name_, and _performer names_. By default the tagger uses metadata set on the file, or parses the filename, this can be changed in the config. +If no fingerprint match is found it's possible to search by keywords. The search works by matching the query against a scene's _title_, _release date_, _studio name_, and _performer names_. By default the tagger uses metadata set on the file, or parses the filename, this can be changed in the config. An important thing to note is that it only returns a match *if all query terms are a match*. As an example, if a scene is titled `"A Trip to the Mall"` with the performer `"Jane Doe"`, a search for `"Trip to the Mall 1080p"` will *not* match, however `"trip mall doe"` would. Usually a few pieces of info is enough, for instance performer name + release date or studio name. To avoid common non-related keywords you can add them to the blacklist in the tagger config. Any items in the blacklist are stripped out of the query. From badf9ec35e54f51582593101e242cf49018d46a0 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:24:08 -0800 Subject: [PATCH 044/177] add cover check (#6542) --- internal/api/resolver_mutation_scene.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/api/resolver_mutation_scene.go b/internal/api/resolver_mutation_scene.go index 6ac5b0227..5347de806 100644 --- a/internal/api/resolver_mutation_scene.go +++ b/internal/api/resolver_mutation_scene.go @@ -624,7 +624,12 @@ func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput return fmt.Errorf("scene with id %d not found", destID) } - return r.sceneUpdateCoverImage(ctx, ret, coverImageData) + // only update cover image if one was provided + if len(coverImageData) > 0 { + return r.sceneUpdateCoverImage(ctx, ret, coverImageData) + } + + return nil }); err != nil { return nil, err } From b76edffc5dc82b0cad8eb5bcb24a3e679d1b45b4 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:34:56 -0800 Subject: [PATCH 045/177] FR: Add Generate Task to Galleries (#6442) --- graphql/schema/types/metadata.graphql | 6 ++-- internal/manager/task_generate.go | 28 +++++++++++++++++-- .../src/components/Dialogs/GenerateDialog.tsx | 21 ++++++++++---- .../Galleries/GalleryDetails/Gallery.tsx | 25 +++++++++++++++++ .../src/components/Galleries/GalleryList.tsx | 22 +++++++++++++++ .../Settings/Tasks/GenerateOptions.tsx | 4 +-- 6 files changed, 93 insertions(+), 13 deletions(-) diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index 66b74bb86..3d004ccb3 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -20,10 +20,12 @@ input GenerateMetadataInput { "scene ids to generate for" sceneIDs: [ID!] - "image ids to generate for" - imageIDs: [ID!] "marker ids to generate for" markerIDs: [ID!] + "image ids to generate for" + imageIDs: [ID!] + "gallery ids to generate for" + galleryIDs: [ID!] "overwrite existing media" overwrite: Boolean diff --git a/internal/manager/task_generate.go b/internal/manager/task_generate.go index 5631db87b..2b330bcf3 100644 --- a/internal/manager/task_generate.go +++ b/internal/manager/task_generate.go @@ -35,10 +35,12 @@ type GenerateMetadataInput struct { ImageThumbnails bool `json:"imageThumbnails"` // scene ids to generate for SceneIDs []string `json:"sceneIDs"` - // image ids to generate for - ImageIDs []string `json:"imageIDs"` // marker ids to generate for MarkerIDs []string `json:"markerIDs"` + // image ids to generate for + ImageIDs []string `json:"imageIDs"` + // gallery ids to generate for + GalleryIDs []string `json:"galleryIDs"` // overwrite existing media Overwrite bool `json:"overwrite"` } @@ -114,6 +116,10 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error if err != nil { logger.Error(err.Error()) } + galleryIDs, err := stringslice.StringSliceToIntSlice(j.input.GalleryIDs) + if err != nil { + logger.Error(err.Error()) + } g := &generate.Generator{ Encoder: instance.FFMpeg, @@ -127,7 +133,7 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error r := j.repository if err := r.WithReadTxn(ctx, func(ctx context.Context) error { qb := r.Scene - if len(j.input.SceneIDs) == 0 && len(j.input.MarkerIDs) == 0 && len(j.input.ImageIDs) == 0 { + if len(j.input.SceneIDs) == 0 && len(j.input.MarkerIDs) == 0 && len(j.input.ImageIDs) == 0 && len(j.input.GalleryIDs) == 0 { j.queueTasks(ctx, g, queue) } else { if len(j.input.SceneIDs) > 0 { @@ -161,6 +167,22 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error j.queueImageJob(g, i, queue) } } + + if len(j.input.GalleryIDs) > 0 { + for _, galleryID := range galleryIDs { + imgs, err := r.Image.FindByGalleryID(ctx, galleryID) + if err != nil { + return err + } + for _, img := range imgs { + if err := img.LoadFiles(ctx, r.Image); err != nil { + return err + } + + j.queueImageJob(g, img, queue) + } + } + } } return nil diff --git a/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx b/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx index c30073e48..a5688aed0 100644 --- a/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx +++ b/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx @@ -14,19 +14,20 @@ import { SettingSection } from "../Settings/SettingSection"; import { faCogs, faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; import { SettingsContext } from "../Settings/context"; -interface ISceneGenerateDialog { +interface IGenerateDialog { selectedIds?: string[]; onClose: () => void; - type: "scene" | "image"; + type: "scene" | "image" | "gallery"; } -export const GenerateDialog: React.FC = ({ +export const GenerateDialog: React.FC = ({ selectedIds, onClose, type, }) => { const sceneIDs = type === "scene" ? selectedIds : undefined; const imageIDs = type === "image" ? selectedIds : undefined; + const galleryIDs = type === "gallery" ? selectedIds : undefined; const { configuration } = useConfigurationContext(); @@ -92,6 +93,13 @@ export const GenerateDialog: React.FC = ({ }, [configuration, configRead]); const selectionStatus = useMemo(() => { + const countableIds: Record = { + scene: "countables.scenes", + image: "countables.images", + gallery: "countables.galleries", + }; + const countableId = countableIds[type]; + if (selectedIds) { return ( @@ -101,7 +109,7 @@ export const GenerateDialog: React.FC = ({ num: selectedIds.length, scene: intl.formatMessage( { - id: "countables.scenes", + id: countableId, }, { count: selectedIds.length, @@ -121,7 +129,7 @@ export const GenerateDialog: React.FC = ({ num: intl.formatMessage({ id: "all" }), scene: intl.formatMessage( { - id: "countables.scenes", + id: countableId, }, { count: 0, @@ -138,7 +146,7 @@ export const GenerateDialog: React.FC = ({
{message}
); - }, [selectedIds, intl]); + }, [selectedIds, intl, type]); async function onGenerate() { try { @@ -146,6 +154,7 @@ export const GenerateDialog: React.FC = ({ ...options, sceneIDs, imageIDs, + galleryIDs, }); Toast.success( intl.formatMessage( diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx index 195766e03..18cbeff96 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx @@ -15,6 +15,11 @@ import { useFindGallery, useGalleryUpdate, } from "src/core/StashService"; +import { lazyComponent } from "src/utils/lazyComponent"; + +const GenerateDialog = lazyComponent( + () => import("../../Dialogs/GenerateDialog") +); import { ErrorMessage } from "src/components/Shared/ErrorMessage"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { Icon } from "src/components/Shared/Icon"; @@ -165,6 +170,7 @@ export const GalleryPage: React.FC = ({ gallery, add }) => { } const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); + const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false); function onDeleteDialogClosed(deleted: boolean) { setIsDeleteAlertOpen(false); @@ -184,6 +190,18 @@ export const GalleryPage: React.FC = ({ gallery, add }) => { } } + function maybeRenderGenerateDialog() { + if (isGenerateDialogOpen) { + return ( + setIsGenerateDialogOpen(false)} + type="gallery" + /> + ); + } + } + function renderOperations() { return ( @@ -210,6 +228,12 @@ export const GalleryPage: React.FC = ({ gallery, add }) => { >
+ setIsGenerateDialogOpen(true)} + > + {`${intl.formatMessage({ id: "actions.generate" })}…`} + setIsDeleteAlertOpen(true)} @@ -387,6 +411,7 @@ export const GalleryPage: React.FC = ({ gallery, add }) => { {title} {maybeRenderDeleteDialog()} + {maybeRenderGenerateDialog()}
diff --git a/ui/v2.5/src/components/Galleries/GalleryList.tsx b/ui/v2.5/src/components/Galleries/GalleryList.tsx index 9a4fc5236..4afbab620 100644 --- a/ui/v2.5/src/components/Galleries/GalleryList.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryList.tsx @@ -12,11 +12,13 @@ import GalleryWallCard from "./GalleryWallCard"; import { EditGalleriesDialog } from "./EditGalleriesDialog"; import { DeleteGalleriesDialog } from "./DeleteGalleriesDialog"; import { ExportDialog } from "../Shared/ExportDialog"; +import { GenerateDialog } from "../Dialogs/GenerateDialog"; import { GalleryListTable } from "./GalleryListTable"; import { GalleryCardGrid } from "./GalleryCardGrid"; import { View } from "../List/views"; import { PatchComponent } from "src/patch"; import { IItemListOperation } from "../List/FilteredListToolbar"; +import { useModal } from "src/hooks/modal"; function getItems(result: GQL.FindGalleriesQueryResult) { return result?.data?.findGalleries?.galleries ?? []; @@ -40,6 +42,7 @@ export const GalleryList: React.FC = PatchComponent( const history = useHistory(); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportAll, setIsExportAll] = useState(false); + const { modal, showModal, closeModal } = useModal(); const filterMode = GQL.FilterMode.Galleries; @@ -49,6 +52,24 @@ export const GalleryList: React.FC = PatchComponent( text: intl.formatMessage({ id: "actions.view_random" }), onClick: viewRandom, }, + { + text: `${intl.formatMessage({ id: "actions.generate" })}…`, + onClick: ( + _result: GQL.FindGalleriesQueryResult, + _filter: ListFilterModel, + selectedIds: Set + ) => { + showModal( + closeModal()} + /> + ); + return Promise.resolve(); + }, + isDisplayed: showWhenSelected, + }, { text: intl.formatMessage({ id: "actions.export" }), onClick: onExport, @@ -172,6 +193,7 @@ export const GalleryList: React.FC = PatchComponent( return ( <> {maybeRenderGalleryExportDialog()} + {modal} {renderGalleries()} ); diff --git a/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx b/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx index 24be2c7fe..c68b6d5eb 100644 --- a/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx @@ -7,7 +7,7 @@ import { } from "../GeneratePreviewOptions"; interface IGenerateOptions { - type?: "scene" | "image"; + type?: "scene" | "image" | "gallery"; selection?: boolean; options: GQL.GenerateMetadataInput; setOptions: (s: GQL.GenerateMetadataInput) => void; @@ -27,7 +27,7 @@ export const GenerateOptions: React.FC = ({ } const showSceneOptions = !type || type === "scene"; - const showImageOptions = !type || type === "image"; + const showImageOptions = !type || type === "image" || type === "gallery"; return ( <> From cf5d60f51181b45481b3d2cd36ba1a2587ac8bbc Mon Sep 17 00:00:00 2001 From: GammelSami <39372285+GammelSami@users.noreply.github.com> Date: Wed, 4 Feb 2026 00:18:39 +0100 Subject: [PATCH 046/177] Added loop feature for markers + AB prefill (#6510) * add loop feature for markers + AB prefill * chore(ui): type ab loop plugin access --- ui/v2.5/src/components/ScenePlayer/util.ts | 22 +++++++- .../Scenes/SceneDetails/PrimaryTags.tsx | 25 ++++++++-- .../components/Scenes/SceneDetails/Scene.tsx | 50 ++++++++++++++++++- .../Scenes/SceneDetails/SceneMarkerForm.tsx | 38 +++++++++++--- .../Scenes/SceneDetails/SceneMarkersPanel.tsx | 3 ++ 5 files changed, 126 insertions(+), 12 deletions(-) diff --git a/ui/v2.5/src/components/ScenePlayer/util.ts b/ui/v2.5/src/components/ScenePlayer/util.ts index 8c6fb8010..21ed99b62 100644 --- a/ui/v2.5/src/components/ScenePlayer/util.ts +++ b/ui/v2.5/src/components/ScenePlayer/util.ts @@ -1,7 +1,27 @@ -import videojs from "video.js"; +import videojs, { VideoJsPlayer } from "video.js"; export const VIDEO_PLAYER_ID = "VideoJsPlayer"; export const getPlayer = () => videojs.getPlayer(VIDEO_PLAYER_ID); export const getPlayerPosition = () => getPlayer()?.currentTime(); + +export type AbLoopOptions = { + start: number; + end: number | false; + enabled?: boolean; +}; + +export type AbLoopPluginApi = { + getOptions: () => AbLoopOptions; + setOptions: (options: AbLoopOptions) => void; +}; + +export const getAbLoopPlugin = () => { + const player = getPlayer(); + if (!player) return null; + const { abLoopPlugin } = player as VideoJsPlayer & { + abLoopPlugin?: AbLoopPluginApi; + }; + return abLoopPlugin ?? null; +}; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx index 11c805ec6..d5a32fc31 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx @@ -4,18 +4,24 @@ import * as GQL from "src/core/generated-graphql"; import { Button, Badge, Card } from "react-bootstrap"; import TextUtils from "src/utils/text"; import { markerTitle } from "src/core/markers"; +import { useConfigurationContext } from "src/hooks/Config"; interface IPrimaryTags { sceneMarkers: GQL.SceneMarkerDataFragment[]; onClickMarker: (marker: GQL.SceneMarkerDataFragment) => void; + onLoopMarker: (marker: GQL.SceneMarkerDataFragment) => void; onEdit: (marker: GQL.SceneMarkerDataFragment) => void; } export const PrimaryTags: React.FC = ({ sceneMarkers, onClickMarker, + onLoopMarker, onEdit, }) => { + const { configuration } = useConfigurationContext(); + const showAbLoopControls = configuration?.ui?.showAbLoopControls; + if (!sceneMarkers?.length) return
; const primaryTagNames: Record = {}; @@ -52,10 +58,21 @@ export const PrimaryTags: React.FC = ({
-
- {TextUtils.formatTimestampRange( - marker.seconds, - marker.end_seconds ?? undefined +
+
+ {TextUtils.formatTimestampRange( + marker.seconds, + marker.end_seconds ?? undefined + )} +
+ {showAbLoopControls && marker.end_seconds != null && ( + )}
{tags}
diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index 7c9b178c1..435b9dce2 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -32,7 +32,10 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import Mousetrap from "mousetrap"; import { OrganizedButton } from "./OrganizedButton"; import { useConfigurationContext } from "src/hooks/Config"; -import { getPlayerPosition } from "src/components/ScenePlayer/util"; +import { + getAbLoopPlugin, + getPlayerPosition, +} from "src/components/ScenePlayer/util"; import { faEllipsisV, faChevronRight, @@ -311,9 +314,53 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { }; function onClickMarker(marker: GQL.SceneMarkerDataFragment) { + const abLoopPlugin = getAbLoopPlugin(); + const opts = abLoopPlugin?.getOptions(); + const start = opts?.start; + const end = opts?.end; + + const hasLoopRange = + opts?.enabled && + typeof start === "number" && + typeof end === "number" && + Number.isFinite(start) && + Number.isFinite(end); + + if ( + abLoopPlugin && + opts && + hasLoopRange && + (marker.seconds < Math.min(start as number, end as number) || + marker.seconds > Math.max(start as number, end as number)) + ) { + abLoopPlugin.setOptions({ + ...opts, + enabled: false, + }); + } + setTimestamp(marker.seconds); } + function onLoopMarker(marker: GQL.SceneMarkerDataFragment) { + if (marker.end_seconds == null) return; + + setTimestamp(marker.seconds); + const start = Math.min(marker.seconds, marker.end_seconds); + const end = Math.max(marker.seconds, marker.end_seconds); + const abLoopPlugin = getAbLoopPlugin(); + const opts = abLoopPlugin?.getOptions(); + + if (opts && abLoopPlugin) { + abLoopPlugin.setOptions({ + ...opts, + start, + end, + enabled: true, + }); + } + } + async function onRescan() { await mutateMetadataScan({ paths: [objectPath(scene)], @@ -561,6 +608,7 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx index ef1a2e7e1..cbb2ad4bb 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx @@ -11,7 +11,10 @@ import { } from "src/core/StashService"; import { DurationInput } from "src/components/Shared/DurationInput"; import { MarkerTitleSuggest } from "src/components/Shared/Select"; -import { getPlayerPosition } from "src/components/ScenePlayer/util"; +import { + getAbLoopPlugin, + getPlayerPosition, +} from "src/components/ScenePlayer/util"; import { useToast } from "src/hooks/Toast"; import isEqual from "lodash-es/isEqual"; import { formikUtils } from "src/utils/form"; @@ -61,16 +64,39 @@ export const SceneMarkerForm: React.FC = ({ }); // useMemo to only run getPlayerPosition when the input marker actually changes - const initialValues = useMemo( - () => ({ + const initialValues = useMemo(() => { + if (!marker) { + const abLoopPlugin = getAbLoopPlugin(); + const opts = abLoopPlugin?.getOptions(); + const start = opts?.start; + const end = opts?.end; + const hasAbLoop = Number.isFinite(start); + + if (hasAbLoop) { + const current = Math.round(getPlayerPosition() ?? 0); + const rawEnd = + Number.isFinite(end) && (end as number) > 0 ? (end as number) : null; + const endSeconds = + rawEnd !== null ? rawEnd : Math.max(start as number, current); + + return { + title: "", + seconds: start as number, + end_seconds: endSeconds, + primary_tag_id: "", + tag_ids: [], + }; + } + } + + return { title: marker?.title ?? "", seconds: marker?.seconds ?? Math.round(getPlayerPosition() ?? 0), end_seconds: marker?.end_seconds ?? null, primary_tag_id: marker?.primary_tag.id ?? "", tag_ids: marker?.tags.map((tag) => tag.id) ?? [], - }), - [marker] - ); + }; + }, [marker]); type InputValues = yup.InferType; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx index 331c58c78..28a6e4d98 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx @@ -11,12 +11,14 @@ interface ISceneMarkersPanelProps { sceneId: string; isVisible: boolean; onClickMarker: (marker: GQL.SceneMarkerDataFragment) => void; + onLoopMarker: (marker: GQL.SceneMarkerDataFragment) => void; } export const SceneMarkersPanel: React.FC = ({ sceneId, isVisible, onClickMarker, + onLoopMarker, }) => { const { data, loading } = GQL.useFindSceneMarkerTagsQuery({ variables: { id: sceneId }, @@ -70,6 +72,7 @@ export const SceneMarkersPanel: React.FC = ({
From ed0fb53ae0df68ca30195206d9662e06a0ab27fa Mon Sep 17 00:00:00 2001 From: Hans Evers <69341873+xantror@users.noreply.github.com> Date: Wed, 4 Feb 2026 00:37:15 +0100 Subject: [PATCH 047/177] feat: auto-remove duplicate aliases (#6514) --- graphql/schema/types/performer.graphql | 3 + graphql/schema/types/studio.graphql | 2 + graphql/schema/types/tag.graphql | 3 + internal/api/resolver_mutation_performer.go | 23 ++++- internal/api/resolver_mutation_studio.go | 24 +++++- internal/api/resolver_mutation_tag.go | 24 +++++- pkg/performer/validate.go | 5 ++ pkg/performer/validate_test.go | 6 +- .../stringslice/string_collections.go | 17 ++++ pkg/studio/validate.go | 1 + pkg/studio/validate_test.go | 69 +++++++++++++++ pkg/tag/validate.go | 4 +- pkg/tag/validate_test.go | 86 +++++++++++++++++++ .../PerformerDetails/PerformerEditPanel.tsx | 4 +- .../Studios/StudioDetails/StudioEditPanel.tsx | 4 +- .../Tags/TagDetails/TagEditPanel.tsx | 4 +- ui/v2.5/src/utils/yup.ts | 39 --------- 17 files changed, 266 insertions(+), 52 deletions(-) create mode 100644 pkg/tag/validate_test.go diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index e788b91a8..7275d4495 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -80,6 +80,7 @@ input PerformerCreateInput { career_length: String tattoos: String piercings: String + "Duplicate aliases and those equal to name will be ignored (case-insensitive)" alias_list: [String!] twitter: String @deprecated(reason: "Use urls") instagram: String @deprecated(reason: "Use urls") @@ -118,6 +119,7 @@ input PerformerUpdateInput { career_length: String tattoos: String piercings: String + "Duplicate aliases and those equal to name will be ignored (case-insensitive)" alias_list: [String!] twitter: String @deprecated(reason: "Use urls") instagram: String @deprecated(reason: "Use urls") @@ -161,6 +163,7 @@ input BulkPerformerUpdateInput { career_length: String tattoos: String piercings: String + "Duplicate aliases and those equal to name will result in an error (case-insensitive)" alias_list: BulkUpdateStrings twitter: String @deprecated(reason: "Use urls") instagram: String @deprecated(reason: "Use urls") diff --git a/graphql/schema/types/studio.graphql b/graphql/schema/types/studio.graphql index 4c5778c5b..a1e1659ec 100644 --- a/graphql/schema/types/studio.graphql +++ b/graphql/schema/types/studio.graphql @@ -40,6 +40,7 @@ input StudioCreateInput { rating100: Int favorite: Boolean details: String + "Duplicate aliases and those equal to name will be ignored (case-insensitive)" aliases: [String!] tag_ids: [ID!] ignore_auto_tag: Boolean @@ -58,6 +59,7 @@ input StudioUpdateInput { rating100: Int favorite: Boolean details: String + "Duplicate aliases and those equal to name will be ignored (case-insensitive)" aliases: [String!] tag_ids: [ID!] ignore_auto_tag: Boolean diff --git a/graphql/schema/types/tag.graphql b/graphql/schema/types/tag.graphql index 8424ab92a..a69b83548 100644 --- a/graphql/schema/types/tag.graphql +++ b/graphql/schema/types/tag.graphql @@ -31,6 +31,7 @@ input TagCreateInput { "Value that does not appear in the UI but overrides name for sorting" sort_name: String description: String + "Duplicate aliases and those equal to name will be ignored (case-insensitive)" aliases: [String!] ignore_auto_tag: Boolean favorite: Boolean @@ -48,6 +49,7 @@ input TagUpdateInput { "Value that does not appear in the UI but overrides name for sorting" sort_name: String description: String + "Duplicate aliases and those equal to name will be ignored (case-insensitive)" aliases: [String!] ignore_auto_tag: Boolean favorite: Boolean @@ -76,6 +78,7 @@ input TagsMergeInput { input BulkTagUpdateInput { ids: [ID!] description: String + "Duplicate aliases and those equal to name will result in an error (case-insensitive)" aliases: BulkUpdateStrings ignore_auto_tag: Boolean favorite: Boolean diff --git a/internal/api/resolver_mutation_performer.go b/internal/api/resolver_mutation_performer.go index ab9abf6cf..fd18ecb95 100644 --- a/internal/api/resolver_mutation_performer.go +++ b/internal/api/resolver_mutation_performer.go @@ -43,7 +43,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per newPerformer.Name = strings.TrimSpace(input.Name) newPerformer.Disambiguation = translator.string(input.Disambiguation) - newPerformer.Aliases = models.NewRelatedStrings(stringslice.TrimSpace(input.AliasList)) + newPerformer.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.AliasList), newPerformer.Name)) newPerformer.Gender = input.Gender newPerformer.Ethnicity = translator.string(input.Ethnicity) newPerformer.Country = translator.string(input.Country) @@ -348,6 +348,27 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per } } + if updatedPerformer.Aliases != nil { + p, err := qb.Find(ctx, performerID) + if err != nil { + return err + } + if p != nil { + if err := p.LoadAliases(ctx, qb); err != nil { + return err + } + + effectiveAliases := updatedPerformer.Aliases.Apply(p.Aliases.List()) + name := p.Name + if updatedPerformer.Name.Set { + name = updatedPerformer.Name.Value + } + + sanitized := stringslice.UniqueExcludeFold(effectiveAliases, name) + updatedPerformer.Aliases.Values = sanitized + updatedPerformer.Aliases.Mode = models.RelationshipUpdateModeSet + } + } if err := performer.ValidateUpdate(ctx, performerID, *updatedPerformer, qb); err != nil { return err } diff --git a/internal/api/resolver_mutation_studio.go b/internal/api/resolver_mutation_studio.go index da3aa1983..fdd700490 100644 --- a/internal/api/resolver_mutation_studio.go +++ b/internal/api/resolver_mutation_studio.go @@ -38,7 +38,7 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio newStudio.Favorite = translator.bool(input.Favorite) newStudio.Details = translator.string(input.Details) newStudio.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag) - newStudio.Aliases = models.NewRelatedStrings(stringslice.TrimSpace(input.Aliases)) + newStudio.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.Aliases), newStudio.Name)) newStudio.StashIDs = models.NewRelatedStashIDs(models.StashIDInputs(input.StashIds).ToStashIDs()) var err error @@ -167,6 +167,28 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Studio + if updatedStudio.Aliases != nil { + s, err := qb.Find(ctx, studioID) + if err != nil { + return err + } + if s != nil { + if err := s.LoadAliases(ctx, qb); err != nil { + return err + } + + effectiveAliases := updatedStudio.Aliases.Apply(s.Aliases.List()) + name := s.Name + if updatedStudio.Name.Set { + name = updatedStudio.Name.Value + } + + sanitized := stringslice.UniqueExcludeFold(effectiveAliases, name) + updatedStudio.Aliases.Values = sanitized + updatedStudio.Aliases.Mode = models.RelationshipUpdateModeSet + } + } + if err := studio.ValidateModify(ctx, updatedStudio, qb); err != nil { return err } diff --git a/internal/api/resolver_mutation_tag.go b/internal/api/resolver_mutation_tag.go index f8d4943be..8fb295d40 100644 --- a/internal/api/resolver_mutation_tag.go +++ b/internal/api/resolver_mutation_tag.go @@ -35,7 +35,7 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput) newTag.Name = strings.TrimSpace(input.Name) newTag.SortName = translator.string(input.SortName) - newTag.Aliases = models.NewRelatedStrings(stringslice.TrimSpace(input.Aliases)) + newTag.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.Aliases), newTag.Name)) newTag.Favorite = translator.bool(input.Favorite) newTag.Description = translator.string(input.Description) newTag.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag) @@ -151,6 +151,28 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Tag + if updatedTag.Aliases != nil { + t, err := qb.Find(ctx, tagID) + if err != nil { + return err + } + if t != nil { + if err := t.LoadAliases(ctx, qb); err != nil { + return err + } + + newAliases := updatedTag.Aliases.Apply(t.Aliases.List()) + name := t.Name + if updatedTag.Name.Set { + name = updatedTag.Name.Value + } + + sanitized := stringslice.UniqueExcludeFold(newAliases, name) + updatedTag.Aliases.Values = sanitized + updatedTag.Aliases.Mode = models.RelationshipUpdateModeSet + } + } + if err := tag.ValidateUpdate(ctx, tagID, updatedTag, qb); err != nil { return err } diff --git a/pkg/performer/validate.go b/pkg/performer/validate.go index 68f7a8ef5..3baaa182b 100644 --- a/pkg/performer/validate.go +++ b/pkg/performer/validate.go @@ -225,6 +225,11 @@ func ValidateUpdateAliases(existing models.Performer, name models.OptionalString newName = name.Value } + // If aliases is nil, we're only changing the name - check existing aliases against new name + if aliases == nil { + return ValidateAliases(newName, existing.Aliases) + } + newAliases := aliases.Apply(existing.Aliases.List()) return ValidateAliases(newName, models.NewRelatedStrings(newAliases)) diff --git a/pkg/performer/validate_test.go b/pkg/performer/validate_test.go index 33f4b1cec..afd9c01c5 100644 --- a/pkg/performer/validate_test.go +++ b/pkg/performer/validate_test.go @@ -213,12 +213,12 @@ func TestValidateUpdateAliases(t *testing.T) { want error }{ {"both unset", osUnset, nil, nil}, - {"invalid name set", os2, nil, &DuplicateAliasError{name2}}, + {"name conflicts with alias", os2, nil, &DuplicateAliasError{name2}}, {"valid name set", os3, nil, nil}, {"valid aliases empty", os1, []string{}, nil}, - {"invalid aliases set", osUnset, []string{name1U}, &DuplicateAliasError{name1U}}, + {"alias matches name", osUnset, []string{name1U}, &DuplicateAliasError{name1U}}, {"valid aliases set", osUnset, []string{name3, name2}, nil}, - {"invalid both set", os4, []string{name4}, &DuplicateAliasError{name4}}, + {"alias matches new name", os4, []string{name4}, &DuplicateAliasError{name4}}, {"valid both set", os2, []string{name1}, nil}, } diff --git a/pkg/sliceutil/stringslice/string_collections.go b/pkg/sliceutil/stringslice/string_collections.go index f5251de5f..eff3409e2 100644 --- a/pkg/sliceutil/stringslice/string_collections.go +++ b/pkg/sliceutil/stringslice/string_collections.go @@ -45,6 +45,23 @@ func UniqueFold(s []string) []string { return ret } +// UniqueExcludeFold returns a deduplicated slice of strings with the excluded string removed. +// The comparison is case-insensitive. +func UniqueExcludeFold(values []string, exclude string) []string { + seen := make(map[string]struct{}, len(values)) + seen[strings.ToLower(exclude)] = struct{}{} + ret := make([]string, 0, len(values)) + for _, v := range values { + vLower := strings.ToLower(v) + if _, exists := seen[vLower]; exists { + continue + } + seen[vLower] = struct{}{} + ret = append(ret, v) + } + return ret +} + // TrimSpace trims whitespace from each string in a slice. func TrimSpace(s []string) []string { for i, v := range s { diff --git a/pkg/studio/validate.go b/pkg/studio/validate.go index 4e2f51c84..1654a2e78 100644 --- a/pkg/studio/validate.go +++ b/pkg/studio/validate.go @@ -135,6 +135,7 @@ func ValidateModify(ctx context.Context, s models.StudioPartial, qb ValidateModi } effectiveAliases := s.Aliases.Apply(existing.Aliases.List()) + if err := ValidateAliases(ctx, s.ID, effectiveAliases, qb); err != nil { return err } diff --git a/pkg/studio/validate_test.go b/pkg/studio/validate_test.go index 6562dc5ca..b196ba3c3 100644 --- a/pkg/studio/validate_test.go +++ b/pkg/studio/validate_test.go @@ -102,3 +102,72 @@ func TestValidateUpdateName(t *testing.T) { }) } } + +func TestValidateUpdateAliases(t *testing.T) { + db := mocks.NewDatabase() + + const ( + name1 = "name 1" + name2 = "name 2" + alias1 = "alias 1" + newAlias = "new alias" + ) + + existing1 := models.Studio{ + ID: 1, + Name: name1, + } + existing2 := models.Studio{ + ID: 2, + Name: name2, + } + + pp := 1 + findFilter := &models.FindFilterType{ + PerPage: &pp, + } + + aliasFilter := func(n string) *models.StudioFilterType { + return &models.StudioFilterType{ + Aliases: &models.StringCriterionInput{ + Value: n, + Modifier: models.CriterionModifierEquals, + }, + } + } + + // name1 matches existing1 name - ok + db.Studio.On("Query", testCtx, nameFilter(alias1), findFilter).Return(nil, 0, nil) + db.Studio.On("Query", testCtx, aliasFilter(alias1), findFilter).Return(nil, 0, nil) + + // name2 matches existing2 name - error + db.Studio.On("Query", testCtx, nameFilter(name2), findFilter).Return([]*models.Studio{&existing2}, 1, nil) + + // alias matches existing alias - error + db.Studio.On("Query", testCtx, nameFilter(newAlias), findFilter).Return(nil, 0, nil) + db.Studio.On("Query", testCtx, aliasFilter(newAlias), findFilter).Return([]*models.Studio{&existing2}, 1, nil) + + // valid alias + db.Studio.On("Query", testCtx, nameFilter("valid"), findFilter).Return(nil, 0, nil) + db.Studio.On("Query", testCtx, aliasFilter("valid"), findFilter).Return(nil, 0, nil) + + tests := []struct { + tName string + studio models.Studio + aliases []string + want error + }{ + {"valid alias", existing1, []string{alias1}, nil}, + {"alias duplicates other name", existing1, []string{name2}, &NameExistsError{name2}}, + {"alias duplicates other alias", existing1, []string{newAlias}, &NameUsedByAliasError{newAlias, existing2.Name}}, + {"valid new alias", existing1, []string{"valid"}, nil}, + {"empty alias", existing1, []string{""}, ErrEmptyAlias}, + } + + for _, tt := range tests { + t.Run(tt.tName, func(t *testing.T) { + got := ValidateAliases(testCtx, tt.studio.ID, tt.aliases, db.Studio) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/tag/validate.go b/pkg/tag/validate.go index 966cec945..abc260b5e 100644 --- a/pkg/tag/validate.go +++ b/pkg/tag/validate.go @@ -69,7 +69,9 @@ func ValidateUpdate(ctx context.Context, id int, partial models.TagPartial, qb m return err } - if err := EnsureAliasesUnique(ctx, id, partial.Aliases.Apply(existing.Aliases.List()), qb); err != nil { + newAliases := partial.Aliases.Apply(existing.Aliases.List()) + + if err := EnsureAliasesUnique(ctx, id, newAliases, qb); err != nil { return err } } diff --git a/pkg/tag/validate_test.go b/pkg/tag/validate_test.go new file mode 100644 index 000000000..539086a6d --- /dev/null +++ b/pkg/tag/validate_test.go @@ -0,0 +1,86 @@ +package tag + +import ( + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stretchr/testify/assert" +) + +func nameFilter(n string) *models.TagFilterType { + return &models.TagFilterType{ + Name: &models.StringCriterionInput{ + Value: n, + Modifier: models.CriterionModifierEquals, + }, + } +} + +func aliasFilter(n string) *models.TagFilterType { + return &models.TagFilterType{ + Aliases: &models.StringCriterionInput{ + Value: n, + Modifier: models.CriterionModifierEquals, + }, + } +} + +func TestEnsureAliasesUnique(t *testing.T) { + db := mocks.NewDatabase() + + const ( + name1 = "name 1" + name2 = "name 2" + alias1 = "alias 1" + newAlias = "new alias" + ) + + existing2 := models.Tag{ + ID: 2, + Name: name2, + } + + pp := 1 + findFilter := &models.FindFilterType{ + PerPage: &pp, + } + + // name1 matches existing1 name - ok + // EnsureAliasesUnique calls EnsureTagNameUnique. + // EnsureTagNameUnique calls ByName then ByAlias. + + // Case 1: valid alias + // ByName "alias 1" -> nil + // ByAlias "alias 1" -> nil + db.Tag.On("Query", testCtx, nameFilter(alias1), findFilter).Return(nil, 0, nil) + db.Tag.On("Query", testCtx, aliasFilter(alias1), findFilter).Return(nil, 0, nil) + + // Case 2: alias duplicates existing2 name + // ByName "name 2" -> existing2 + db.Tag.On("Query", testCtx, nameFilter(name2), findFilter).Return([]*models.Tag{&existing2}, 1, nil) + + // Case 3: alias duplicates existing2 alias + // ByName "new alias" -> nil + // ByAlias "new alias" -> existing2 + db.Tag.On("Query", testCtx, nameFilter(newAlias), findFilter).Return(nil, 0, nil) + db.Tag.On("Query", testCtx, aliasFilter(newAlias), findFilter).Return([]*models.Tag{&existing2}, 1, nil) + + tests := []struct { + tName string + id int + aliases []string + want error + }{ + {"valid alias", 1, []string{alias1}, nil}, + {"alias duplicates other name", 1, []string{name2}, &NameExistsError{name2}}, + {"alias duplicates other alias", 1, []string{newAlias}, &NameUsedByAliasError{newAlias, existing2.Name}}, + } + + for _, tt := range tests { + t.Run(tt.tName, func(t *testing.T) { + got := EnsureAliasesUnique(testCtx, tt.id, tt.aliases, db.Tag) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index 7bb8d399a..0e769edf9 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -44,7 +44,7 @@ import { yupInputNumber, yupInputEnum, yupDateString, - yupUniqueAliases, + yupRequiredStringArray, yupUniqueStringList, } from "src/utils/yup"; import { useTagsEdit } from "src/hooks/tagsEdit"; @@ -110,7 +110,7 @@ export const PerformerEditPanel: React.FC = ({ const schema = yup.object({ name: yup.string().required(), disambiguation: yup.string().ensure(), - alias_list: yupUniqueAliases(intl, "name"), + alias_list: yupRequiredStringArray(intl).defined(), gender: yupInputEnum(GQL.GenderEnum).nullable().defined(), birthdate: yupDateString(intl), death_date: yupDateString(intl), diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx index a45471b26..f887e5403 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx @@ -16,7 +16,7 @@ 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 { yupFormikValidate, yupRequiredStringArray } from "src/utils/yup"; import { Studio, StudioSelect } from "../StudioSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { Icon } from "src/components/Shared/Icon"; @@ -58,7 +58,7 @@ export const StudioEditPanel: React.FC = ({ urls: yup.array(yup.string().required()).defined(), details: yup.string().ensure(), parent_id: yup.string().required().nullable(), - aliases: yupUniqueAliases(intl, "name"), + aliases: yupRequiredStringArray(intl).defined(), tag_ids: yup.array(yup.string().required()).defined(), ignore_auto_tag: yup.boolean().defined(), stash_ids: yup.mixed().defined(), diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx index 077300788..22c99b80e 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx @@ -15,7 +15,7 @@ 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 { yupFormikValidate, yupRequiredStringArray } from "src/utils/yup"; import { addUpdateStashID, getStashIDs } from "src/utils/stashIds"; import { Tag, TagSelect } from "../TagSelect"; import { Icon } from "src/components/Shared/Icon"; @@ -56,7 +56,7 @@ export const TagEditPanel: React.FC = ({ const schema = yup.object({ name: yup.string().required(), sort_name: yup.string().ensure(), - aliases: yupUniqueAliases(intl, "name"), + aliases: yupRequiredStringArray(intl).defined(), description: yup.string().ensure(), parent_ids: yup.array(yup.string().required()).defined(), child_ids: yup.array(yup.string().required()).defined(), diff --git a/ui/v2.5/src/utils/yup.ts b/ui/v2.5/src/utils/yup.ts index 5ae8123df..a9c4f69e1 100644 --- a/ui/v2.5/src/utils/yup.ts +++ b/ui/v2.5/src/utils/yup.ts @@ -92,45 +92,6 @@ export function yupUniqueStringList(intl: IntlShape) { }); } -export function yupUniqueAliases(intl: IntlShape, nameField: string) { - return yupRequiredStringArray(intl) - .defined() - .test({ - name: "unique", - test(value) { - const aliases = [this.parent[nameField].toLowerCase()]; - const dupes: number[] = []; - for (let i = 0; i < value.length; i++) { - const s = value[i].toLowerCase(); - if (aliases.includes(s)) { - dupes.push(i); - } else { - aliases.push(s); - } - } - if (dupes.length === 0) return true; - - const msg = yup.ValidationError.formatError( - intl.formatMessage({ id: "validation.unique" }), - { - label: this.schema.spec.label, - path: this.path, - } - ); - const errors = dupes.map( - (i) => - new yup.ValidationError( - msg, - value[i], - `${this.path}["${i}"]`, - "unique" - ) - ); - return new yup.ValidationError(errors, value, this.path, "unique"); - }, - }); -} - export function yupDateString(intl: IntlShape) { return yup .string() From 88eb46380c1f05ecd1e8cac632d503b6b1a5070f Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 4 Feb 2026 11:07:51 +1100 Subject: [PATCH 048/177] Refactor scraper package (#6495) * Remove reflection from mapped value processing * AI generated unit tests * Move mappedConfig to separate file * Rename group to configScraper * Separate mapped post-processing code into separate file * Update test after group rename * Check map entry when returning scraper * Refactor config into definition * Support single string for string slice translation * Rename config.go to definition.go * Rename configScraper to definedScraper * Rename config_scraper.go to defined_scraper.go --- pkg/scraper/action.go | 112 +- pkg/scraper/cache.go | 2 +- pkg/scraper/cookies.go | 4 +- pkg/scraper/{group.go => defined_scraper.go} | 48 +- pkg/scraper/{config.go => definition.go} | 77 +- pkg/scraper/freeones.go | 2 +- pkg/scraper/json.go | 133 +-- pkg/scraper/json_test.go | 2 +- pkg/scraper/mapped.go | 1080 +----------------- pkg/scraper/mapped_config.go | 537 +++++++++ pkg/scraper/mapped_postprocessing.go | 333 ++++++ pkg/scraper/mapped_result.go | 276 +++++ pkg/scraper/mapped_result_test.go | 908 +++++++++++++++ pkg/scraper/mapped_test.go | 2 +- pkg/scraper/query_url.go | 2 +- pkg/scraper/script.go | 120 +- pkg/scraper/stash.go | 6 +- pkg/scraper/url.go | 8 +- pkg/scraper/xpath.go | 131 +-- pkg/scraper/xpath_test.go | 16 +- 20 files changed, 2475 insertions(+), 1324 deletions(-) rename pkg/scraper/{group.go => defined_scraper.go} (56%) rename pkg/scraper/{config.go => definition.go} (80%) create mode 100644 pkg/scraper/mapped_config.go create mode 100644 pkg/scraper/mapped_postprocessing.go create mode 100644 pkg/scraper/mapped_result.go create mode 100644 pkg/scraper/mapped_result_test.go diff --git a/pkg/scraper/action.go b/pkg/scraper/action.go index 74bbca415..cd31fbe72 100644 --- a/pkg/scraper/action.go +++ b/pkg/scraper/action.go @@ -24,9 +24,85 @@ func (e scraperAction) IsValid() bool { return false } -type scraperActionImpl interface { +type urlScraperActionImpl interface { scrapeByURL(ctx context.Context, url string, ty ScrapeContentType) (ScrapedContent, error) +} + +func (c Definition) getURLScraper(def ByURLDefinition, client *http.Client, globalConfig GlobalConfig) urlScraperActionImpl { + switch def.Action { + case scraperActionScript: + return &scriptURLScraper{ + scriptScraper: scriptScraper{ + definition: c, + globalConfig: globalConfig, + }, + definition: def, + } + case scraperActionStash: + return newStashScraper(client, c, globalConfig) + case scraperActionXPath: + return &xpathURLScraper{ + xpathScraper: xpathScraper{ + definition: c, + globalConfig: globalConfig, + client: client, + }, + definition: def, + } + case scraperActionJson: + return &jsonURLScraper{ + jsonScraper: jsonScraper{ + definition: c, + globalConfig: globalConfig, + client: client, + }, + definition: def, + } + } + + panic("unknown scraper action: " + def.Action) +} + +type nameScraperActionImpl interface { scrapeByName(ctx context.Context, name string, ty ScrapeContentType) ([]ScrapedContent, error) +} + +func (c Definition) getNameScraper(def ByNameDefinition, client *http.Client, globalConfig GlobalConfig) nameScraperActionImpl { + switch def.Action { + case scraperActionScript: + return &scriptNameScraper{ + scriptScraper: scriptScraper{ + definition: c, + globalConfig: globalConfig, + }, + definition: def, + } + case scraperActionStash: + return newStashScraper(client, c, globalConfig) + case scraperActionXPath: + return &xpathNameScraper{ + xpathScraper: xpathScraper{ + definition: c, + globalConfig: globalConfig, + client: client, + }, + definition: def, + } + case scraperActionJson: + return &jsonNameScraper{ + jsonScraper: jsonScraper{ + definition: c, + globalConfig: globalConfig, + client: client, + }, + definition: def, + } + } + + panic("unknown scraper action: " + def.Action) +} + +type fragmentScraperActionImpl interface { scrapeByFragment(ctx context.Context, input Input) (ScrapedContent, error) scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*models.ScrapedScene, error) @@ -34,17 +110,37 @@ type scraperActionImpl interface { scrapeImageByImage(ctx context.Context, image *models.Image) (*models.ScrapedImage, error) } -func (c config) getScraper(scraper scraperTypeConfig, client *http.Client, globalConfig GlobalConfig) scraperActionImpl { - switch scraper.Action { +func (c Definition) getFragmentScraper(actionDef ByFragmentDefinition, client *http.Client, globalConfig GlobalConfig) fragmentScraperActionImpl { + switch actionDef.Action { case scraperActionScript: - return newScriptScraper(scraper, c, globalConfig) + return &scriptFragmentScraper{ + scriptScraper: scriptScraper{ + definition: c, + globalConfig: globalConfig, + }, + definition: actionDef, + } case scraperActionStash: - return newStashScraper(scraper, client, c, globalConfig) + return newStashScraper(client, c, globalConfig) case scraperActionXPath: - return newXpathScraper(scraper, client, c, globalConfig) + return &xpathFragmentScraper{ + xpathScraper: xpathScraper{ + definition: c, + globalConfig: globalConfig, + client: client, + }, + definition: actionDef, + } case scraperActionJson: - return newJsonScraper(scraper, client, c, globalConfig) + return &jsonFragmentScraper{ + jsonScraper: jsonScraper{ + definition: c, + globalConfig: globalConfig, + client: client, + }, + definition: actionDef, + } } - panic("unknown scraper action: " + scraper.Action) + panic("unknown scraper action: " + actionDef.Action) } diff --git a/pkg/scraper/cache.go b/pkg/scraper/cache.go index 5cc51ac54..6aeb95fcf 100644 --- a/pkg/scraper/cache.go +++ b/pkg/scraper/cache.go @@ -182,7 +182,7 @@ func (c *Cache) ReloadScrapers() { if err != nil { logger.Errorf("Error loading scraper %s: %v", fp, err) } else { - scraper := newGroupScraper(*conf, c.globalConfig) + scraper := scraperFromDefinition(*conf, c.globalConfig) scrapers[scraper.spec().ID] = scraper } } diff --git a/pkg/scraper/cookies.go b/pkg/scraper/cookies.go index 0a2877b7b..c76dae037 100644 --- a/pkg/scraper/cookies.go +++ b/pkg/scraper/cookies.go @@ -18,7 +18,7 @@ import ( ) // jar constructs a cookie jar from a configuration -func (c config) jar() (*cookiejar.Jar, error) { +func (c Definition) jar() (*cookiejar.Jar, error) { opts := c.DriverOptions jar, err := cookiejar.New(&cookiejar.Options{ PublicSuffixList: publicsuffix.List, @@ -77,7 +77,7 @@ func randomSequence(n int) string { } // printCookies prints all cookies from the given cookie jar -func printCookies(jar *cookiejar.Jar, scraperConfig config, msg string) { +func printCookies(jar *cookiejar.Jar, scraperConfig Definition, msg string) { driverOptions := scraperConfig.DriverOptions if driverOptions != nil && !driverOptions.UseCDP { var foundURLs []*url.URL diff --git a/pkg/scraper/group.go b/pkg/scraper/defined_scraper.go similarity index 56% rename from pkg/scraper/group.go rename to pkg/scraper/defined_scraper.go index 43fd2a37b..0287101d0 100644 --- a/pkg/scraper/group.go +++ b/pkg/scraper/defined_scraper.go @@ -8,25 +8,26 @@ import ( "github.com/stashapp/stash/pkg/models" ) -type group struct { - config config +// definedScraper implements the scraper interface using a Definition object. +type definedScraper struct { + config Definition globalConf GlobalConfig } -func newGroupScraper(c config, globalConfig GlobalConfig) scraper { - return group{ +func scraperFromDefinition(c Definition, globalConfig GlobalConfig) definedScraper { + return definedScraper{ config: c, globalConf: globalConfig, } } -func (g group) spec() Scraper { +func (g definedScraper) spec() Scraper { return g.config.spec() } // fragmentScraper finds an appropriate fragment scraper based on input. -func (g group) fragmentScraper(input Input) *scraperTypeConfig { +func (g definedScraper) fragmentScraper(input Input) *ByFragmentDefinition { switch { case input.Performer != nil: return g.config.PerformerByFragment @@ -43,7 +44,7 @@ func (g group) fragmentScraper(input Input) *scraperTypeConfig { return nil } -func (g group) viaFragment(ctx context.Context, client *http.Client, input Input) (ScrapedContent, error) { +func (g definedScraper) viaFragment(ctx context.Context, client *http.Client, input Input) (ScrapedContent, error) { stc := g.fragmentScraper(input) if stc == nil { // If there's no performer fragment scraper in the group, we try to use @@ -56,38 +57,38 @@ func (g group) viaFragment(ctx context.Context, client *http.Client, input Input return nil, ErrNotSupported } - s := g.config.getScraper(*stc, client, g.globalConf) + s := g.config.getFragmentScraper(*stc, client, g.globalConf) return s.scrapeByFragment(ctx, input) } -func (g group) viaScene(ctx context.Context, client *http.Client, scene *models.Scene) (*models.ScrapedScene, error) { +func (g definedScraper) viaScene(ctx context.Context, client *http.Client, scene *models.Scene) (*models.ScrapedScene, error) { if g.config.SceneByFragment == nil { return nil, ErrNotSupported } - s := g.config.getScraper(*g.config.SceneByFragment, client, g.globalConf) + s := g.config.getFragmentScraper(*g.config.SceneByFragment, client, g.globalConf) return s.scrapeSceneByScene(ctx, scene) } -func (g group) viaGallery(ctx context.Context, client *http.Client, gallery *models.Gallery) (*models.ScrapedGallery, error) { +func (g definedScraper) viaGallery(ctx context.Context, client *http.Client, gallery *models.Gallery) (*models.ScrapedGallery, error) { if g.config.GalleryByFragment == nil { return nil, ErrNotSupported } - s := g.config.getScraper(*g.config.GalleryByFragment, client, g.globalConf) + s := g.config.getFragmentScraper(*g.config.GalleryByFragment, client, g.globalConf) return s.scrapeGalleryByGallery(ctx, gallery) } -func (g group) viaImage(ctx context.Context, client *http.Client, gallery *models.Image) (*models.ScrapedImage, error) { +func (g definedScraper) viaImage(ctx context.Context, client *http.Client, gallery *models.Image) (*models.ScrapedImage, error) { if g.config.ImageByFragment == nil { return nil, ErrNotSupported } - s := g.config.getScraper(*g.config.ImageByFragment, client, g.globalConf) + s := g.config.getFragmentScraper(*g.config.ImageByFragment, client, g.globalConf) return s.scrapeImageByImage(ctx, gallery) } -func loadUrlCandidates(c config, ty ScrapeContentType) []*scrapeByURLConfig { +func loadUrlCandidates(c Definition, ty ScrapeContentType) []*ByURLDefinition { switch ty { case ScrapeContentTypePerformer: return c.PerformerByURL @@ -104,12 +105,13 @@ func loadUrlCandidates(c config, ty ScrapeContentType) []*scrapeByURLConfig { panic("loadUrlCandidates: unreachable") } -func (g group) viaURL(ctx context.Context, client *http.Client, url string, ty ScrapeContentType) (ScrapedContent, error) { +func (g definedScraper) viaURL(ctx context.Context, client *http.Client, url string, ty ScrapeContentType) (ScrapedContent, error) { candidates := loadUrlCandidates(g.config, ty) for _, scraper := range candidates { if scraper.matchesURL(url) { - s := g.config.getScraper(scraper.scraperTypeConfig, client, g.globalConf) - ret, err := s.scrapeByURL(ctx, url, ty) + u := replaceURL(url, *scraper) // allow a URL Replace for url-queries + s := g.config.getURLScraper(*scraper, client, g.globalConf) + ret, err := s.scrapeByURL(ctx, u, ty) if err != nil { return nil, err } @@ -123,31 +125,31 @@ func (g group) viaURL(ctx context.Context, client *http.Client, url string, ty S return nil, nil } -func (g group) viaName(ctx context.Context, client *http.Client, name string, ty ScrapeContentType) ([]ScrapedContent, error) { +func (g definedScraper) viaName(ctx context.Context, client *http.Client, name string, ty ScrapeContentType) ([]ScrapedContent, error) { switch ty { case ScrapeContentTypePerformer: if g.config.PerformerByName == nil { break } - s := g.config.getScraper(*g.config.PerformerByName, client, g.globalConf) + s := g.config.getNameScraper(*g.config.PerformerByName, client, g.globalConf) return s.scrapeByName(ctx, name, ty) case ScrapeContentTypeScene: if g.config.SceneByName == nil { break } - s := g.config.getScraper(*g.config.SceneByName, client, g.globalConf) + s := g.config.getNameScraper(*g.config.SceneByName, client, g.globalConf) return s.scrapeByName(ctx, name, ty) } return nil, fmt.Errorf("%w: cannot load %v by name", ErrNotSupported, ty) } -func (g group) supports(ty ScrapeContentType) bool { +func (g definedScraper) supports(ty ScrapeContentType) bool { return g.config.supports(ty) } -func (g group) supportsURL(url string, ty ScrapeContentType) bool { +func (g definedScraper) supportsURL(url string, ty ScrapeContentType) bool { return g.config.matchesURL(url, ty) } diff --git a/pkg/scraper/config.go b/pkg/scraper/definition.go similarity index 80% rename from pkg/scraper/config.go rename to pkg/scraper/definition.go index 5775dc97c..03ba4d75b 100644 --- a/pkg/scraper/config.go +++ b/pkg/scraper/definition.go @@ -11,7 +11,8 @@ import ( "gopkg.in/yaml.v2" ) -type config struct { +// Definition represents a scraper definition (typically) loaded from a YAML configuration file. +type Definition struct { ID string path string @@ -19,43 +20,43 @@ type config struct { Name string `yaml:"name"` // Configuration for querying performers by name - PerformerByName *scraperTypeConfig `yaml:"performerByName"` + PerformerByName *ByNameDefinition `yaml:"performerByName"` // Configuration for querying performers by a Performer fragment - PerformerByFragment *scraperTypeConfig `yaml:"performerByFragment"` + PerformerByFragment *ByFragmentDefinition `yaml:"performerByFragment"` // Configuration for querying a performer by a URL - PerformerByURL []*scrapeByURLConfig `yaml:"performerByURL"` + PerformerByURL []*ByURLDefinition `yaml:"performerByURL"` // Configuration for querying scenes by a Scene fragment - SceneByFragment *scraperTypeConfig `yaml:"sceneByFragment"` + SceneByFragment *ByFragmentDefinition `yaml:"sceneByFragment"` // Configuration for querying gallery by a Gallery fragment - GalleryByFragment *scraperTypeConfig `yaml:"galleryByFragment"` + GalleryByFragment *ByFragmentDefinition `yaml:"galleryByFragment"` // Configuration for querying scenes by name - SceneByName *scraperTypeConfig `yaml:"sceneByName"` + SceneByName *ByNameDefinition `yaml:"sceneByName"` // Configuration for querying scenes by query fragment - SceneByQueryFragment *scraperTypeConfig `yaml:"sceneByQueryFragment"` + SceneByQueryFragment *ByFragmentDefinition `yaml:"sceneByQueryFragment"` // Configuration for querying a scene by a URL - SceneByURL []*scrapeByURLConfig `yaml:"sceneByURL"` + SceneByURL []*ByURLDefinition `yaml:"sceneByURL"` // Configuration for querying a gallery by a URL - GalleryByURL []*scrapeByURLConfig `yaml:"galleryByURL"` + GalleryByURL []*ByURLDefinition `yaml:"galleryByURL"` // Configuration for querying an image by a URL - ImageByURL []*scrapeByURLConfig `yaml:"imageByURL"` + ImageByURL []*ByURLDefinition `yaml:"imageByURL"` // Configuration for querying image by an Image fragment - ImageByFragment *scraperTypeConfig `yaml:"imageByFragment"` + ImageByFragment *ByFragmentDefinition `yaml:"imageByFragment"` // Configuration for querying a movie by a URL - deprecated, use GroupByURL - MovieByURL []*scrapeByURLConfig `yaml:"movieByURL"` + MovieByURL []*ByURLDefinition `yaml:"movieByURL"` // Configuration for querying a group by a URL - GroupByURL []*scrapeByURLConfig `yaml:"groupByURL"` + GroupByURL []*ByURLDefinition `yaml:"groupByURL"` // Scraper debugging options DebugOptions *scraperDebugOptions `yaml:"debug"` @@ -73,7 +74,7 @@ type config struct { DriverOptions *scraperDriverOptions `yaml:"driver"` } -func (c config) validate() error { +func (c Definition) validate() error { if strings.TrimSpace(c.Name) == "" { return errors.New("name must not be empty") } @@ -126,17 +127,13 @@ type stashServer struct { ApiKey string `yaml:"apiKey"` } -type scraperTypeConfig struct { +type ActionDefinition struct { Action scraperAction `yaml:"action"` Script []string `yaml:"script,flow"` Scraper string `yaml:"scraper"` - - // for xpath name scraper only - QueryURL string `yaml:"queryURL"` - QueryURLReplacements queryURLReplacements `yaml:"queryURLReplace"` } -func (c scraperTypeConfig) validate() error { +func (c ActionDefinition) validate() error { if !c.Action.IsValid() { return fmt.Errorf("%s is not a valid scraper action", c.Action) } @@ -148,20 +145,22 @@ func (c scraperTypeConfig) validate() error { return nil } -type scrapeByURLConfig struct { - scraperTypeConfig `yaml:",inline"` - URL []string `yaml:"url,flow"` +type ByURLDefinition struct { + ActionDefinition `yaml:",inline"` + URL []string `yaml:"url,flow"` + QueryURL string `yaml:"queryURL"` + QueryURLReplacements queryURLReplacements `yaml:"queryURLReplace"` } -func (c scrapeByURLConfig) validate() error { +func (c ByURLDefinition) validate() error { if len(c.URL) == 0 { return errors.New("url is mandatory for scrape by url scrapers") } - return c.scraperTypeConfig.validate() + return c.ActionDefinition.validate() } -func (c scrapeByURLConfig) matchesURL(url string) bool { +func (c ByURLDefinition) matchesURL(url string) bool { for _, thisURL := range c.URL { if strings.Contains(url, thisURL) { return true @@ -171,6 +170,18 @@ func (c scrapeByURLConfig) matchesURL(url string) bool { return false } +type ByFragmentDefinition struct { + ActionDefinition `yaml:",inline"` + + QueryURL string `yaml:"queryURL"` + QueryURLReplacements queryURLReplacements `yaml:"queryURLReplace"` +} + +type ByNameDefinition struct { + ActionDefinition `yaml:",inline"` + QueryURL string `yaml:"queryURL"` +} + type scraperDebugOptions struct { PrintHTML bool `yaml:"printHTML"` } @@ -206,8 +217,8 @@ type scraperDriverOptions struct { Headers []*header `yaml:"headers"` } -func loadConfigFromYAML(id string, reader io.Reader) (*config, error) { - ret := &config{} +func loadConfigFromYAML(id string, reader io.Reader) (*Definition, error) { + ret := &Definition{} parser := yaml.NewDecoder(reader) parser.SetStrict(true) @@ -225,7 +236,7 @@ func loadConfigFromYAML(id string, reader io.Reader) (*config, error) { return ret, nil } -func loadConfigFromYAMLFile(path string) (*config, error) { +func loadConfigFromYAMLFile(path string) (*Definition, error) { file, err := os.Open(path) if err != nil { return nil, err @@ -246,7 +257,7 @@ func loadConfigFromYAMLFile(path string) (*config, error) { return ret, nil } -func (c config) spec() Scraper { +func (c Definition) spec() Scraper { ret := Scraper{ ID: c.ID, Name: c.Name, @@ -334,7 +345,7 @@ func (c config) spec() Scraper { return ret } -func (c config) supports(ty ScrapeContentType) bool { +func (c Definition) supports(ty ScrapeContentType) bool { switch ty { case ScrapeContentTypePerformer: return c.PerformerByName != nil || c.PerformerByFragment != nil || len(c.PerformerByURL) > 0 @@ -351,7 +362,7 @@ func (c config) supports(ty ScrapeContentType) bool { panic("Unhandled ScrapeContentType") } -func (c config) matchesURL(url string, ty ScrapeContentType) bool { +func (c Definition) matchesURL(url string, ty ScrapeContentType) bool { switch ty { case ScrapeContentTypePerformer: for _, scraper := range c.PerformerByURL { diff --git a/pkg/scraper/freeones.go b/pkg/scraper/freeones.go index 96caf2fec..e78488b24 100644 --- a/pkg/scraper/freeones.go +++ b/pkg/scraper/freeones.go @@ -139,5 +139,5 @@ func getFreeonesScraper(globalConfig GlobalConfig) scraper { logger.Fatalf("Error loading builtin freeones scraper: %s", err.Error()) } - return newGroupScraper(*c, globalConfig) + return scraperFromDefinition(*c, globalConfig) } diff --git a/pkg/scraper/json.go b/pkg/scraper/json.go index 9f479f1c2..1dcb887da 100644 --- a/pkg/scraper/json.go +++ b/pkg/scraper/json.go @@ -15,43 +15,22 @@ import ( ) type jsonScraper struct { - scraper scraperTypeConfig - config config + definition Definition globalConfig GlobalConfig client *http.Client } -func newJsonScraper(scraper scraperTypeConfig, client *http.Client, config config, globalConfig GlobalConfig) *jsonScraper { - return &jsonScraper{ - scraper: scraper, - config: config, - client: client, - globalConfig: globalConfig, - } -} - -func (s *jsonScraper) getJsonScraper() *mappedScraper { - return s.config.JsonScrapers[s.scraper.Scraper] -} - -func (s *jsonScraper) scrapeURL(ctx context.Context, url string) (string, *mappedScraper, error) { - scraper := s.getJsonScraper() - - if scraper == nil { - return "", nil, errors.New("json scraper with name " + s.scraper.Scraper + " not found in config") +func (s *jsonScraper) getJsonScraper(name string) (*mappedScraper, error) { + ret, ok := s.definition.JsonScrapers[name] + if !ok { + return nil, fmt.Errorf("json scraper with name %s not found in config", name) } - doc, err := s.loadURL(ctx, url) - - if err != nil { - return "", nil, err - } - - return doc, scraper, nil + return &ret, nil } func (s *jsonScraper) loadURL(ctx context.Context, url string) (string, error) { - r, err := loadURL(ctx, url, s.client, s.config, s.globalConfig) + r, err := loadURL(ctx, url, s.client, s.definition, s.globalConfig) if err != nil { return "", err } @@ -66,21 +45,30 @@ func (s *jsonScraper) loadURL(ctx context.Context, url string) (string, error) { return "", errors.New("not valid json") } - if s.config.DebugOptions != nil && s.config.DebugOptions.PrintHTML { + if s.definition.DebugOptions != nil && s.definition.DebugOptions.PrintHTML { logger.Infof("loadURL (%s) response: \n%s", url, docStr) } return docStr, err } -func (s *jsonScraper) scrapeByURL(ctx context.Context, url string, ty ScrapeContentType) (ScrapedContent, error) { - u := replaceURL(url, s.scraper) // allow a URL Replace for url-queries - doc, scraper, err := s.scrapeURL(ctx, u) +type jsonURLScraper struct { + jsonScraper + definition ByURLDefinition +} + +func (s *jsonURLScraper) scrapeByURL(ctx context.Context, url string, ty ScrapeContentType) (ScrapedContent, error) { + scraper, err := s.getJsonScraper(s.definition.Scraper) if err != nil { return nil, err } - q := s.getJsonQuery(doc, u) + doc, err := s.loadURL(ctx, url) + if err != nil { + return nil, err + } + + q := s.getJsonQuery(doc, url) // if these just return the return values from scraper.scrape* functions then // it ends up returning ScrapedContent(nil) rather than nil switch ty { @@ -119,11 +107,15 @@ func (s *jsonScraper) scrapeByURL(ctx context.Context, url string, ty ScrapeCont return nil, ErrNotSupported } -func (s *jsonScraper) scrapeByName(ctx context.Context, name string, ty ScrapeContentType) ([]ScrapedContent, error) { - scraper := s.getJsonScraper() +type jsonNameScraper struct { + jsonScraper + definition ByNameDefinition +} - if scraper == nil { - return nil, fmt.Errorf("%w: name %v", ErrNotFound, s.scraper.Scraper) +func (s *jsonNameScraper) scrapeByName(ctx context.Context, name string, ty ScrapeContentType) ([]ScrapedContent, error) { + scraper, err := s.getJsonScraper(s.definition.Scraper) + if err != nil { + return nil, err } const placeholder = "{}" @@ -131,7 +123,7 @@ func (s *jsonScraper) scrapeByName(ctx context.Context, name string, ty ScrapeCo // replace the placeholder string with the URL-escaped name escapedName := url.QueryEscape(name) - url := s.scraper.QueryURL + url := s.definition.QueryURL url = strings.ReplaceAll(url, placeholder, escapedName) doc, err := s.loadURL(ctx, url) @@ -172,18 +164,22 @@ func (s *jsonScraper) scrapeByName(ctx context.Context, name string, ty ScrapeCo return nil, ErrNotSupported } -func (s *jsonScraper) scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*models.ScrapedScene, error) { +type jsonFragmentScraper struct { + jsonScraper + definition ByFragmentDefinition +} + +func (s *jsonFragmentScraper) scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*models.ScrapedScene, error) { // construct the URL queryURL := queryURLParametersFromScene(scene) - if s.scraper.QueryURLReplacements != nil { - queryURL.applyReplacements(s.scraper.QueryURLReplacements) + if s.definition.QueryURLReplacements != nil { + queryURL.applyReplacements(s.definition.QueryURLReplacements) } - url := queryURL.constructURL(s.scraper.QueryURL) + url := queryURL.constructURL(s.definition.QueryURL) - scraper := s.getJsonScraper() - - if scraper == nil { - return nil, errors.New("json scraper with name " + s.scraper.Scraper + " not found in config") + scraper, err := s.getJsonScraper(s.definition.Scraper) + if err != nil { + return nil, err } doc, err := s.loadURL(ctx, url) @@ -196,7 +192,7 @@ func (s *jsonScraper) scrapeSceneByScene(ctx context.Context, scene *models.Scen return scraper.scrapeScene(ctx, q) } -func (s *jsonScraper) scrapeByFragment(ctx context.Context, input Input) (ScrapedContent, error) { +func (s *jsonFragmentScraper) scrapeByFragment(ctx context.Context, input Input) (ScrapedContent, error) { switch { case input.Gallery != nil: return nil, fmt.Errorf("%w: cannot use a json scraper as a gallery fragment scraper", ErrNotSupported) @@ -210,15 +206,14 @@ func (s *jsonScraper) scrapeByFragment(ctx context.Context, input Input) (Scrape // construct the URL queryURL := queryURLParametersFromScrapedScene(scene) - if s.scraper.QueryURLReplacements != nil { - queryURL.applyReplacements(s.scraper.QueryURLReplacements) + if s.definition.QueryURLReplacements != nil { + queryURL.applyReplacements(s.definition.QueryURLReplacements) } - url := queryURL.constructURL(s.scraper.QueryURL) + url := queryURL.constructURL(s.definition.QueryURL) - scraper := s.getJsonScraper() - - if scraper == nil { - return nil, errors.New("xpath scraper with name " + s.scraper.Scraper + " not found in config") + scraper, err := s.getJsonScraper(s.definition.Scraper) + if err != nil { + return nil, err } doc, err := s.loadURL(ctx, url) @@ -231,18 +226,17 @@ func (s *jsonScraper) scrapeByFragment(ctx context.Context, input Input) (Scrape return scraper.scrapeScene(ctx, q) } -func (s *jsonScraper) scrapeImageByImage(ctx context.Context, image *models.Image) (*models.ScrapedImage, error) { +func (s *jsonFragmentScraper) scrapeImageByImage(ctx context.Context, image *models.Image) (*models.ScrapedImage, error) { // construct the URL queryURL := queryURLParametersFromImage(image) - if s.scraper.QueryURLReplacements != nil { - queryURL.applyReplacements(s.scraper.QueryURLReplacements) + if s.definition.QueryURLReplacements != nil { + queryURL.applyReplacements(s.definition.QueryURLReplacements) } - url := queryURL.constructURL(s.scraper.QueryURL) + url := queryURL.constructURL(s.definition.QueryURL) - scraper := s.getJsonScraper() - - if scraper == nil { - return nil, errors.New("json scraper with name " + s.scraper.Scraper + " not found in config") + scraper, err := s.getJsonScraper(s.definition.Scraper) + if err != nil { + return nil, err } doc, err := s.loadURL(ctx, url) @@ -255,18 +249,17 @@ func (s *jsonScraper) scrapeImageByImage(ctx context.Context, image *models.Imag return scraper.scrapeImage(ctx, q) } -func (s *jsonScraper) scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*models.ScrapedGallery, error) { +func (s *jsonFragmentScraper) scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*models.ScrapedGallery, error) { // construct the URL queryURL := queryURLParametersFromGallery(gallery) - if s.scraper.QueryURLReplacements != nil { - queryURL.applyReplacements(s.scraper.QueryURLReplacements) + if s.definition.QueryURLReplacements != nil { + queryURL.applyReplacements(s.definition.QueryURLReplacements) } - url := queryURL.constructURL(s.scraper.QueryURL) + url := queryURL.constructURL(s.definition.QueryURL) - scraper := s.getJsonScraper() - - if scraper == nil { - return nil, errors.New("json scraper with name " + s.scraper.Scraper + " not found in config") + scraper, err := s.getJsonScraper(s.definition.Scraper) + if err != nil { + return nil, err } doc, err := s.loadURL(ctx, url) diff --git a/pkg/scraper/json_test.go b/pkg/scraper/json_test.go index 249f17ad6..285c15489 100644 --- a/pkg/scraper/json_test.go +++ b/pkg/scraper/json_test.go @@ -68,7 +68,7 @@ jsonScrapers: } ` - c := &config{} + c := &Definition{} err := yaml.Unmarshal([]byte(yamlStr), &c) if err != nil { diff --git a/pkg/scraper/mapped.go b/pkg/scraper/mapped.go index 3fac22ec3..d92415c61 100644 --- a/pkg/scraper/mapped.go +++ b/pkg/scraper/mapped.go @@ -2,22 +2,9 @@ package scraper import ( "context" - "errors" - "fmt" - "math" - "net/url" - "reflect" - "regexp" - "strconv" - "strings" - "time" - "gopkg.in/yaml.v2" - - "github.com/stashapp/stash/pkg/javascript" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/sliceutil" ) type mappedQuery interface { @@ -28,850 +15,7 @@ type mappedQuery interface { getURL() string } -type commonMappedConfig map[string]string - -type mappedConfig map[string]mappedScraperAttrConfig - -func (s mappedConfig) applyCommon(c commonMappedConfig, src string) string { - if c == nil { - return src - } - - ret := src - for commonKey, commonVal := range c { - ret = strings.ReplaceAll(ret, commonKey, commonVal) - } - - return ret -} - -// extractHostname parses a URL string and returns the hostname. -// Returns empty string if the URL cannot be parsed. -func extractHostname(urlStr string) string { - if urlStr == "" { - return "" - } - - u, err := url.Parse(urlStr) - if err != nil { - logger.Warnf("Error parsing URL '%s': %s", urlStr, err.Error()) - return "" - } - - return u.Hostname() -} - -type isMultiFunc func(key string) bool - -func (s mappedConfig) process(ctx context.Context, q mappedQuery, common commonMappedConfig, isMulti isMultiFunc) mappedResults { - var ret mappedResults - - for k, attrConfig := range s { - - if attrConfig.Fixed != "" { - // TODO - not sure if this needs to set _all_ indexes for the key - const i = 0 - // Support {inputURL} and {inputHostname} placeholders in fixed values - value := strings.ReplaceAll(attrConfig.Fixed, "{inputURL}", q.getURL()) - value = strings.ReplaceAll(value, "{inputHostname}", extractHostname(q.getURL())) - ret = ret.setSingleValue(i, k, value) - } else { - selector := attrConfig.Selector - selector = s.applyCommon(common, selector) - // Support {inputURL} and {inputHostname} placeholders in selectors - selector = strings.ReplaceAll(selector, "{inputURL}", q.getURL()) - selector = strings.ReplaceAll(selector, "{inputHostname}", extractHostname(q.getURL())) - - found, err := q.runQuery(selector) - if err != nil { - logger.Warnf("key '%v': %v", k, err) - } - - if len(found) > 0 { - result := s.postProcess(ctx, q, attrConfig, found) - - // HACK - if the key is URLs, then we need to set the value as a multi-value - isMulti := isMulti != nil && isMulti(k) - if isMulti { - ret = ret.setMultiValue(0, k, result) - } else { - for i, text := range result { - ret = ret.setSingleValue(i, k, text) - } - } - } - } - } - - return ret -} - -func (s mappedConfig) postProcess(ctx context.Context, q mappedQuery, attrConfig mappedScraperAttrConfig, found []string) []string { - // check if we're concatenating the results into a single result - var ret []string - if attrConfig.hasConcat() { - result := attrConfig.concatenateResults(found) - result = attrConfig.postProcess(ctx, result, q) - if attrConfig.hasSplit() { - results := attrConfig.splitString(result) - // skip cleaning when the query is used for searching - if q.getType() == SearchQuery { - return results - } - results = attrConfig.cleanResults(results) - return results - } - - ret = []string{result} - } else { - for _, text := range found { - text = attrConfig.postProcess(ctx, text, q) - if attrConfig.hasSplit() { - return attrConfig.splitString(text) - } - - ret = append(ret, text) - } - // skip cleaning when the query is used for searching - if q.getType() == SearchQuery { - return ret - } - ret = attrConfig.cleanResults(ret) - - } - - return ret -} - -type mappedSceneScraperConfig struct { - mappedConfig - - Tags mappedConfig `yaml:"Tags"` - Performers mappedPerformerScraperConfig `yaml:"Performers"` - Studio mappedConfig `yaml:"Studio"` - Movies mappedConfig `yaml:"Movies"` - Groups mappedConfig `yaml:"Groups"` -} -type _mappedSceneScraperConfig mappedSceneScraperConfig - -const ( - mappedScraperConfigSceneTags = "Tags" - mappedScraperConfigScenePerformers = "Performers" - mappedScraperConfigSceneStudio = "Studio" - mappedScraperConfigSceneMovies = "Movies" - mappedScraperConfigSceneGroups = "Groups" -) - -func (s *mappedSceneScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { - // HACK - unmarshal to map first, then remove known scene sub-fields, then - // remarshal to yaml and pass that down to the base map - parentMap := make(map[string]interface{}) - if err := unmarshal(parentMap); err != nil { - return err - } - - // move the known sub-fields to a separate map - thisMap := make(map[string]interface{}) - - thisMap[mappedScraperConfigSceneTags] = parentMap[mappedScraperConfigSceneTags] - thisMap[mappedScraperConfigScenePerformers] = parentMap[mappedScraperConfigScenePerformers] - thisMap[mappedScraperConfigSceneStudio] = parentMap[mappedScraperConfigSceneStudio] - thisMap[mappedScraperConfigSceneMovies] = parentMap[mappedScraperConfigSceneMovies] - thisMap[mappedScraperConfigSceneGroups] = parentMap[mappedScraperConfigSceneGroups] - - delete(parentMap, mappedScraperConfigSceneTags) - delete(parentMap, mappedScraperConfigScenePerformers) - delete(parentMap, mappedScraperConfigSceneStudio) - delete(parentMap, mappedScraperConfigSceneMovies) - delete(parentMap, mappedScraperConfigSceneGroups) - - // re-unmarshal the sub-fields - yml, err := yaml.Marshal(thisMap) - if err != nil { - return err - } - - // needs to be a different type to prevent infinite recursion - c := _mappedSceneScraperConfig{} - if err := yaml.Unmarshal(yml, &c); err != nil { - return err - } - - *s = mappedSceneScraperConfig(c) - - yml, err = yaml.Marshal(parentMap) - if err != nil { - return err - } - - if err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil { - return err - } - - return nil -} - -type mappedGalleryScraperConfig struct { - mappedConfig - - Tags mappedConfig `yaml:"Tags"` - Performers mappedConfig `yaml:"Performers"` - Studio mappedConfig `yaml:"Studio"` -} - -type _mappedGalleryScraperConfig mappedGalleryScraperConfig - -func (s *mappedGalleryScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { - // HACK - unmarshal to map first, then remove known scene sub-fields, then - // remarshal to yaml and pass that down to the base map - parentMap := make(map[string]interface{}) - if err := unmarshal(parentMap); err != nil { - return err - } - - // move the known sub-fields to a separate map - thisMap := make(map[string]interface{}) - - thisMap[mappedScraperConfigSceneTags] = parentMap[mappedScraperConfigSceneTags] - thisMap[mappedScraperConfigScenePerformers] = parentMap[mappedScraperConfigScenePerformers] - thisMap[mappedScraperConfigSceneStudio] = parentMap[mappedScraperConfigSceneStudio] - - delete(parentMap, mappedScraperConfigSceneTags) - delete(parentMap, mappedScraperConfigScenePerformers) - delete(parentMap, mappedScraperConfigSceneStudio) - - // re-unmarshal the sub-fields - yml, err := yaml.Marshal(thisMap) - if err != nil { - return err - } - - // needs to be a different type to prevent infinite recursion - c := _mappedGalleryScraperConfig{} - if err := yaml.Unmarshal(yml, &c); err != nil { - return err - } - - *s = mappedGalleryScraperConfig(c) - - yml, err = yaml.Marshal(parentMap) - if err != nil { - return err - } - - if err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil { - return err - } - - return nil -} - -type mappedImageScraperConfig struct { - mappedConfig - - Tags mappedConfig `yaml:"Tags"` - Performers mappedConfig `yaml:"Performers"` - Studio mappedConfig `yaml:"Studio"` -} -type _mappedImageScraperConfig mappedImageScraperConfig - -func (s *mappedImageScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { - // HACK - unmarshal to map first, then remove known scene sub-fields, then - // remarshal to yaml and pass that down to the base map - parentMap := make(map[string]interface{}) - if err := unmarshal(parentMap); err != nil { - return err - } - - // move the known sub-fields to a separate map - thisMap := make(map[string]interface{}) - - thisMap[mappedScraperConfigSceneTags] = parentMap[mappedScraperConfigSceneTags] - thisMap[mappedScraperConfigScenePerformers] = parentMap[mappedScraperConfigScenePerformers] - thisMap[mappedScraperConfigSceneStudio] = parentMap[mappedScraperConfigSceneStudio] - - delete(parentMap, mappedScraperConfigSceneTags) - delete(parentMap, mappedScraperConfigScenePerformers) - delete(parentMap, mappedScraperConfigSceneStudio) - - // re-unmarshal the sub-fields - yml, err := yaml.Marshal(thisMap) - if err != nil { - return err - } - - // needs to be a different type to prevent infinite recursion - c := _mappedImageScraperConfig{} - if err := yaml.Unmarshal(yml, &c); err != nil { - return err - } - - *s = mappedImageScraperConfig(c) - - yml, err = yaml.Marshal(parentMap) - if err != nil { - return err - } - - if err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil { - return err - } - - return nil -} - -type mappedPerformerScraperConfig struct { - mappedConfig - - Tags mappedConfig `yaml:"Tags"` -} -type _mappedPerformerScraperConfig mappedPerformerScraperConfig - -const ( - mappedScraperConfigPerformerTags = "Tags" -) - -func (s *mappedPerformerScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { - // HACK - unmarshal to map first, then remove known scene sub-fields, then - // remarshal to yaml and pass that down to the base map - parentMap := make(map[string]interface{}) - if err := unmarshal(parentMap); err != nil { - return err - } - - // move the known sub-fields to a separate map - thisMap := make(map[string]interface{}) - - thisMap[mappedScraperConfigPerformerTags] = parentMap[mappedScraperConfigPerformerTags] - - delete(parentMap, mappedScraperConfigPerformerTags) - - // re-unmarshal the sub-fields - yml, err := yaml.Marshal(thisMap) - if err != nil { - return err - } - - // needs to be a different type to prevent infinite recursion - c := _mappedPerformerScraperConfig{} - if err := yaml.Unmarshal(yml, &c); err != nil { - return err - } - - *s = mappedPerformerScraperConfig(c) - - yml, err = yaml.Marshal(parentMap) - if err != nil { - return err - } - - if err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil { - return err - } - - return nil -} - -type mappedMovieScraperConfig struct { - mappedConfig - - Studio mappedConfig `yaml:"Studio"` - Tags mappedConfig `yaml:"Tags"` -} -type _mappedMovieScraperConfig mappedMovieScraperConfig - -const ( - mappedScraperConfigMovieStudio = "Studio" - mappedScraperConfigMovieTags = "Tags" -) - -func (s *mappedMovieScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { - // HACK - unmarshal to map first, then remove known movie sub-fields, then - // remarshal to yaml and pass that down to the base map - parentMap := make(map[string]interface{}) - if err := unmarshal(parentMap); err != nil { - return err - } - - // move the known sub-fields to a separate map - thisMap := make(map[string]interface{}) - - thisMap[mappedScraperConfigMovieStudio] = parentMap[mappedScraperConfigMovieStudio] - delete(parentMap, mappedScraperConfigMovieStudio) - - thisMap[mappedScraperConfigMovieTags] = parentMap[mappedScraperConfigMovieTags] - delete(parentMap, mappedScraperConfigMovieTags) - - // re-unmarshal the sub-fields - yml, err := yaml.Marshal(thisMap) - if err != nil { - return err - } - - // needs to be a different type to prevent infinite recursion - c := _mappedMovieScraperConfig{} - if err := yaml.Unmarshal(yml, &c); err != nil { - return err - } - - *s = mappedMovieScraperConfig(c) - - yml, err = yaml.Marshal(parentMap) - if err != nil { - return err - } - - if err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil { - return err - } - - return nil -} - -type mappedRegexConfig struct { - Regex string `yaml:"regex"` - With string `yaml:"with"` -} - -type mappedRegexConfigs []mappedRegexConfig - -func (c mappedRegexConfig) apply(value string) string { - if c.Regex != "" { - re, err := regexp.Compile(c.Regex) - if err != nil { - logger.Warnf("Error compiling regex '%s': %s", c.Regex, err.Error()) - return value - } - - ret := re.ReplaceAllString(value, c.With) - - // trim leading and trailing whitespace - // this is done to maintain backwards compatibility with existing - // scrapers - ret = strings.TrimSpace(ret) - - logger.Debugf(`Replace: '%s' with '%s'`, c.Regex, c.With) - logger.Debugf("Before: %s", value) - logger.Debugf("After: %s", ret) - return ret - } - - return value -} - -func (c mappedRegexConfigs) apply(value string) string { - // apply regex in order - for _, config := range c { - value = config.apply(value) - } - - return value -} - -type postProcessAction interface { - Apply(ctx context.Context, value string, q mappedQuery) string -} - -type postProcessParseDate string - -func (p *postProcessParseDate) Apply(ctx context.Context, value string, q mappedQuery) string { - parseDate := string(*p) - - const internalDateFormat = "2006-01-02" - - valueLower := strings.ToLower(value) - if valueLower == "today" || valueLower == "yesterday" { // handle today, yesterday - dt := time.Now() - if valueLower == "yesterday" { // subtract 1 day from now - dt = dt.AddDate(0, 0, -1) - } - return dt.Format(internalDateFormat) - } - - if parseDate == "" { - return value - } - - if parseDate == "unix" { - // try to parse the date using unix timestamp format - // if it fails, then just fall back to the original value - timeAsInt, err := strconv.ParseInt(value, 10, 64) - if err != nil { - logger.Warnf("Error parsing date string '%s' using unix timestamp format : %s", value, err.Error()) - return value - } - parsedValue := time.Unix(timeAsInt, 0) - - return parsedValue.Format(internalDateFormat) - } - - // try to parse the date using the pattern - // if it fails, then just fall back to the original value - parsedValue, err := time.Parse(parseDate, value) - if err != nil { - logger.Warnf("Error parsing date string '%s' using format '%s': %s", value, parseDate, err.Error()) - return value - } - - // convert it into our date format - return parsedValue.Format(internalDateFormat) -} - -type postProcessSubtractDays bool - -func (p *postProcessSubtractDays) Apply(ctx context.Context, value string, q mappedQuery) string { - const internalDateFormat = "2006-01-02" - - i, err := strconv.Atoi(value) - if err != nil { - logger.Warnf("Error parsing day string %s: %s", value, err) - return value - } - - dt := time.Now() - dt = dt.AddDate(0, 0, -i) - return dt.Format(internalDateFormat) -} - -type postProcessReplace mappedRegexConfigs - -func (c *postProcessReplace) Apply(ctx context.Context, value string, q mappedQuery) string { - replace := mappedRegexConfigs(*c) - return replace.apply(value) -} - -type postProcessSubScraper mappedScraperAttrConfig - -func (p *postProcessSubScraper) Apply(ctx context.Context, value string, q mappedQuery) string { - subScrapeConfig := mappedScraperAttrConfig(*p) - - logger.Debugf("Sub-scraping for: %s", value) - ss := q.subScrape(ctx, value) - - if ss != nil { - found, err := ss.runQuery(subScrapeConfig.Selector) - if err != nil { - logger.Warnf("subscrape for '%v': %v", value, err) - } - - if len(found) > 0 { - // check if we're concatenating the results into a single result - var result string - if subScrapeConfig.hasConcat() { - result = subScrapeConfig.concatenateResults(found) - } else { - result = found[0] - } - - result = subScrapeConfig.postProcess(ctx, result, ss) - return result - } - } - - return "" -} - -type postProcessMap map[string]string - -func (p *postProcessMap) Apply(ctx context.Context, value string, q mappedQuery) string { - // return the mapped value if present - m := *p - mapped, ok := m[value] - - if ok { - return mapped - } - - return value -} - -type postProcessFeetToCm bool - -func (p *postProcessFeetToCm) Apply(ctx context.Context, value string, q mappedQuery) string { - const foot_in_cm = 30.48 - const inch_in_cm = 2.54 - - reg := regexp.MustCompile("[0-9]+") - filtered := reg.FindAllString(value, -1) - - var feet float64 - var inches float64 - if len(filtered) > 0 { - feet, _ = strconv.ParseFloat(filtered[0], 64) - } - if len(filtered) > 1 { - inches, _ = strconv.ParseFloat(filtered[1], 64) - } - - var centimeters = feet*foot_in_cm + inches*inch_in_cm - - // Return rounded integer string - return strconv.Itoa(int(math.Round(centimeters))) -} - -type postProcessLbToKg bool - -func (p *postProcessLbToKg) Apply(ctx context.Context, value string, q mappedQuery) string { - const lb_in_kg = 0.45359237 - w, err := strconv.ParseFloat(value, 64) - if err == nil { - w *= lb_in_kg - value = strconv.Itoa(int(math.Round(w))) - } - return value -} - -type postProcessJavascript string - -func (p *postProcessJavascript) Apply(ctx context.Context, value string, q mappedQuery) string { - vm := javascript.NewVM() - if err := vm.Set("value", value); err != nil { - logger.Warnf("javascript failed to set value: %v", err) - return value - } - - log := &javascript.Log{ - Logger: logger.Logger, - Prefix: "", - ProgressChan: make(chan float64), - } - - if err := log.AddToVM("log", vm); err != nil { - logger.Logger.Errorf("error adding log API: %w", err) - } - - util := &javascript.Util{} - if err := util.AddToVM("util", vm); err != nil { - logger.Logger.Errorf("error adding util API: %w", err) - } - - script, err := javascript.CompileScript("", "(function() { "+string(*p)+"})()") - if err != nil { - logger.Warnf("javascript failed to compile: %v", err) - return value - } - - output, err := vm.RunProgram(script) - if err != nil { - logger.Warnf("javascript failed to run: %v", err) - return value - } - - // assume output is string - return output.String() -} - -type mappedPostProcessAction struct { - ParseDate string `yaml:"parseDate"` - SubtractDays bool `yaml:"subtractDays"` - Replace mappedRegexConfigs `yaml:"replace"` - SubScraper *mappedScraperAttrConfig `yaml:"subScraper"` - Map map[string]string `yaml:"map"` - FeetToCm bool `yaml:"feetToCm"` - LbToKg bool `yaml:"lbToKg"` - Javascript string `yaml:"javascript"` -} - -func (a mappedPostProcessAction) ToPostProcessAction() (postProcessAction, error) { - var found string - var ret postProcessAction - - ensureOnly := func(field string) error { - if found != "" { - return fmt.Errorf("post-process actions must have a single field, found %s and %s", found, field) - } - found = field - return nil - } - - if a.ParseDate != "" { - found = "parseDate" - action := postProcessParseDate(a.ParseDate) - ret = &action - } - if len(a.Replace) > 0 { - if err := ensureOnly("replace"); err != nil { - return nil, err - } - action := postProcessReplace(a.Replace) - ret = &action - } - if a.SubScraper != nil { - if err := ensureOnly("subScraper"); err != nil { - return nil, err - } - action := postProcessSubScraper(*a.SubScraper) - ret = &action - } - if a.Map != nil { - if err := ensureOnly("map"); err != nil { - return nil, err - } - action := postProcessMap(a.Map) - ret = &action - } - if a.FeetToCm { - if err := ensureOnly("feetToCm"); err != nil { - return nil, err - } - action := postProcessFeetToCm(a.FeetToCm) - ret = &action - } - if a.LbToKg { - if err := ensureOnly("lbToKg"); err != nil { - return nil, err - } - action := postProcessLbToKg(a.LbToKg) - ret = &action - } - if a.SubtractDays { - if err := ensureOnly("subtractDays"); err != nil { - return nil, err - } - action := postProcessSubtractDays(a.SubtractDays) - ret = &action - } - if a.Javascript != "" { - if err := ensureOnly("javascript"); err != nil { - return nil, err - } - action := postProcessJavascript(a.Javascript) - ret = &action - } - - if ret == nil { - return nil, errors.New("invalid post-process action") - } - - return ret, nil -} - -type mappedScraperAttrConfig struct { - Selector string `yaml:"selector"` - Fixed string `yaml:"fixed"` - PostProcess []mappedPostProcessAction `yaml:"postProcess"` - Concat string `yaml:"concat"` - Split string `yaml:"split"` - - postProcessActions []postProcessAction - - // Deprecated: use PostProcess instead - ParseDate string `yaml:"parseDate"` - Replace mappedRegexConfigs `yaml:"replace"` - SubScraper *mappedScraperAttrConfig `yaml:"subScraper"` -} - -type _mappedScraperAttrConfig mappedScraperAttrConfig - -func (c *mappedScraperAttrConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { - // try unmarshalling into a string first - if err := unmarshal(&c.Selector); err != nil { - // if it's a type error then we try to unmarshall to the full object - var typeErr *yaml.TypeError - if !errors.As(err, &typeErr) { - return err - } - - // unmarshall to full object - // need it as a separate object - t := _mappedScraperAttrConfig{} - if err = unmarshal(&t); err != nil { - return err - } - - *c = mappedScraperAttrConfig(t) - } - - return c.convertPostProcessActions() -} - -func (c *mappedScraperAttrConfig) convertPostProcessActions() error { - // ensure we don't have the old deprecated fields and the new post process field - if len(c.PostProcess) > 0 { - if c.ParseDate != "" || len(c.Replace) > 0 || c.SubScraper != nil { - return errors.New("cannot include postProcess and (parseDate, replace, subScraper) deprecated fields") - } - - // convert xpathPostProcessAction actions to postProcessActions - for _, a := range c.PostProcess { - action, err := a.ToPostProcessAction() - if err != nil { - return err - } - c.postProcessActions = append(c.postProcessActions, action) - } - - c.PostProcess = nil - } else { - // convert old deprecated fields if present - // in same order as they used to be executed - if len(c.Replace) > 0 { - action := postProcessReplace(c.Replace) - c.postProcessActions = append(c.postProcessActions, &action) - c.Replace = nil - } - - if c.SubScraper != nil { - action := postProcessSubScraper(*c.SubScraper) - c.postProcessActions = append(c.postProcessActions, &action) - c.SubScraper = nil - } - - if c.ParseDate != "" { - action := postProcessParseDate(c.ParseDate) - c.postProcessActions = append(c.postProcessActions, &action) - c.ParseDate = "" - } - } - - return nil -} - -func (c mappedScraperAttrConfig) hasConcat() bool { - return c.Concat != "" -} - -func (c mappedScraperAttrConfig) hasSplit() bool { - return c.Split != "" -} - -func (c mappedScraperAttrConfig) concatenateResults(nodes []string) string { - separator := c.Concat - return strings.Join(nodes, separator) -} - -func (c mappedScraperAttrConfig) cleanResults(nodes []string) []string { - cleaned := sliceutil.Unique(nodes) // remove duplicate values - cleaned = sliceutil.Delete(cleaned, "") // remove empty values - return cleaned -} - -func (c mappedScraperAttrConfig) splitString(value string) []string { - separator := c.Split - var res []string - - if separator == "" { - return []string{value} - } - - for _, str := range strings.Split(value, separator) { - if str != "" { - res = append(res, str) - } - } - - return res -} - -func (c mappedScraperAttrConfig) postProcess(ctx context.Context, value string, q mappedQuery) string { - for _, action := range c.postProcessActions { - value = action.Apply(ctx, value, q) - } - - return value -} - -type mappedScrapers map[string]*mappedScraper +type mappedScrapers map[string]mappedScraper type mappedScraper struct { Common commonMappedConfig `yaml:"common"` @@ -885,102 +29,12 @@ type mappedScraper struct { Movie *mappedMovieScraperConfig `yaml:"movie"` } -type mappedResult map[string]interface{} -type mappedResults []mappedResult - -func (r mappedResult) apply(dest interface{}) { - destVal := reflect.ValueOf(dest).Elem() - - // all fields are either string pointers or string slices - for key, value := range r { - if err := mapFieldValue(destVal, key, value); err != nil { - logger.Errorf("Error mapping field %s in %T: %v", key, dest, err) - } - } -} - -func mapFieldValue(destVal reflect.Value, key string, value interface{}) error { - field := destVal.FieldByName(key) - - if !field.IsValid() { - return fmt.Errorf("field %s does not exist on %s", key, destVal.Type().Name()) - } - - if !field.CanSet() { - return fmt.Errorf("field %s cannot be set on %s", key, destVal.Type().Name()) - } - - fieldType := field.Type() - - switch v := value.(type) { - case string: - // if the field is a pointer to a string, then we need to convert the string to a pointer - // if the field is a string slice, then we need to convert the string to a slice - switch { - case fieldType.Kind() == reflect.String: - field.SetString(v) - case fieldType.Kind() == reflect.Ptr && fieldType.Elem().Kind() == reflect.String: - ptr := reflect.New(fieldType.Elem()) - ptr.Elem().SetString(v) - field.Set(ptr) - case fieldType.Kind() == reflect.Slice && fieldType.Elem().Kind() == reflect.String: - field.Set(reflect.ValueOf([]string{v})) - default: - return fmt.Errorf("cannot convert %T to %s", value, fieldType) - } - case []string: - // expect the field to be a string slice - if fieldType.Kind() == reflect.Slice && fieldType.Elem().Kind() == reflect.String { - field.Set(reflect.ValueOf(v)) - } else { - return fmt.Errorf("cannot convert %T to %s", value, fieldType) - } - default: - // fallback to reflection - reflectValue := reflect.ValueOf(value) - reflectValueType := reflectValue.Type() - - switch { - case reflectValueType.ConvertibleTo(fieldType): - field.Set(reflectValue.Convert(fieldType)) - case fieldType.Kind() == reflect.Pointer && reflectValueType.ConvertibleTo(fieldType.Elem()): - ptr := reflect.New(fieldType.Elem()) - ptr.Elem().Set(reflectValue.Convert(fieldType.Elem())) - field.Set(ptr) - default: - return fmt.Errorf("cannot convert %T to %s", value, fieldType) - } - } - - return nil -} - -func (r mappedResults) setSingleValue(index int, key string, value string) mappedResults { - if index >= len(r) { - r = append(r, make(mappedResult)) - } - - logger.Debugf(`[%d][%s] = %s`, index, key, value) - r[index][key] = value - return r -} - -func (r mappedResults) setMultiValue(index int, key string, value []string) mappedResults { - if index >= len(r) { - r = append(r, make(mappedResult)) - } - - logger.Debugf(`[%d][%s] = %s`, index, key, value) - r[index][key] = value - return r -} - func urlsIsMulti(key string) bool { return key == "URLs" } func (s mappedScraper) scrapePerformer(ctx context.Context, q mappedQuery) (*models.ScrapedPerformer, error) { - var ret models.ScrapedPerformer + var ret *models.ScrapedPerformer performerMap := s.Performer if performerMap == nil { @@ -992,31 +46,26 @@ func (s mappedScraper) scrapePerformer(ctx context.Context, q mappedQuery) (*mod results := performerMap.process(ctx, q, s.Common, urlsIsMulti) // now apply the tags + var tagResults mappedResults + if performerTagsMap != nil { logger.Debug(`Processing performer tags:`) - tagResults := performerTagsMap.process(ctx, q, s.Common, nil) - - for _, p := range tagResults { - tag := &models.ScrapedTag{} - p.apply(tag) - ret.Tags = append(ret.Tags, tag) - } + tagResults = performerTagsMap.process(ctx, q, s.Common, nil) } - if len(results) == 0 && len(ret.Tags) == 0 { + if len(results) == 0 { return nil, nil } if len(results) > 0 { - results[0].apply(&ret) + ret = results[0].scrapedPerformer() + ret.Tags = tagResults.scrapedTags() } - return &ret, nil + return ret, nil } func (s mappedScraper) scrapePerformers(ctx context.Context, q mappedQuery) ([]*models.ScrapedPerformer, error) { - var ret []*models.ScrapedPerformer - performerMap := s.Performer if performerMap == nil { return nil, nil @@ -1024,13 +73,7 @@ func (s mappedScraper) scrapePerformers(ctx context.Context, q mappedQuery) ([]* // isMulti is nil because it will behave incorrect when scraping multiple performers results := performerMap.process(ctx, q, s.Common, nil) - for _, r := range results { - var p models.ScrapedPerformer - r.apply(&p) - ret = append(ret, &p) - } - - return ret, nil + return results.scrapedPerformers(), nil } // processSceneRelationships sets the relationships on the models.ScrapedScene. It returns true if any relationships were set. @@ -1048,7 +91,7 @@ func (s mappedScraper) processSceneRelationships(ctx context.Context, q mappedQu if sceneTagsMap != nil { logger.Debug(`Processing scene tags:`) - ret.Tags = processRelationships[models.ScrapedTag](ctx, s, sceneTagsMap, q) + ret.Tags = sceneTagsMap.process(ctx, q, s.Common, nil).scrapedTags() } if sceneStudioMap != nil { @@ -1056,21 +99,20 @@ func (s mappedScraper) processSceneRelationships(ctx context.Context, q mappedQu studioResults := sceneStudioMap.process(ctx, q, s.Common, nil) if len(studioResults) > 0 && resultIndex < len(studioResults) { - studio := &models.ScrapedStudio{} // when doing a `search` scrape get the related studio - studioResults[resultIndex].apply(studio) + studio := studioResults[resultIndex].scrapedStudio() ret.Studio = studio } } if sceneMoviesMap != nil { logger.Debug(`Processing scene movies:`) - ret.Movies = processRelationships[models.ScrapedMovie](ctx, s, sceneMoviesMap, q) + ret.Movies = sceneMoviesMap.process(ctx, q, s.Common, nil).scrapedMovies() } if sceneGroupsMap != nil { logger.Debug(`Processing scene groups:`) - ret.Groups = processRelationships[models.ScrapedGroup](ctx, s, sceneGroupsMap, q) + ret.Groups = sceneGroupsMap.process(ctx, q, s.Common, nil).scrapedGroups() } return len(ret.Performers) > 0 || len(ret.Tags) > 0 || ret.Studio != nil || len(ret.Movies) > 0 || len(ret.Groups) > 0 @@ -1094,12 +136,10 @@ func (s mappedScraper) processPerformers(ctx context.Context, performersMap mapp } for _, p := range performerResults { - performer := &models.ScrapedPerformer{} - p.apply(performer) + performer := p.scrapedPerformer() for _, p := range performerTagResults { - tag := &models.ScrapedTag{} - p.apply(tag) + tag := p.scrapedTag() performer.Tags = append(performer.Tags, tag) } @@ -1110,20 +150,6 @@ func (s mappedScraper) processPerformers(ctx context.Context, performersMap mapp return ret } -func processRelationships[T any](ctx context.Context, s mappedScraper, relationshipMap mappedConfig, q mappedQuery) []*T { - var ret []*T - - results := relationshipMap.process(ctx, q, s.Common, nil) - - for _, p := range results { - var value T - p.apply(&value) - ret = append(ret, &value) - } - - return ret -} - func (s mappedScraper) scrapeScenes(ctx context.Context, q mappedQuery) ([]*models.ScrapedScene, error) { var ret []*models.ScrapedScene @@ -1139,10 +165,9 @@ func (s mappedScraper) scrapeScenes(ctx context.Context, q mappedQuery) ([]*mode for i, r := range results { logger.Debug(`Processing scene:`) - var thisScene models.ScrapedScene - r.apply(&thisScene) - s.processSceneRelationships(ctx, q, i, &thisScene) - ret = append(ret, &thisScene) + thisScene := r.scrapedScene() + s.processSceneRelationships(ctx, q, i, thisScene) + ret = append(ret, thisScene) } return ret, nil @@ -1159,17 +184,17 @@ func (s mappedScraper) scrapeScene(ctx context.Context, q mappedQuery) (*models. logger.Debug(`Processing scene:`) results := sceneMap.process(ctx, q, s.Common, urlsIsMulti) - var ret models.ScrapedScene + var ret *models.ScrapedScene if len(results) > 0 { - results[0].apply(&ret) + ret = results[0].scrapedScene() } - hasRelationships := s.processSceneRelationships(ctx, q, 0, &ret) + hasRelationships := s.processSceneRelationships(ctx, q, 0, ret) // #3953 - process only returns results if the non-relationship fields are // populated // only return if we have results or relationships if len(results) > 0 || hasRelationships { - return &ret, nil + return ret, nil } return nil, nil @@ -1192,15 +217,19 @@ func (s mappedScraper) scrapeImage(ctx context.Context, q mappedQuery) (*models. logger.Debug(`Processing image:`) results := imageMap.process(ctx, q, s.Common, urlsIsMulti) + if len(results) > 0 { + ret = *results[0].scrapedImage() + } + // now apply the performers and tags if imagePerformersMap != nil { logger.Debug(`Processing image performers:`) - ret.Performers = processRelationships[models.ScrapedPerformer](ctx, s, imagePerformersMap, q) + ret.Performers = imagePerformersMap.process(ctx, q, s.Common, nil).scrapedPerformers() } if imageTagsMap != nil { logger.Debug(`Processing image tags:`) - ret.Tags = processRelationships[models.ScrapedTag](ctx, s, imageTagsMap, q) + ret.Tags = imageTagsMap.process(ctx, q, s.Common, nil).scrapedTags() } if imageStudioMap != nil { @@ -1208,9 +237,7 @@ func (s mappedScraper) scrapeImage(ctx context.Context, q mappedQuery) (*models. studioResults := imageStudioMap.process(ctx, q, s.Common, nil) if len(studioResults) > 0 { - studio := &models.ScrapedStudio{} - studioResults[0].apply(studio) - ret.Studio = studio + ret.Studio = studioResults[0].scrapedStudio() } } @@ -1219,10 +246,6 @@ func (s mappedScraper) scrapeImage(ctx context.Context, q mappedQuery) (*models. return nil, nil } - if len(results) > 0 { - results[0].apply(&ret) - } - return &ret, nil } @@ -1243,27 +266,22 @@ func (s mappedScraper) scrapeGallery(ctx context.Context, q mappedQuery) (*model logger.Debug(`Processing gallery:`) results := galleryMap.process(ctx, q, s.Common, urlsIsMulti) + if len(results) > 0 { + ret = *results[0].scrapedGallery() + } + // now apply the performers and tags if galleryPerformersMap != nil { logger.Debug(`Processing gallery performers:`) performerResults := galleryPerformersMap.process(ctx, q, s.Common, urlsIsMulti) - for _, p := range performerResults { - performer := &models.ScrapedPerformer{} - p.apply(performer) - ret.Performers = append(ret.Performers, performer) - } + ret.Performers = performerResults.scrapedPerformers() } if galleryTagsMap != nil { logger.Debug(`Processing gallery tags:`) tagResults := galleryTagsMap.process(ctx, q, s.Common, nil) - - for _, p := range tagResults { - tag := &models.ScrapedTag{} - p.apply(tag) - ret.Tags = append(ret.Tags, tag) - } + ret.Tags = tagResults.scrapedTags() } if galleryStudioMap != nil { @@ -1271,9 +289,7 @@ func (s mappedScraper) scrapeGallery(ctx context.Context, q mappedQuery) (*model studioResults := galleryStudioMap.process(ctx, q, s.Common, nil) if len(studioResults) > 0 { - studio := &models.ScrapedStudio{} - studioResults[0].apply(studio) - ret.Studio = studio + ret.Studio = studioResults[0].scrapedStudio() } } @@ -1282,10 +298,6 @@ func (s mappedScraper) scrapeGallery(ctx context.Context, q mappedQuery) (*model return nil, nil } - if len(results) > 0 { - results[0].apply(&ret) - } - return &ret, nil } @@ -1309,14 +321,16 @@ func (s mappedScraper) scrapeGroup(ctx context.Context, q mappedQuery) (*models. results := groupMap.process(ctx, q, s.Common, urlsIsMulti) + if len(results) > 0 { + ret = *results[0].scrapedGroup() + } + if groupStudioMap != nil { logger.Debug(`Processing group studio:`) studioResults := groupStudioMap.process(ctx, q, s.Common, nil) if len(studioResults) > 0 { - studio := &models.ScrapedStudio{} - studioResults[0].apply(studio) - ret.Studio = studio + ret.Studio = studioResults[0].scrapedStudio() } } @@ -1325,20 +339,12 @@ func (s mappedScraper) scrapeGroup(ctx context.Context, q mappedQuery) (*models. logger.Debug(`Processing group tags:`) tagResults := groupTagsMap.process(ctx, q, s.Common, nil) - for _, p := range tagResults { - tag := &models.ScrapedTag{} - p.apply(tag) - ret.Tags = append(ret.Tags, tag) - } + ret.Tags = tagResults.scrapedTags() } if len(results) == 0 && ret.Studio == nil && len(ret.Tags) == 0 { return nil, nil } - if len(results) > 0 { - results[0].apply(&ret) - } - return &ret, nil } diff --git a/pkg/scraper/mapped_config.go b/pkg/scraper/mapped_config.go new file mode 100644 index 000000000..920bf74b4 --- /dev/null +++ b/pkg/scraper/mapped_config.go @@ -0,0 +1,537 @@ +package scraper + +import ( + "context" + "errors" + "net/url" + "strings" + + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/sliceutil" + "gopkg.in/yaml.v2" +) + +type commonMappedConfig map[string]string + +type mappedConfig map[string]mappedScraperAttrConfig + +func (s mappedConfig) applyCommon(c commonMappedConfig, src string) string { + if c == nil { + return src + } + + ret := src + for commonKey, commonVal := range c { + ret = strings.ReplaceAll(ret, commonKey, commonVal) + } + + return ret +} + +// extractHostname parses a URL string and returns the hostname. +// Returns empty string if the URL cannot be parsed. +func extractHostname(urlStr string) string { + if urlStr == "" { + return "" + } + + u, err := url.Parse(urlStr) + if err != nil { + logger.Warnf("Error parsing URL '%s': %s", urlStr, err.Error()) + return "" + } + + return u.Hostname() +} + +type isMultiFunc func(key string) bool + +func (s mappedConfig) process(ctx context.Context, q mappedQuery, common commonMappedConfig, isMulti isMultiFunc) mappedResults { + var ret mappedResults + + for k, attrConfig := range s { + + if attrConfig.Fixed != "" { + // TODO - not sure if this needs to set _all_ indexes for the key + const i = 0 + // Support {inputURL} and {inputHostname} placeholders in fixed values + value := strings.ReplaceAll(attrConfig.Fixed, "{inputURL}", q.getURL()) + value = strings.ReplaceAll(value, "{inputHostname}", extractHostname(q.getURL())) + ret = ret.setSingleValue(i, k, value) + } else { + selector := attrConfig.Selector + selector = s.applyCommon(common, selector) + // Support {inputURL} and {inputHostname} placeholders in selectors + selector = strings.ReplaceAll(selector, "{inputURL}", q.getURL()) + selector = strings.ReplaceAll(selector, "{inputHostname}", extractHostname(q.getURL())) + + found, err := q.runQuery(selector) + if err != nil { + logger.Warnf("key '%v': %v", k, err) + } + + if len(found) > 0 { + result := s.postProcess(ctx, q, attrConfig, found) + + // HACK - if the key is URLs, then we need to set the value as a multi-value + isMulti := isMulti != nil && isMulti(k) + if isMulti { + ret = ret.setMultiValue(0, k, result) + } else { + for i, text := range result { + ret = ret.setSingleValue(i, k, text) + } + } + } + } + } + + return ret +} + +func (s mappedConfig) postProcess(ctx context.Context, q mappedQuery, attrConfig mappedScraperAttrConfig, found []string) []string { + // check if we're concatenating the results into a single result + var ret []string + if attrConfig.hasConcat() { + result := attrConfig.concatenateResults(found) + result = attrConfig.postProcess(ctx, result, q) + if attrConfig.hasSplit() { + results := attrConfig.splitString(result) + // skip cleaning when the query is used for searching + if q.getType() == SearchQuery { + return results + } + results = attrConfig.cleanResults(results) + return results + } + + ret = []string{result} + } else { + for _, text := range found { + text = attrConfig.postProcess(ctx, text, q) + if attrConfig.hasSplit() { + return attrConfig.splitString(text) + } + + ret = append(ret, text) + } + // skip cleaning when the query is used for searching + if q.getType() == SearchQuery { + return ret + } + ret = attrConfig.cleanResults(ret) + + } + + return ret +} + +type mappedSceneScraperConfig struct { + mappedConfig + + Tags mappedConfig `yaml:"Tags"` + Performers mappedPerformerScraperConfig `yaml:"Performers"` + Studio mappedConfig `yaml:"Studio"` + Movies mappedConfig `yaml:"Movies"` + Groups mappedConfig `yaml:"Groups"` +} +type _mappedSceneScraperConfig mappedSceneScraperConfig + +const ( + mappedScraperConfigSceneTags = "Tags" + mappedScraperConfigScenePerformers = "Performers" + mappedScraperConfigSceneStudio = "Studio" + mappedScraperConfigSceneMovies = "Movies" + mappedScraperConfigSceneGroups = "Groups" +) + +func (s *mappedSceneScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { + // HACK - unmarshal to map first, then remove known scene sub-fields, then + // remarshal to yaml and pass that down to the base map + parentMap := make(map[string]interface{}) + if err := unmarshal(parentMap); err != nil { + return err + } + + // move the known sub-fields to a separate map + thisMap := make(map[string]interface{}) + + thisMap[mappedScraperConfigSceneTags] = parentMap[mappedScraperConfigSceneTags] + thisMap[mappedScraperConfigScenePerformers] = parentMap[mappedScraperConfigScenePerformers] + thisMap[mappedScraperConfigSceneStudio] = parentMap[mappedScraperConfigSceneStudio] + thisMap[mappedScraperConfigSceneMovies] = parentMap[mappedScraperConfigSceneMovies] + thisMap[mappedScraperConfigSceneGroups] = parentMap[mappedScraperConfigSceneGroups] + + delete(parentMap, mappedScraperConfigSceneTags) + delete(parentMap, mappedScraperConfigScenePerformers) + delete(parentMap, mappedScraperConfigSceneStudio) + delete(parentMap, mappedScraperConfigSceneMovies) + delete(parentMap, mappedScraperConfigSceneGroups) + + // re-unmarshal the sub-fields + yml, err := yaml.Marshal(thisMap) + if err != nil { + return err + } + + // needs to be a different type to prevent infinite recursion + c := _mappedSceneScraperConfig{} + if err := yaml.Unmarshal(yml, &c); err != nil { + return err + } + + *s = mappedSceneScraperConfig(c) + + yml, err = yaml.Marshal(parentMap) + if err != nil { + return err + } + + if err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil { + return err + } + + return nil +} + +type mappedGalleryScraperConfig struct { + mappedConfig + + Tags mappedConfig `yaml:"Tags"` + Performers mappedConfig `yaml:"Performers"` + Studio mappedConfig `yaml:"Studio"` +} + +type _mappedGalleryScraperConfig mappedGalleryScraperConfig + +func (s *mappedGalleryScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { + // HACK - unmarshal to map first, then remove known scene sub-fields, then + // remarshal to yaml and pass that down to the base map + parentMap := make(map[string]interface{}) + if err := unmarshal(parentMap); err != nil { + return err + } + + // move the known sub-fields to a separate map + thisMap := make(map[string]interface{}) + + thisMap[mappedScraperConfigSceneTags] = parentMap[mappedScraperConfigSceneTags] + thisMap[mappedScraperConfigScenePerformers] = parentMap[mappedScraperConfigScenePerformers] + thisMap[mappedScraperConfigSceneStudio] = parentMap[mappedScraperConfigSceneStudio] + + delete(parentMap, mappedScraperConfigSceneTags) + delete(parentMap, mappedScraperConfigScenePerformers) + delete(parentMap, mappedScraperConfigSceneStudio) + + // re-unmarshal the sub-fields + yml, err := yaml.Marshal(thisMap) + if err != nil { + return err + } + + // needs to be a different type to prevent infinite recursion + c := _mappedGalleryScraperConfig{} + if err := yaml.Unmarshal(yml, &c); err != nil { + return err + } + + *s = mappedGalleryScraperConfig(c) + + yml, err = yaml.Marshal(parentMap) + if err != nil { + return err + } + + if err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil { + return err + } + + return nil +} + +type mappedImageScraperConfig struct { + mappedConfig + + Tags mappedConfig `yaml:"Tags"` + Performers mappedConfig `yaml:"Performers"` + Studio mappedConfig `yaml:"Studio"` +} +type _mappedImageScraperConfig mappedImageScraperConfig + +func (s *mappedImageScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { + // HACK - unmarshal to map first, then remove known scene sub-fields, then + // remarshal to yaml and pass that down to the base map + parentMap := make(map[string]interface{}) + if err := unmarshal(parentMap); err != nil { + return err + } + + // move the known sub-fields to a separate map + thisMap := make(map[string]interface{}) + + thisMap[mappedScraperConfigSceneTags] = parentMap[mappedScraperConfigSceneTags] + thisMap[mappedScraperConfigScenePerformers] = parentMap[mappedScraperConfigScenePerformers] + thisMap[mappedScraperConfigSceneStudio] = parentMap[mappedScraperConfigSceneStudio] + + delete(parentMap, mappedScraperConfigSceneTags) + delete(parentMap, mappedScraperConfigScenePerformers) + delete(parentMap, mappedScraperConfigSceneStudio) + + // re-unmarshal the sub-fields + yml, err := yaml.Marshal(thisMap) + if err != nil { + return err + } + + // needs to be a different type to prevent infinite recursion + c := _mappedImageScraperConfig{} + if err := yaml.Unmarshal(yml, &c); err != nil { + return err + } + + *s = mappedImageScraperConfig(c) + + yml, err = yaml.Marshal(parentMap) + if err != nil { + return err + } + + if err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil { + return err + } + + return nil +} + +type mappedPerformerScraperConfig struct { + mappedConfig + + Tags mappedConfig `yaml:"Tags"` +} +type _mappedPerformerScraperConfig mappedPerformerScraperConfig + +const ( + mappedScraperConfigPerformerTags = "Tags" +) + +func (s *mappedPerformerScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { + // HACK - unmarshal to map first, then remove known scene sub-fields, then + // remarshal to yaml and pass that down to the base map + parentMap := make(map[string]interface{}) + if err := unmarshal(parentMap); err != nil { + return err + } + + // move the known sub-fields to a separate map + thisMap := make(map[string]interface{}) + + thisMap[mappedScraperConfigPerformerTags] = parentMap[mappedScraperConfigPerformerTags] + + delete(parentMap, mappedScraperConfigPerformerTags) + + // re-unmarshal the sub-fields + yml, err := yaml.Marshal(thisMap) + if err != nil { + return err + } + + // needs to be a different type to prevent infinite recursion + c := _mappedPerformerScraperConfig{} + if err := yaml.Unmarshal(yml, &c); err != nil { + return err + } + + *s = mappedPerformerScraperConfig(c) + + yml, err = yaml.Marshal(parentMap) + if err != nil { + return err + } + + if err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil { + return err + } + + return nil +} + +type mappedMovieScraperConfig struct { + mappedConfig + + Studio mappedConfig `yaml:"Studio"` + Tags mappedConfig `yaml:"Tags"` +} +type _mappedMovieScraperConfig mappedMovieScraperConfig + +const ( + mappedScraperConfigMovieStudio = "Studio" + mappedScraperConfigMovieTags = "Tags" +) + +func (s *mappedMovieScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { + // HACK - unmarshal to map first, then remove known movie sub-fields, then + // remarshal to yaml and pass that down to the base map + parentMap := make(map[string]interface{}) + if err := unmarshal(parentMap); err != nil { + return err + } + + // move the known sub-fields to a separate map + thisMap := make(map[string]interface{}) + + thisMap[mappedScraperConfigMovieStudio] = parentMap[mappedScraperConfigMovieStudio] + delete(parentMap, mappedScraperConfigMovieStudio) + + thisMap[mappedScraperConfigMovieTags] = parentMap[mappedScraperConfigMovieTags] + delete(parentMap, mappedScraperConfigMovieTags) + + // re-unmarshal the sub-fields + yml, err := yaml.Marshal(thisMap) + if err != nil { + return err + } + + // needs to be a different type to prevent infinite recursion + c := _mappedMovieScraperConfig{} + if err := yaml.Unmarshal(yml, &c); err != nil { + return err + } + + *s = mappedMovieScraperConfig(c) + + yml, err = yaml.Marshal(parentMap) + if err != nil { + return err + } + + if err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil { + return err + } + + return nil +} + +type mappedScraperAttrConfig struct { + Selector string `yaml:"selector"` + Fixed string `yaml:"fixed"` + PostProcess []mappedPostProcessAction `yaml:"postProcess"` + Concat string `yaml:"concat"` + Split string `yaml:"split"` + + postProcessActions []postProcessAction + + // Deprecated: use PostProcess instead + ParseDate string `yaml:"parseDate"` + Replace mappedRegexConfigs `yaml:"replace"` + SubScraper *mappedScraperAttrConfig `yaml:"subScraper"` +} + +type _mappedScraperAttrConfig mappedScraperAttrConfig + +func (c *mappedScraperAttrConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { + // try unmarshalling into a string first + if err := unmarshal(&c.Selector); err != nil { + // if it's a type error then we try to unmarshall to the full object + var typeErr *yaml.TypeError + if !errors.As(err, &typeErr) { + return err + } + + // unmarshall to full object + // need it as a separate object + t := _mappedScraperAttrConfig{} + if err = unmarshal(&t); err != nil { + return err + } + + *c = mappedScraperAttrConfig(t) + } + + return c.convertPostProcessActions() +} + +func (c *mappedScraperAttrConfig) convertPostProcessActions() error { + // ensure we don't have the old deprecated fields and the new post process field + if len(c.PostProcess) > 0 { + if c.ParseDate != "" || len(c.Replace) > 0 || c.SubScraper != nil { + return errors.New("cannot include postProcess and (parseDate, replace, subScraper) deprecated fields") + } + + // convert xpathPostProcessAction actions to postProcessActions + for _, a := range c.PostProcess { + action, err := a.ToPostProcessAction() + if err != nil { + return err + } + c.postProcessActions = append(c.postProcessActions, action) + } + + c.PostProcess = nil + } else { + // convert old deprecated fields if present + // in same order as they used to be executed + if len(c.Replace) > 0 { + action := postProcessReplace(c.Replace) + c.postProcessActions = append(c.postProcessActions, &action) + c.Replace = nil + } + + if c.SubScraper != nil { + action := postProcessSubScraper(*c.SubScraper) + c.postProcessActions = append(c.postProcessActions, &action) + c.SubScraper = nil + } + + if c.ParseDate != "" { + action := postProcessParseDate(c.ParseDate) + c.postProcessActions = append(c.postProcessActions, &action) + c.ParseDate = "" + } + } + + return nil +} + +func (c mappedScraperAttrConfig) hasConcat() bool { + return c.Concat != "" +} + +func (c mappedScraperAttrConfig) hasSplit() bool { + return c.Split != "" +} + +func (c mappedScraperAttrConfig) concatenateResults(nodes []string) string { + separator := c.Concat + return strings.Join(nodes, separator) +} + +func (c mappedScraperAttrConfig) cleanResults(nodes []string) []string { + cleaned := sliceutil.Unique(nodes) // remove duplicate values + cleaned = sliceutil.Delete(cleaned, "") // remove empty values + return cleaned +} + +func (c mappedScraperAttrConfig) splitString(value string) []string { + separator := c.Split + var res []string + + if separator == "" { + return []string{value} + } + + for _, str := range strings.Split(value, separator) { + if str != "" { + res = append(res, str) + } + } + + return res +} + +func (c mappedScraperAttrConfig) postProcess(ctx context.Context, value string, q mappedQuery) string { + for _, action := range c.postProcessActions { + value = action.Apply(ctx, value, q) + } + + return value +} diff --git a/pkg/scraper/mapped_postprocessing.go b/pkg/scraper/mapped_postprocessing.go new file mode 100644 index 000000000..22a8b748a --- /dev/null +++ b/pkg/scraper/mapped_postprocessing.go @@ -0,0 +1,333 @@ +package scraper + +import ( + "context" + "errors" + "fmt" + "math" + "regexp" + "strconv" + "strings" + "time" + + "github.com/stashapp/stash/pkg/javascript" + "github.com/stashapp/stash/pkg/logger" +) + +type mappedRegexConfig struct { + Regex string `yaml:"regex"` + With string `yaml:"with"` +} + +type mappedRegexConfigs []mappedRegexConfig + +func (c mappedRegexConfig) apply(value string) string { + if c.Regex != "" { + re, err := regexp.Compile(c.Regex) + if err != nil { + logger.Warnf("Error compiling regex '%s': %s", c.Regex, err.Error()) + return value + } + + ret := re.ReplaceAllString(value, c.With) + + // trim leading and trailing whitespace + // this is done to maintain backwards compatibility with existing + // scrapers + ret = strings.TrimSpace(ret) + + logger.Debugf(`Replace: '%s' with '%s'`, c.Regex, c.With) + logger.Debugf("Before: %s", value) + logger.Debugf("After: %s", ret) + return ret + } + + return value +} + +func (c mappedRegexConfigs) apply(value string) string { + // apply regex in order + for _, config := range c { + value = config.apply(value) + } + + return value +} + +type postProcessAction interface { + Apply(ctx context.Context, value string, q mappedQuery) string +} + +type postProcessParseDate string + +func (p *postProcessParseDate) Apply(ctx context.Context, value string, q mappedQuery) string { + parseDate := string(*p) + + const internalDateFormat = "2006-01-02" + + valueLower := strings.ToLower(value) + if valueLower == "today" || valueLower == "yesterday" { // handle today, yesterday + dt := time.Now() + if valueLower == "yesterday" { // subtract 1 day from now + dt = dt.AddDate(0, 0, -1) + } + return dt.Format(internalDateFormat) + } + + if parseDate == "" { + return value + } + + if parseDate == "unix" { + // try to parse the date using unix timestamp format + // if it fails, then just fall back to the original value + timeAsInt, err := strconv.ParseInt(value, 10, 64) + if err != nil { + logger.Warnf("Error parsing date string '%s' using unix timestamp format : %s", value, err.Error()) + return value + } + parsedValue := time.Unix(timeAsInt, 0) + + return parsedValue.Format(internalDateFormat) + } + + // try to parse the date using the pattern + // if it fails, then just fall back to the original value + parsedValue, err := time.Parse(parseDate, value) + if err != nil { + logger.Warnf("Error parsing date string '%s' using format '%s': %s", value, parseDate, err.Error()) + return value + } + + // convert it into our date format + return parsedValue.Format(internalDateFormat) +} + +type postProcessSubtractDays bool + +func (p *postProcessSubtractDays) Apply(ctx context.Context, value string, q mappedQuery) string { + const internalDateFormat = "2006-01-02" + + i, err := strconv.Atoi(value) + if err != nil { + logger.Warnf("Error parsing day string %s: %s", value, err) + return value + } + + dt := time.Now() + dt = dt.AddDate(0, 0, -i) + return dt.Format(internalDateFormat) +} + +type postProcessReplace mappedRegexConfigs + +func (c *postProcessReplace) Apply(ctx context.Context, value string, q mappedQuery) string { + replace := mappedRegexConfigs(*c) + return replace.apply(value) +} + +type postProcessSubScraper mappedScraperAttrConfig + +func (p *postProcessSubScraper) Apply(ctx context.Context, value string, q mappedQuery) string { + subScrapeConfig := mappedScraperAttrConfig(*p) + + logger.Debugf("Sub-scraping for: %s", value) + ss := q.subScrape(ctx, value) + + if ss != nil { + found, err := ss.runQuery(subScrapeConfig.Selector) + if err != nil { + logger.Warnf("subscrape for '%v': %v", value, err) + } + + if len(found) > 0 { + // check if we're concatenating the results into a single result + var result string + if subScrapeConfig.hasConcat() { + result = subScrapeConfig.concatenateResults(found) + } else { + result = found[0] + } + + result = subScrapeConfig.postProcess(ctx, result, ss) + return result + } + } + + return "" +} + +type postProcessMap map[string]string + +func (p *postProcessMap) Apply(ctx context.Context, value string, q mappedQuery) string { + // return the mapped value if present + m := *p + mapped, ok := m[value] + + if ok { + return mapped + } + + return value +} + +type postProcessFeetToCm bool + +func (p *postProcessFeetToCm) Apply(ctx context.Context, value string, q mappedQuery) string { + const foot_in_cm = 30.48 + const inch_in_cm = 2.54 + + reg := regexp.MustCompile("[0-9]+") + filtered := reg.FindAllString(value, -1) + + var feet float64 + var inches float64 + if len(filtered) > 0 { + feet, _ = strconv.ParseFloat(filtered[0], 64) + } + if len(filtered) > 1 { + inches, _ = strconv.ParseFloat(filtered[1], 64) + } + + var centimeters = feet*foot_in_cm + inches*inch_in_cm + + // Return rounded integer string + return strconv.Itoa(int(math.Round(centimeters))) +} + +type postProcessLbToKg bool + +func (p *postProcessLbToKg) Apply(ctx context.Context, value string, q mappedQuery) string { + const lb_in_kg = 0.45359237 + w, err := strconv.ParseFloat(value, 64) + if err == nil { + w *= lb_in_kg + value = strconv.Itoa(int(math.Round(w))) + } + return value +} + +type postProcessJavascript string + +func (p *postProcessJavascript) Apply(ctx context.Context, value string, q mappedQuery) string { + vm := javascript.NewVM() + if err := vm.Set("value", value); err != nil { + logger.Warnf("javascript failed to set value: %v", err) + return value + } + + log := &javascript.Log{ + Logger: logger.Logger, + Prefix: "", + ProgressChan: make(chan float64), + } + + if err := log.AddToVM("log", vm); err != nil { + logger.Logger.Errorf("error adding log API: %w", err) + } + + util := &javascript.Util{} + if err := util.AddToVM("util", vm); err != nil { + logger.Logger.Errorf("error adding util API: %w", err) + } + + script, err := javascript.CompileScript("", "(function() { "+string(*p)+"})()") + if err != nil { + logger.Warnf("javascript failed to compile: %v", err) + return value + } + + output, err := vm.RunProgram(script) + if err != nil { + logger.Warnf("javascript failed to run: %v", err) + return value + } + + // assume output is string + return output.String() +} + +type mappedPostProcessAction struct { + ParseDate string `yaml:"parseDate"` + SubtractDays bool `yaml:"subtractDays"` + Replace mappedRegexConfigs `yaml:"replace"` + SubScraper *mappedScraperAttrConfig `yaml:"subScraper"` + Map map[string]string `yaml:"map"` + FeetToCm bool `yaml:"feetToCm"` + LbToKg bool `yaml:"lbToKg"` + Javascript string `yaml:"javascript"` +} + +func (a mappedPostProcessAction) ToPostProcessAction() (postProcessAction, error) { + var found string + var ret postProcessAction + + ensureOnly := func(field string) error { + if found != "" { + return fmt.Errorf("post-process actions must have a single field, found %s and %s", found, field) + } + found = field + return nil + } + + if a.ParseDate != "" { + found = "parseDate" + action := postProcessParseDate(a.ParseDate) + ret = &action + } + if len(a.Replace) > 0 { + if err := ensureOnly("replace"); err != nil { + return nil, err + } + action := postProcessReplace(a.Replace) + ret = &action + } + if a.SubScraper != nil { + if err := ensureOnly("subScraper"); err != nil { + return nil, err + } + action := postProcessSubScraper(*a.SubScraper) + ret = &action + } + if a.Map != nil { + if err := ensureOnly("map"); err != nil { + return nil, err + } + action := postProcessMap(a.Map) + ret = &action + } + if a.FeetToCm { + if err := ensureOnly("feetToCm"); err != nil { + return nil, err + } + action := postProcessFeetToCm(a.FeetToCm) + ret = &action + } + if a.LbToKg { + if err := ensureOnly("lbToKg"); err != nil { + return nil, err + } + action := postProcessLbToKg(a.LbToKg) + ret = &action + } + if a.SubtractDays { + if err := ensureOnly("subtractDays"); err != nil { + return nil, err + } + action := postProcessSubtractDays(a.SubtractDays) + ret = &action + } + if a.Javascript != "" { + if err := ensureOnly("javascript"); err != nil { + return nil, err + } + action := postProcessJavascript(a.Javascript) + ret = &action + } + + if ret == nil { + return nil, errors.New("invalid post-process action") + } + + return ret, nil +} diff --git a/pkg/scraper/mapped_result.go b/pkg/scraper/mapped_result.go new file mode 100644 index 000000000..eb06a4eba --- /dev/null +++ b/pkg/scraper/mapped_result.go @@ -0,0 +1,276 @@ +package scraper + +import ( + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" +) + +type mappedResult map[string]interface{} +type mappedResults []mappedResult + +func (r mappedResult) string(key string) (string, bool) { + v, ok := r[key] + if !ok { + return "", false + } + + val, ok := v.(string) + if !ok { + logger.Errorf("String field %s is %T in mappedResult", key, r[key]) + } + + return val, true +} + +func (r mappedResult) mustString(key string) string { + v, ok := r[key] + if !ok { + logger.Errorf("Missing required string field %s in mappedResult", key) + return "" + } + + val, ok := v.(string) + if !ok { + logger.Errorf("String field %s is %T in mappedResult", key, r[key]) + } + + return val +} + +func (r mappedResult) stringPtr(key string) *string { + val, ok := r.string(key) + if !ok { + return nil + } + return &val +} + +func (r mappedResult) stringSlice(key string) []string { + v, ok := r[key] + if !ok { + return nil + } + + // need to try both []string and string + val, ok := v.([]string) + + if ok { + return val + } + + // try single string + singleVal, ok := v.(string) + if !ok { + logger.Errorf("String slice field %s is %T in mappedResult", key, r[key]) + return nil + } + + return []string{singleVal} +} + +func (r mappedResult) IntPtr(key string) *int { + v, ok := r[key] + if !ok { + return nil + } + + val, ok := v.(int) + if !ok { + logger.Errorf("Int field %s is %T in mappedResult", key, r[key]) + return nil + } + + return &val +} + +func (r mappedResults) setSingleValue(index int, key string, value string) mappedResults { + if index >= len(r) { + r = append(r, make(mappedResult)) + } + + logger.Debugf(`[%d][%s] = %s`, index, key, value) + r[index][key] = value + return r +} + +func (r mappedResults) setMultiValue(index int, key string, value []string) mappedResults { + if index >= len(r) { + r = append(r, make(mappedResult)) + } + + logger.Debugf(`[%d][%s] = %s`, index, key, value) + r[index][key] = value + return r +} + +func (r mappedResults) scrapedTags() []*models.ScrapedTag { + if len(r) == 0 { + return nil + } + + ret := make([]*models.ScrapedTag, len(r)) + for i, result := range r { + ret[i] = result.scrapedTag() + } + + return ret +} + +func (r mappedResult) scrapedTag() *models.ScrapedTag { + return &models.ScrapedTag{ + Name: r.mustString("Name"), + } +} + +func (r mappedResult) scrapedPerformer() *models.ScrapedPerformer { + ret := &models.ScrapedPerformer{ + Name: r.stringPtr("Name"), + Disambiguation: r.stringPtr("Disambiguation"), + Gender: r.stringPtr("Gender"), + URL: r.stringPtr("URL"), + URLs: r.stringSlice("URLs"), + Twitter: r.stringPtr("Twitter"), + Birthdate: r.stringPtr("Birthdate"), + Ethnicity: r.stringPtr("Ethnicity"), + Country: r.stringPtr("Country"), + EyeColor: r.stringPtr("EyeColor"), + Height: r.stringPtr("Height"), + Measurements: r.stringPtr("Measurements"), + FakeTits: r.stringPtr("FakeTits"), + PenisLength: r.stringPtr("PenisLength"), + Circumcised: r.stringPtr("Circumcised"), + CareerLength: r.stringPtr("CareerLength"), + Tattoos: r.stringPtr("Tattoos"), + Piercings: r.stringPtr("Piercings"), + Aliases: r.stringPtr("Aliases"), + Image: r.stringPtr("Image"), + Images: r.stringSlice("Images"), + Details: r.stringPtr("Details"), + DeathDate: r.stringPtr("DeathDate"), + HairColor: r.stringPtr("HairColor"), + Weight: r.stringPtr("Weight"), + } + return ret +} + +func (r mappedResults) scrapedPerformers() []*models.ScrapedPerformer { + if len(r) == 0 { + return nil + } + + ret := make([]*models.ScrapedPerformer, len(r)) + for i, result := range r { + ret[i] = result.scrapedPerformer() + } + + return ret +} + +func (r mappedResult) scrapedScene() *models.ScrapedScene { + ret := &models.ScrapedScene{ + Title: r.stringPtr("Title"), + Code: r.stringPtr("Code"), + Details: r.stringPtr("Details"), + Director: r.stringPtr("Director"), + URL: r.stringPtr("URL"), + URLs: r.stringSlice("URLs"), + Date: r.stringPtr("Date"), + Image: r.stringPtr("Image"), + Duration: r.IntPtr("Duration"), + } + return ret +} + +func (r mappedResult) scrapedImage() *models.ScrapedImage { + ret := &models.ScrapedImage{ + Title: r.stringPtr("Title"), + Code: r.stringPtr("Code"), + Details: r.stringPtr("Details"), + Photographer: r.stringPtr("Photographer"), + URLs: r.stringSlice("URLs"), + Date: r.stringPtr("Date"), + } + return ret +} + +func (r mappedResult) scrapedGallery() *models.ScrapedGallery { + ret := &models.ScrapedGallery{ + Title: r.stringPtr("Title"), + Code: r.stringPtr("Code"), + Details: r.stringPtr("Details"), + Photographer: r.stringPtr("Photographer"), + URL: r.stringPtr("URL"), + URLs: r.stringSlice("URLs"), + Date: r.stringPtr("Date"), + } + return ret +} + +func (r mappedResult) scrapedStudio() *models.ScrapedStudio { + ret := &models.ScrapedStudio{ + Name: r.mustString("Name"), + URL: r.stringPtr("URL"), + URLs: r.stringSlice("URLs"), + Image: r.stringPtr("Image"), + Details: r.stringPtr("Details"), + Aliases: r.stringPtr("Aliases"), + } + return ret +} + +func (r mappedResult) scrapedMovie() *models.ScrapedMovie { + ret := &models.ScrapedMovie{ + Name: r.stringPtr("Name"), + Aliases: r.stringPtr("Aliases"), + URLs: r.stringSlice("URLs"), + Duration: r.stringPtr("Duration"), + Date: r.stringPtr("Date"), + Director: r.stringPtr("Director"), + Synopsis: r.stringPtr("Synopsis"), + FrontImage: r.stringPtr("FrontImage"), + BackImage: r.stringPtr("BackImage"), + } + + return ret +} + +func (r mappedResult) scrapedGroup() *models.ScrapedGroup { + ret := &models.ScrapedGroup{ + Name: r.stringPtr("Name"), + Aliases: r.stringPtr("Aliases"), + URL: r.stringPtr("URL"), + URLs: r.stringSlice("URLs"), + Duration: r.stringPtr("Duration"), + Date: r.stringPtr("Date"), + Director: r.stringPtr("Director"), + Synopsis: r.stringPtr("Synopsis"), + FrontImage: r.stringPtr("FrontImage"), + BackImage: r.stringPtr("BackImage"), + } + + return ret +} + +func (r mappedResults) scrapedMovies() []*models.ScrapedMovie { + if len(r) == 0 { + return nil + } + ret := make([]*models.ScrapedMovie, len(r)) + for i, result := range r { + ret[i] = result.scrapedMovie() + } + + return ret +} + +func (r mappedResults) scrapedGroups() []*models.ScrapedGroup { + if len(r) == 0 { + return nil + } + ret := make([]*models.ScrapedGroup, len(r)) + for i, result := range r { + ret[i] = result.scrapedGroup() + } + + return ret +} diff --git a/pkg/scraper/mapped_result_test.go b/pkg/scraper/mapped_result_test.go new file mode 100644 index 000000000..db6d921bf --- /dev/null +++ b/pkg/scraper/mapped_result_test.go @@ -0,0 +1,908 @@ +package scraper + +import ( + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stretchr/testify/assert" +) + +// Test string method +func TestMappedResultString(t *testing.T) { + tests := []struct { + name string + data mappedResult + key string + expectedValue string + expectedOk bool + }{ + { + name: "valid string", + data: mappedResult{"name": "test"}, + key: "name", + expectedValue: "test", + expectedOk: true, + }, + { + name: "missing key", + data: mappedResult{}, + key: "missing", + expectedValue: "", + expectedOk: false, + }, + { + name: "wrong type still returns ok true but empty value", + data: mappedResult{"num": 123}, + key: "num", + expectedValue: "", + expectedOk: true, // logs error but returns ok=true + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + val, ok := test.data.string(test.key) + assert.Equal(t, test.expectedValue, val) + assert.Equal(t, test.expectedOk, ok) + }) + } +} + +// Test mustString method +func TestMappedResultMustString(t *testing.T) { + tests := []struct { + name string + data mappedResult + key string + expectedValue string + }{ + { + name: "valid string", + data: mappedResult{"name": "test"}, + key: "name", + expectedValue: "test", + }, + { + name: "missing key returns empty string", + data: mappedResult{}, + key: "missing", + expectedValue: "", + }, + { + name: "wrong type returns empty string", + data: mappedResult{"num": 123}, + key: "num", + expectedValue: "", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + val := test.data.mustString(test.key) + assert.Equal(t, test.expectedValue, val) + }) + } +} + +// Test stringPtr method +func TestMappedResultStringPtr(t *testing.T) { + tests := []struct { + name string + data mappedResult + key string + expectedValue *string + }{ + { + name: "valid string", + data: mappedResult{"name": "test"}, + key: "name", + expectedValue: strPtr("test"), + }, + { + name: "missing key returns nil", + data: mappedResult{}, + key: "missing", + expectedValue: nil, + }, + { + name: "wrong type returns non-nil pointer to empty string", + data: mappedResult{"num": 123}, + key: "num", + expectedValue: strPtr(""), // string() returns empty string but ok=true + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + val := test.data.stringPtr(test.key) + if test.expectedValue == nil { + assert.Nil(t, val) + } else { + assert.NotNil(t, val) + assert.Equal(t, *test.expectedValue, *val) + } + }) + } +} + +// Test stringSlice method +func TestMappedResultStringSlice(t *testing.T) { + tests := []struct { + name string + data mappedResult + key string + expectedValue []string + }{ + { + name: "valid slice", + data: mappedResult{"tags": []string{"a", "b", "c"}}, + key: "tags", + expectedValue: []string{"a", "b", "c"}, + }, + { + name: "missing key returns nil", + data: mappedResult{}, + key: "missing", + expectedValue: nil, + }, + { + name: "single value converted to slice", + data: mappedResult{"tags": "not a slice"}, + key: "tags", + expectedValue: []string{"not a slice"}, + }, + { + name: "wrong type returns nil", + data: mappedResult{"tags": 123}, + key: "tags", + expectedValue: nil, + }, + { + name: "empty slice", + data: mappedResult{"tags": []string{}}, + key: "tags", + expectedValue: []string{}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + val := test.data.stringSlice(test.key) + assert.Equal(t, test.expectedValue, val) + }) + } +} + +// Test IntPtr method +func TestMappedResultIntPtr(t *testing.T) { + tests := []struct { + name string + data mappedResult + key string + expectedValue *int + }{ + { + name: "valid int", + data: mappedResult{"duration": 120}, + key: "duration", + expectedValue: intPtr(120), + }, + { + name: "missing key returns nil", + data: mappedResult{}, + key: "missing", + expectedValue: nil, + }, + { + name: "wrong type returns nil", + data: mappedResult{"duration": "120"}, + key: "duration", + expectedValue: nil, + }, + { + name: "zero value", + data: mappedResult{"duration": 0}, + key: "duration", + expectedValue: intPtr(0), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + val := test.data.IntPtr(test.key) + assert.Equal(t, test.expectedValue, val) + }) + } +} + +// Test setSingleValue method +func TestMappedResultsSetSingleValue(t *testing.T) { + tests := []struct { + name string + initialResults mappedResults + index int + key string + value string + expectedLen int + shouldPanic bool + }{ + { + name: "append to empty", + initialResults: mappedResults{}, + index: 0, + key: "name", + value: "test", + expectedLen: 1, + shouldPanic: false, + }, + { + name: "set in existing", + initialResults: mappedResults{mappedResult{}}, + index: 0, + key: "name", + value: "test", + expectedLen: 1, + shouldPanic: false, + }, + { + name: "append to existing", + initialResults: mappedResults{mappedResult{}}, + index: 1, + key: "name", + value: "test", + expectedLen: 2, + shouldPanic: false, + }, + { + name: "sparse index causes panic", + initialResults: mappedResults{mappedResult{}}, + index: 5, + key: "name", + value: "test", + expectedLen: 6, + shouldPanic: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.shouldPanic { + assert.Panics(t, func() { + test.initialResults.setSingleValue(test.index, test.key, test.value) + }) + } else { + results := test.initialResults.setSingleValue(test.index, test.key, test.value) + assert.Equal(t, test.expectedLen, len(results)) + assert.Equal(t, test.value, results[test.index][test.key]) + } + }) + } +} + +// Test setMultiValue method +func TestMappedResultsSetMultiValue(t *testing.T) { + tests := []struct { + name string + initialResults mappedResults + index int + key string + value []string + expectedLen int + }{ + { + name: "append to empty", + initialResults: mappedResults{}, + index: 0, + key: "tags", + value: []string{"a", "b"}, + expectedLen: 1, + }, + { + name: "set in existing", + initialResults: mappedResults{mappedResult{}}, + index: 0, + key: "tags", + value: []string{"a", "b"}, + expectedLen: 1, + }, + { + name: "append to existing", + initialResults: mappedResults{mappedResult{}}, + index: 1, + key: "tags", + value: []string{"x", "y"}, + expectedLen: 2, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + results := test.initialResults.setMultiValue(test.index, test.key, test.value) + assert.Equal(t, test.expectedLen, len(results)) + assert.Equal(t, test.value, results[test.index][test.key]) + }) + } +} + +// Test scrapedTag method +func TestMappedResultScrapedTag(t *testing.T) { + tests := []struct { + name string + data mappedResult + expectedName string + }{ + { + name: "valid tag", + data: mappedResult{"Name": "Action"}, + expectedName: "Action", + }, + { + name: "missing name", + data: mappedResult{}, + expectedName: "", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tag := test.data.scrapedTag() + assert.NotNil(t, tag) + assert.Equal(t, test.expectedName, tag.Name) + }) + } +} + +// Test scrapedTags method +func TestMappedResultsScrapedTags(t *testing.T) { + tests := []struct { + name string + data mappedResults + expectedCount int + expectedNames []string + }{ + { + name: "empty results", + data: mappedResults{}, + expectedCount: 0, + }, + { + name: "single tag", + data: mappedResults{ + mappedResult{"Name": "Action"}, + }, + expectedCount: 1, + expectedNames: []string{"Action"}, + }, + { + name: "multiple tags", + data: mappedResults{ + mappedResult{"Name": "Action"}, + mappedResult{"Name": "Drama"}, + mappedResult{"Name": "Comedy"}, + }, + expectedCount: 3, + expectedNames: []string{"Action", "Drama", "Comedy"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tags := test.data.scrapedTags() + if test.expectedCount == 0 { + assert.Nil(t, tags) + } else { + assert.NotNil(t, tags) + assert.Equal(t, test.expectedCount, len(tags)) + for i, expectedName := range test.expectedNames { + assert.Equal(t, expectedName, tags[i].Name) + } + } + }) + } +} + +// Test scrapedPerformer method +func TestMappedResultScrapedPerformer(t *testing.T) { + tests := []struct { + name string + data mappedResult + validate func(t *testing.T, p *models.ScrapedPerformer) + }{ + { + name: "full performer", + data: mappedResult{ + "Name": "Jane Doe", + "Disambiguation": "Actress", + "Gender": "Female", + "URL": "https://example.com/jane", + "URLs": []string{"url1", "url2"}, + "Twitter": "@jane", + "Birthdate": "1990-01-01", + "Ethnicity": "Caucasian", + "Country": "USA", + "EyeColor": "Blue", + "Height": "5'6\"", + "Measurements": "36-24-36", + "FakeTits": "No", + "PenisLength": "N/A", + "Circumcised": "N/A", + "CareerLength": "10 years", + "Tattoos": "Yes", + "Piercings": "Yes", + "Aliases": "Jane Smith", + "Image": "image.jpg", + "Images": []string{"img1", "img2"}, + "Details": "Some details", + "DeathDate": "N/A", + "HairColor": "Blonde", + "Weight": "130 lbs", + }, + validate: func(t *testing.T, p *models.ScrapedPerformer) { + assert.NotNil(t, p) + assert.Equal(t, "Jane Doe", *p.Name) + assert.Equal(t, "Actress", *p.Disambiguation) + assert.Equal(t, "Female", *p.Gender) + assert.Equal(t, "https://example.com/jane", *p.URL) + assert.Equal(t, []string{"url1", "url2"}, p.URLs) + assert.Equal(t, "@jane", *p.Twitter) + assert.Equal(t, "Blonde", *p.HairColor) + assert.Equal(t, "130 lbs", *p.Weight) + }, + }, + { + name: "minimal performer", + data: mappedResult{}, + validate: func(t *testing.T, p *models.ScrapedPerformer) { + assert.NotNil(t, p) + assert.Nil(t, p.Name) + assert.Nil(t, p.Gender) + assert.Empty(t, p.URLs) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + performer := test.data.scrapedPerformer() + test.validate(t, performer) + }) + } +} + +// Test scrapedPerformers method +func TestMappedResultsScrapedPerformers(t *testing.T) { + tests := []struct { + name string + data mappedResults + expectedCount int + }{ + { + name: "empty results", + data: mappedResults{}, + expectedCount: 0, + }, + { + name: "single performer", + data: mappedResults{ + mappedResult{"Name": "Jane Doe"}, + }, + expectedCount: 1, + }, + { + name: "multiple performers", + data: mappedResults{ + mappedResult{"Name": "Jane Doe"}, + mappedResult{"Name": "John Doe"}, + mappedResult{"Name": "Alice"}, + }, + expectedCount: 3, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + performers := test.data.scrapedPerformers() + if test.expectedCount == 0 { + assert.Nil(t, performers) + } else { + assert.NotNil(t, performers) + assert.Equal(t, test.expectedCount, len(performers)) + } + }) + } +} + +// Test scrapedScene method +func TestMappedResultScrapedScene(t *testing.T) { + tests := []struct { + name string + data mappedResult + validate func(t *testing.T, s *models.ScrapedScene) + }{ + { + name: "full scene", + data: mappedResult{ + "Title": "Scene Title", + "Code": "CODE123", + "Details": "Scene details", + "Director": "John Smith", + "URL": "https://example.com/scene", + "URLs": []string{"url1", "url2"}, + "Date": "2020-01-01", + "Image": "scene.jpg", + "Duration": 3600, + }, + validate: func(t *testing.T, s *models.ScrapedScene) { + assert.NotNil(t, s) + assert.Equal(t, "Scene Title", *s.Title) + assert.Equal(t, "CODE123", *s.Code) + assert.Equal(t, "Scene details", *s.Details) + assert.Equal(t, "John Smith", *s.Director) + assert.Equal(t, "https://example.com/scene", *s.URL) + assert.Equal(t, []string{"url1", "url2"}, s.URLs) + assert.Equal(t, "2020-01-01", *s.Date) + assert.Equal(t, "scene.jpg", *s.Image) + assert.Equal(t, 3600, *s.Duration) + }, + }, + { + name: "minimal scene", + data: mappedResult{}, + validate: func(t *testing.T, s *models.ScrapedScene) { + assert.NotNil(t, s) + assert.Nil(t, s.Title) + assert.Nil(t, s.Duration) + assert.Empty(t, s.URLs) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + scene := test.data.scrapedScene() + test.validate(t, scene) + }) + } +} + +// Test scrapedImage method +func TestMappedResultScrapedImage(t *testing.T) { + tests := []struct { + name string + data mappedResult + validate func(t *testing.T, i *models.ScrapedImage) + }{ + { + name: "full image", + data: mappedResult{ + "Title": "Image Title", + "Code": "IMG123", + "Details": "Image details", + "Photographer": "Jane Photographer", + "URLs": []string{"url1", "url2"}, + "Date": "2020-06-15", + }, + validate: func(t *testing.T, i *models.ScrapedImage) { + assert.NotNil(t, i) + assert.Equal(t, "Image Title", *i.Title) + assert.Equal(t, "IMG123", *i.Code) + assert.Equal(t, "Image details", *i.Details) + assert.Equal(t, "Jane Photographer", *i.Photographer) + assert.Equal(t, []string{"url1", "url2"}, i.URLs) + assert.Equal(t, "2020-06-15", *i.Date) + }, + }, + { + name: "minimal image", + data: mappedResult{}, + validate: func(t *testing.T, i *models.ScrapedImage) { + assert.NotNil(t, i) + assert.Nil(t, i.Title) + assert.Empty(t, i.URLs) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + image := test.data.scrapedImage() + test.validate(t, image) + }) + } +} + +// Test scrapedGallery method +func TestMappedResultScrapedGallery(t *testing.T) { + tests := []struct { + name string + data mappedResult + validate func(t *testing.T, g *models.ScrapedGallery) + }{ + { + name: "full gallery", + data: mappedResult{ + "Title": "Gallery Title", + "Code": "GAL123", + "Details": "Gallery details", + "Photographer": "Jane Photographer", + "URL": "https://example.com/gallery", + "URLs": []string{"url1", "url2"}, + "Date": "2020-07-20", + }, + validate: func(t *testing.T, g *models.ScrapedGallery) { + assert.NotNil(t, g) + assert.Equal(t, "Gallery Title", *g.Title) + assert.Equal(t, "GAL123", *g.Code) + assert.Equal(t, "Gallery details", *g.Details) + assert.Equal(t, "Jane Photographer", *g.Photographer) + assert.Equal(t, "https://example.com/gallery", *g.URL) + assert.Equal(t, []string{"url1", "url2"}, g.URLs) + assert.Equal(t, "2020-07-20", *g.Date) + }, + }, + { + name: "minimal gallery", + data: mappedResult{}, + validate: func(t *testing.T, g *models.ScrapedGallery) { + assert.NotNil(t, g) + assert.Nil(t, g.Title) + assert.Empty(t, g.URLs) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + gallery := test.data.scrapedGallery() + test.validate(t, gallery) + }) + } +} + +// Test scrapedStudio method +func TestMappedResultScrapedStudio(t *testing.T) { + tests := []struct { + name string + data mappedResult + validate func(t *testing.T, st *models.ScrapedStudio) + }{ + { + name: "full studio", + data: mappedResult{ + "Name": "Studio Name", + "URL": "https://example.com/studio", + "URLs": []string{"url1", "url2"}, + "Image": "studio.jpg", + "Details": "Studio details", + "Aliases": "Studio Alias", + }, + validate: func(t *testing.T, st *models.ScrapedStudio) { + assert.NotNil(t, st) + assert.Equal(t, "Studio Name", st.Name) + assert.Equal(t, "https://example.com/studio", *st.URL) + assert.Equal(t, []string{"url1", "url2"}, st.URLs) + assert.Equal(t, "studio.jpg", *st.Image) + assert.Equal(t, "Studio details", *st.Details) + assert.Equal(t, "Studio Alias", *st.Aliases) + }, + }, + { + name: "minimal studio", + data: mappedResult{}, + validate: func(t *testing.T, st *models.ScrapedStudio) { + assert.NotNil(t, st) + assert.Equal(t, "", st.Name) // mustString returns empty string + assert.Nil(t, st.URL) + assert.Empty(t, st.URLs) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + studio := test.data.scrapedStudio() + test.validate(t, studio) + }) + } +} + +// Test scrapedMovie method +func TestMappedResultScrapedMovie(t *testing.T) { + tests := []struct { + name string + data mappedResult + validate func(t *testing.T, m *models.ScrapedMovie) + }{ + { + name: "full movie", + data: mappedResult{ + "Name": "Movie Title", + "Aliases": "Movie Alias", + "URLs": []string{"url1", "url2"}, + "Duration": "120 minutes", + "Date": "2020-05-10", + "Director": "John Director", + "Synopsis": "Movie synopsis", + "FrontImage": "front.jpg", + "BackImage": "back.jpg", + }, + validate: func(t *testing.T, m *models.ScrapedMovie) { + assert.NotNil(t, m) + assert.Equal(t, "Movie Title", *m.Name) + assert.Equal(t, "Movie Alias", *m.Aliases) + assert.Equal(t, []string{"url1", "url2"}, m.URLs) + assert.Equal(t, "120 minutes", *m.Duration) + assert.Equal(t, "2020-05-10", *m.Date) + assert.Equal(t, "John Director", *m.Director) + assert.Equal(t, "Movie synopsis", *m.Synopsis) + assert.Equal(t, "front.jpg", *m.FrontImage) + assert.Equal(t, "back.jpg", *m.BackImage) + }, + }, + { + name: "minimal movie", + data: mappedResult{}, + validate: func(t *testing.T, m *models.ScrapedMovie) { + assert.NotNil(t, m) + assert.Nil(t, m.Name) + assert.Empty(t, m.URLs) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + movie := test.data.scrapedMovie() + test.validate(t, movie) + }) + } +} + +// Test scrapedMovies method +func TestMappedResultsScrapedMovies(t *testing.T) { + tests := []struct { + name string + data mappedResults + expectedCount int + }{ + { + name: "empty results", + data: mappedResults{}, + expectedCount: 0, + }, + { + name: "single movie", + data: mappedResults{ + mappedResult{"Name": "Movie 1"}, + }, + expectedCount: 1, + }, + { + name: "multiple movies", + data: mappedResults{ + mappedResult{"Name": "Movie 1"}, + mappedResult{"Name": "Movie 2"}, + mappedResult{"Name": "Movie 3"}, + }, + expectedCount: 3, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + movies := test.data.scrapedMovies() + if test.expectedCount == 0 { + assert.Nil(t, movies) + } else { + assert.NotNil(t, movies) + assert.Equal(t, test.expectedCount, len(movies)) + } + }) + } +} + +// Test scrapedGroup method +func TestMappedResultScrapedGroup(t *testing.T) { + tests := []struct { + name string + data mappedResult + validate func(t *testing.T, g *models.ScrapedGroup) + }{ + { + name: "full group", + data: mappedResult{ + "Name": "Group Title", + "Aliases": "Group Alias", + "URL": "https://example.com/group", + "URLs": []string{"url1", "url2"}, + "Duration": "240 minutes", + "Date": "2020-08-15", + "Director": "Jane Director", + "Synopsis": "Group synopsis", + "FrontImage": "front.jpg", + "BackImage": "back.jpg", + }, + validate: func(t *testing.T, g *models.ScrapedGroup) { + assert.NotNil(t, g) + assert.Equal(t, "Group Title", *g.Name) + assert.Equal(t, "Group Alias", *g.Aliases) + assert.Equal(t, "https://example.com/group", *g.URL) + assert.Equal(t, []string{"url1", "url2"}, g.URLs) + assert.Equal(t, "240 minutes", *g.Duration) + assert.Equal(t, "2020-08-15", *g.Date) + assert.Equal(t, "Jane Director", *g.Director) + assert.Equal(t, "Group synopsis", *g.Synopsis) + assert.Equal(t, "front.jpg", *g.FrontImage) + assert.Equal(t, "back.jpg", *g.BackImage) + }, + }, + { + name: "minimal group", + data: mappedResult{}, + validate: func(t *testing.T, g *models.ScrapedGroup) { + assert.NotNil(t, g) + assert.Nil(t, g.Name) + assert.Empty(t, g.URLs) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + group := test.data.scrapedGroup() + test.validate(t, group) + }) + } +} + +// Test scrapedGroups method +func TestMappedResultsScrapedGroups(t *testing.T) { + tests := []struct { + name string + data mappedResults + expectedCount int + }{ + { + name: "empty results", + data: mappedResults{}, + expectedCount: 0, + }, + { + name: "single group", + data: mappedResults{ + mappedResult{"Name": "Group 1"}, + }, + expectedCount: 1, + }, + { + name: "multiple groups", + data: mappedResults{ + mappedResult{"Name": "Group 1"}, + mappedResult{"Name": "Group 2"}, + mappedResult{"Name": "Group 3"}, + }, + expectedCount: 3, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + groups := test.data.scrapedGroups() + if test.expectedCount == 0 { + assert.Nil(t, groups) + } else { + assert.NotNil(t, groups) + assert.Equal(t, test.expectedCount, len(groups)) + } + }) + } +} + +// Helper functions +func strPtr(s string) *string { + return &s +} + +func intPtr(i int) *int { + return &i +} diff --git a/pkg/scraper/mapped_test.go b/pkg/scraper/mapped_test.go index 5f44e17af..667bb8385 100644 --- a/pkg/scraper/mapped_test.go +++ b/pkg/scraper/mapped_test.go @@ -25,7 +25,7 @@ xPathScrapers: - anything ` - c := &config{} + c := &Definition{} err := yaml.Unmarshal([]byte(yamlStr), &c) if err == nil { diff --git a/pkg/scraper/query_url.go b/pkg/scraper/query_url.go index 91adb7d67..2cd9f683e 100644 --- a/pkg/scraper/query_url.go +++ b/pkg/scraper/query_url.go @@ -110,7 +110,7 @@ func (p queryURLParameters) constructURL(url string) string { } // replaceURL does a partial URL Replace ( only url parameter is used) -func replaceURL(url string, scraperConfig scraperTypeConfig) string { +func replaceURL(url string, scraperConfig ByURLDefinition) string { u := url queryURL := queryURLParameterFromURL(u) if scraperConfig.QueryURLReplacements != nil { diff --git a/pkg/scraper/script.go b/pkg/scraper/script.go index 866c92365..f8e47b5d8 100644 --- a/pkg/scraper/script.go +++ b/pkg/scraper/script.go @@ -208,22 +208,11 @@ func galleryInputFromGallery(gallery *models.Gallery) galleryInput { var ErrScraperScript = errors.New("scraper script error") type scriptScraper struct { - scraper scraperTypeConfig - config config + definition Definition globalConfig GlobalConfig } -func newScriptScraper(scraper scraperTypeConfig, config config, globalConfig GlobalConfig) *scriptScraper { - return &scriptScraper{ - scraper: scraper, - config: config, - globalConfig: globalConfig, - } -} - -func (s *scriptScraper) runScraperScript(ctx context.Context, inString string, out interface{}) error { - command := s.scraper.Script - +func (s *scriptScraper) runScraperScript(ctx context.Context, command []string, inString string, out interface{}) error { var cmd *exec.Cmd if python.IsPythonCommand(command[0]) { pythonPath := s.globalConfig.GetPythonPath() @@ -233,7 +222,7 @@ func (s *scriptScraper) runScraperScript(ctx context.Context, inString string, o logger.Warnf("%s", err) } else { cmd = p.Command(ctx, command[1:]) - envVariable, _ := filepath.Abs(filepath.Dir(filepath.Dir(s.config.path))) + envVariable, _ := filepath.Abs(filepath.Dir(filepath.Dir(s.definition.path))) python.AppendPythonPath(cmd, envVariable) } } @@ -243,7 +232,7 @@ func (s *scriptScraper) runScraperScript(ctx context.Context, inString string, o cmd = stashExec.CommandContext(ctx, command[0], command[1:]...) } - cmd.Dir = filepath.Dir(s.config.path) + cmd.Dir = filepath.Dir(s.definition.path) stdin, err := cmd.StdinPipe() if err != nil { @@ -273,7 +262,7 @@ func (s *scriptScraper) runScraperScript(ctx context.Context, inString string, o return errors.New("error running scraper script") } - go handleScraperStderr(s.config.Name, stderr) + go handleScraperStderr(s.definition.Name, stderr) logger.Debugf("Scraper script <%s> started", strings.Join(cmd.Args, " ")) @@ -312,7 +301,39 @@ func (s *scriptScraper) runScraperScript(ctx context.Context, inString string, o return nil } -func (s *scriptScraper) scrapeByName(ctx context.Context, name string, ty ScrapeContentType) ([]ScrapedContent, error) { +func (s *scriptScraper) scrape(ctx context.Context, command []string, input string, ty ScrapeContentType) (ScrapedContent, error) { + switch ty { + case ScrapeContentTypePerformer: + var performer *models.ScrapedPerformer + err := s.runScraperScript(ctx, command, input, &performer) + return performer, err + case ScrapeContentTypeGallery: + var gallery *models.ScrapedGallery + err := s.runScraperScript(ctx, command, input, &gallery) + return gallery, err + case ScrapeContentTypeScene: + var scene *models.ScrapedScene + err := s.runScraperScript(ctx, command, input, &scene) + return scene, err + case ScrapeContentTypeMovie, ScrapeContentTypeGroup: + var movie *models.ScrapedMovie + err := s.runScraperScript(ctx, command, input, &movie) + return movie, err + case ScrapeContentTypeImage: + var image *models.ScrapedImage + err := s.runScraperScript(ctx, command, input, &image) + return image, err + } + + return nil, ErrNotSupported +} + +type scriptNameScraper struct { + scriptScraper + definition ByNameDefinition +} + +func (s *scriptNameScraper) scrapeByName(ctx context.Context, name string, ty ScrapeContentType) ([]ScrapedContent, error) { input := `{"name": "` + name + `"}` var ret []ScrapedContent @@ -320,7 +341,7 @@ func (s *scriptScraper) scrapeByName(ctx context.Context, name string, ty Scrape switch ty { case ScrapeContentTypePerformer: var performers []models.ScrapedPerformer - err = s.runScraperScript(ctx, input, &performers) + err = s.runScraperScript(ctx, s.definition.Script, input, &performers) if err == nil { for _, p := range performers { v := p @@ -329,7 +350,7 @@ func (s *scriptScraper) scrapeByName(ctx context.Context, name string, ty Scrape } case ScrapeContentTypeScene: var scenes []models.ScrapedScene - err = s.runScraperScript(ctx, input, &scenes) + err = s.runScraperScript(ctx, s.definition.Script, input, &scenes) if err == nil { for _, s := range scenes { v := s @@ -343,7 +364,21 @@ func (s *scriptScraper) scrapeByName(ctx context.Context, name string, ty Scrape return ret, err } -func (s *scriptScraper) scrapeByFragment(ctx context.Context, input Input) (ScrapedContent, error) { +type scriptURLScraper struct { + scriptScraper + definition ByURLDefinition +} + +func (s *scriptURLScraper) scrapeByURL(ctx context.Context, url string, ty ScrapeContentType) (ScrapedContent, error) { + return s.scrape(ctx, s.definition.Script, `{"url": "`+url+`"}`, ty) +} + +type scriptFragmentScraper struct { + scriptScraper + definition ByFragmentDefinition +} + +func (s *scriptFragmentScraper) scrapeByFragment(ctx context.Context, input Input) (ScrapedContent, error) { var inString []byte var err error var ty ScrapeContentType @@ -363,41 +398,10 @@ func (s *scriptScraper) scrapeByFragment(ctx context.Context, input Input) (Scra return nil, err } - return s.scrape(ctx, string(inString), ty) + return s.scrape(ctx, s.definition.Script, string(inString), ty) } -func (s *scriptScraper) scrapeByURL(ctx context.Context, url string, ty ScrapeContentType) (ScrapedContent, error) { - return s.scrape(ctx, `{"url": "`+url+`"}`, ty) -} - -func (s *scriptScraper) scrape(ctx context.Context, input string, ty ScrapeContentType) (ScrapedContent, error) { - switch ty { - case ScrapeContentTypePerformer: - var performer *models.ScrapedPerformer - err := s.runScraperScript(ctx, input, &performer) - return performer, err - case ScrapeContentTypeGallery: - var gallery *models.ScrapedGallery - err := s.runScraperScript(ctx, input, &gallery) - return gallery, err - case ScrapeContentTypeScene: - var scene *models.ScrapedScene - err := s.runScraperScript(ctx, input, &scene) - return scene, err - case ScrapeContentTypeMovie, ScrapeContentTypeGroup: - var movie *models.ScrapedMovie - err := s.runScraperScript(ctx, input, &movie) - return movie, err - case ScrapeContentTypeImage: - var image *models.ScrapedImage - err := s.runScraperScript(ctx, input, &image) - return image, err - } - - return nil, ErrNotSupported -} - -func (s *scriptScraper) scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*models.ScrapedScene, error) { +func (s *scriptFragmentScraper) scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*models.ScrapedScene, error) { inString, err := json.Marshal(sceneInputFromScene(scene)) if err != nil { @@ -406,12 +410,12 @@ func (s *scriptScraper) scrapeSceneByScene(ctx context.Context, scene *models.Sc var ret *models.ScrapedScene - err = s.runScraperScript(ctx, string(inString), &ret) + err = s.runScraperScript(ctx, s.definition.Script, string(inString), &ret) return ret, err } -func (s *scriptScraper) scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*models.ScrapedGallery, error) { +func (s *scriptFragmentScraper) scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*models.ScrapedGallery, error) { inString, err := json.Marshal(galleryInputFromGallery(gallery)) if err != nil { @@ -420,12 +424,12 @@ func (s *scriptScraper) scrapeGalleryByGallery(ctx context.Context, gallery *mod var ret *models.ScrapedGallery - err = s.runScraperScript(ctx, string(inString), &ret) + err = s.runScraperScript(ctx, s.definition.Script, string(inString), &ret) return ret, err } -func (s *scriptScraper) scrapeImageByImage(ctx context.Context, image *models.Image) (*models.ScrapedImage, error) { +func (s *scriptFragmentScraper) scrapeImageByImage(ctx context.Context, image *models.Image) (*models.ScrapedImage, error) { inString, err := json.Marshal(imageToUpdateInput(image)) if err != nil { @@ -434,7 +438,7 @@ func (s *scriptScraper) scrapeImageByImage(ctx context.Context, image *models.Im var ret *models.ScrapedImage - err = s.runScraperScript(ctx, string(inString), &ret) + err = s.runScraperScript(ctx, s.definition.Script, string(inString), &ret) return ret, err } diff --git a/pkg/scraper/stash.go b/pkg/scraper/stash.go index 5c5cab9fc..23c4b9063 100644 --- a/pkg/scraper/stash.go +++ b/pkg/scraper/stash.go @@ -14,15 +14,13 @@ import ( ) type stashScraper struct { - scraper scraperTypeConfig - config config + config Definition globalConfig GlobalConfig client *http.Client } -func newStashScraper(scraper scraperTypeConfig, client *http.Client, config config, globalConfig GlobalConfig) *stashScraper { +func newStashScraper(client *http.Client, config Definition, globalConfig GlobalConfig) *stashScraper { return &stashScraper{ - scraper: scraper, config: config, client: client, globalConfig: globalConfig, diff --git a/pkg/scraper/url.go b/pkg/scraper/url.go index b53d7b27f..d036ae68e 100644 --- a/pkg/scraper/url.go +++ b/pkg/scraper/url.go @@ -25,8 +25,8 @@ import ( const scrapeDefaultSleep = time.Second * 2 -func loadURL(ctx context.Context, loadURL string, client *http.Client, scraperConfig config, globalConfig GlobalConfig) (io.Reader, error) { - driverOptions := scraperConfig.DriverOptions +func loadURL(ctx context.Context, loadURL string, client *http.Client, def Definition, globalConfig GlobalConfig) (io.Reader, error) { + driverOptions := def.DriverOptions if driverOptions != nil && driverOptions.UseCDP { // get the page using chrome dp return urlFromCDP(ctx, loadURL, *driverOptions, globalConfig) @@ -37,7 +37,7 @@ func loadURL(ctx context.Context, loadURL string, client *http.Client, scraperCo return nil, err } - jar, err := scraperConfig.jar() + jar, err := def.jar() if err != nil { return nil, fmt.Errorf("error creating cookie jar: %w", err) } @@ -83,7 +83,7 @@ func loadURL(ctx context.Context, loadURL string, client *http.Client, scraperCo } bodyReader := bytes.NewReader(body) - printCookies(jar, scraperConfig, "Jar cookies found for scraper urls") + printCookies(jar, def, "Jar cookies found for scraper urls") return charset.NewReader(bodyReader, resp.Header.Get("Content-Type")) } diff --git a/pkg/scraper/xpath.go b/pkg/scraper/xpath.go index 5f7b76372..bf70869e8 100644 --- a/pkg/scraper/xpath.go +++ b/pkg/scraper/xpath.go @@ -3,7 +3,6 @@ package scraper import ( "bytes" "context" - "errors" "fmt" "net/http" "net/url" @@ -19,49 +18,36 @@ import ( ) type xpathScraper struct { - scraper scraperTypeConfig - config config + definition Definition globalConfig GlobalConfig client *http.Client } -func newXpathScraper(scraper scraperTypeConfig, client *http.Client, config config, globalConfig GlobalConfig) *xpathScraper { - return &xpathScraper{ - scraper: scraper, - config: config, - globalConfig: globalConfig, - client: client, +func (s *xpathScraper) getXpathScraper(name string) (*mappedScraper, error) { + ret, ok := s.definition.XPathScrapers[name] + if !ok { + return nil, fmt.Errorf("xpath scraper with name %s not found in config", name) } + return &ret, nil } -func (s *xpathScraper) getXpathScraper() *mappedScraper { - return s.config.XPathScrapers[s.scraper.Scraper] +type xpathURLScraper struct { + xpathScraper + definition ByURLDefinition } -func (s *xpathScraper) scrapeURL(ctx context.Context, url string) (*html.Node, *mappedScraper, error) { - scraper := s.getXpathScraper() - - if scraper == nil { - return nil, nil, errors.New("xpath scraper with name " + s.scraper.Scraper + " not found in config") - } - - doc, err := s.loadURL(ctx, url) - - if err != nil { - return nil, nil, err - } - - return doc, scraper, nil -} - -func (s *xpathScraper) scrapeByURL(ctx context.Context, url string, ty ScrapeContentType) (ScrapedContent, error) { - u := replaceURL(url, s.scraper) // allow a URL Replace for performer by URL queries - doc, scraper, err := s.scrapeURL(ctx, u) +func (s *xpathURLScraper) scrapeByURL(ctx context.Context, url string, ty ScrapeContentType) (ScrapedContent, error) { + scraper, err := s.getXpathScraper(s.definition.Scraper) if err != nil { return nil, err } - q := s.getXPathQuery(doc, u) + doc, err := s.loadURL(ctx, url) + if err != nil { + return nil, err + } + + q := s.getXPathQuery(doc, url) // if these just return the return values from scraper.scrape* functions then // it ends up returning ScrapedContent(nil) rather than nil switch ty { @@ -100,11 +86,15 @@ func (s *xpathScraper) scrapeByURL(ctx context.Context, url string, ty ScrapeCon return nil, ErrNotSupported } -func (s *xpathScraper) scrapeByName(ctx context.Context, name string, ty ScrapeContentType) ([]ScrapedContent, error) { - scraper := s.getXpathScraper() +type xpathNameScraper struct { + xpathScraper + definition ByNameDefinition +} - if scraper == nil { - return nil, fmt.Errorf("%w: name %v", ErrNotFound, s.scraper.Scraper) +func (s *xpathNameScraper) scrapeByName(ctx context.Context, name string, ty ScrapeContentType) ([]ScrapedContent, error) { + scraper, err := s.getXpathScraper(s.definition.Scraper) + if err != nil { + return nil, err } const placeholder = "{}" @@ -112,7 +102,7 @@ func (s *xpathScraper) scrapeByName(ctx context.Context, name string, ty ScrapeC // replace the placeholder string with the URL-escaped name escapedName := url.QueryEscape(name) - url := s.scraper.QueryURL + url := s.definition.QueryURL url = strings.ReplaceAll(url, placeholder, escapedName) doc, err := s.loadURL(ctx, url) @@ -151,18 +141,22 @@ func (s *xpathScraper) scrapeByName(ctx context.Context, name string, ty ScrapeC return nil, ErrNotSupported } -func (s *xpathScraper) scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*models.ScrapedScene, error) { +type xpathFragmentScraper struct { + xpathScraper + definition ByFragmentDefinition +} + +func (s *xpathFragmentScraper) scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*models.ScrapedScene, error) { // construct the URL queryURL := queryURLParametersFromScene(scene) - if s.scraper.QueryURLReplacements != nil { - queryURL.applyReplacements(s.scraper.QueryURLReplacements) + if s.definition.QueryURLReplacements != nil { + queryURL.applyReplacements(s.definition.QueryURLReplacements) } - url := queryURL.constructURL(s.scraper.QueryURL) + url := queryURL.constructURL(s.definition.QueryURL) - scraper := s.getXpathScraper() - - if scraper == nil { - return nil, errors.New("xpath scraper with name " + s.scraper.Scraper + " not found in config") + scraper, err := s.getXpathScraper(s.definition.Scraper) + if err != nil { + return nil, err } doc, err := s.loadURL(ctx, url) @@ -175,7 +169,7 @@ func (s *xpathScraper) scrapeSceneByScene(ctx context.Context, scene *models.Sce return scraper.scrapeScene(ctx, q) } -func (s *xpathScraper) scrapeByFragment(ctx context.Context, input Input) (ScrapedContent, error) { +func (s *xpathFragmentScraper) scrapeByFragment(ctx context.Context, input Input) (ScrapedContent, error) { switch { case input.Gallery != nil: return nil, fmt.Errorf("%w: cannot use an xpath scraper as a gallery fragment scraper", ErrNotSupported) @@ -189,15 +183,14 @@ func (s *xpathScraper) scrapeByFragment(ctx context.Context, input Input) (Scrap // construct the URL queryURL := queryURLParametersFromScrapedScene(scene) - if s.scraper.QueryURLReplacements != nil { - queryURL.applyReplacements(s.scraper.QueryURLReplacements) + if s.definition.QueryURLReplacements != nil { + queryURL.applyReplacements(s.definition.QueryURLReplacements) } - url := queryURL.constructURL(s.scraper.QueryURL) + url := queryURL.constructURL(s.definition.QueryURL) - scraper := s.getXpathScraper() - - if scraper == nil { - return nil, errors.New("xpath scraper with name " + s.scraper.Scraper + " not found in config") + scraper, err := s.getXpathScraper(s.definition.Scraper) + if err != nil { + return nil, err } doc, err := s.loadURL(ctx, url) @@ -210,18 +203,17 @@ func (s *xpathScraper) scrapeByFragment(ctx context.Context, input Input) (Scrap return scraper.scrapeScene(ctx, q) } -func (s *xpathScraper) scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*models.ScrapedGallery, error) { +func (s *xpathFragmentScraper) scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*models.ScrapedGallery, error) { // construct the URL queryURL := queryURLParametersFromGallery(gallery) - if s.scraper.QueryURLReplacements != nil { - queryURL.applyReplacements(s.scraper.QueryURLReplacements) + if s.definition.QueryURLReplacements != nil { + queryURL.applyReplacements(s.definition.QueryURLReplacements) } - url := queryURL.constructURL(s.scraper.QueryURL) + url := queryURL.constructURL(s.definition.QueryURL) - scraper := s.getXpathScraper() - - if scraper == nil { - return nil, errors.New("xpath scraper with name " + s.scraper.Scraper + " not found in config") + scraper, err := s.getXpathScraper(s.definition.Scraper) + if err != nil { + return nil, err } doc, err := s.loadURL(ctx, url) @@ -234,18 +226,17 @@ func (s *xpathScraper) scrapeGalleryByGallery(ctx context.Context, gallery *mode return scraper.scrapeGallery(ctx, q) } -func (s *xpathScraper) scrapeImageByImage(ctx context.Context, image *models.Image) (*models.ScrapedImage, error) { +func (s *xpathFragmentScraper) scrapeImageByImage(ctx context.Context, image *models.Image) (*models.ScrapedImage, error) { // construct the URL queryURL := queryURLParametersFromImage(image) - if s.scraper.QueryURLReplacements != nil { - queryURL.applyReplacements(s.scraper.QueryURLReplacements) + if s.definition.QueryURLReplacements != nil { + queryURL.applyReplacements(s.definition.QueryURLReplacements) } - url := queryURL.constructURL(s.scraper.QueryURL) + url := queryURL.constructURL(s.definition.QueryURL) - scraper := s.getXpathScraper() - - if scraper == nil { - return nil, errors.New("xpath scraper with name " + s.scraper.Scraper + " not found in config") + scraper, err := s.getXpathScraper(s.definition.Scraper) + if err != nil { + return nil, err } doc, err := s.loadURL(ctx, url) @@ -259,14 +250,14 @@ func (s *xpathScraper) scrapeImageByImage(ctx context.Context, image *models.Ima } func (s *xpathScraper) loadURL(ctx context.Context, url string) (*html.Node, error) { - r, err := loadURL(ctx, url, s.client, s.config, s.globalConfig) + r, err := loadURL(ctx, url, s.client, s.definition, s.globalConfig) if err != nil { return nil, fmt.Errorf("failed to load URL %q: %w", url, err) } ret, err := html.Parse(r) - if err == nil && s.config.DebugOptions != nil && s.config.DebugOptions.PrintHTML { + if err == nil && s.definition.DebugOptions != nil && s.definition.DebugOptions.PrintHTML { var b bytes.Buffer if err := html.Render(&b, ret); err != nil { logger.Warnf("could not render HTML: %v", err) diff --git a/pkg/scraper/xpath_test.go b/pkg/scraper/xpath_test.go index 391f60728..42ee2227b 100644 --- a/pkg/scraper/xpath_test.go +++ b/pkg/scraper/xpath_test.go @@ -674,10 +674,10 @@ func verifyPerformers(t *testing.T, expectedNames []string, expectedURLs []strin } if expectedName != actualName { - t.Errorf("Expected performer name %s, got %s", expectedName, actualName) + t.Errorf("Expected performer name %q, got %q", expectedName, actualName) } if expectedURL != actualURL { - t.Errorf("Expected performer URL %s, got %s", expectedName, actualName) + t.Errorf("Expected performer URL %q, got %q", expectedURL, actualURL) } i++ } @@ -780,7 +780,7 @@ xPathScrapers: Name: //studio ` - c := &config{} + c := &Definition{} err := yaml.Unmarshal([]byte(yamlStr), &c) if err != nil { @@ -892,7 +892,7 @@ xPathScrapers: selector: //span ` - c := &config{} + c := &Definition{} err := yaml.Unmarshal([]byte(yamlStr), &c) if err != nil { @@ -904,12 +904,8 @@ xPathScrapers: client := &http.Client{} ctx := context.Background() - s := newGroupScraper(*c, globalConfig) - us, ok := s.(urlScraper) - if !ok { - t.Error("couldn't convert scraper into url scraper") - } - content, err := us.viaURL(ctx, client, ts.URL, ScrapeContentTypePerformer) + s := scraperFromDefinition(*c, globalConfig) + content, err := s.viaURL(ctx, client, ts.URL, ScrapeContentTypePerformer) if err != nil { t.Errorf("Error scraping performer: %s", err.Error()) From b5de30a295ffb855000940e69e8d2047416f869a Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:45:59 +1100 Subject: [PATCH 049/177] Revamp gallery list with sidebar (#6157) * Make list operation utility component * Add defaults for sidebar filters * Refactor gallery list for sidebar * Fix gallery styling * Fix sidebar state issues * Auto-populate query string into name on create * Remove new gallery button from navbar * Make components patchable --- .../src/components/Galleries/Galleries.tsx | 4 +- .../src/components/Galleries/GalleryList.tsx | 648 ++++++++---- ui/v2.5/src/components/Galleries/styles.scss | 1 - .../List/Filters/LabeledIdFilter.tsx | 17 +- .../List/Filters/PerformersFilter.tsx | 26 +- .../components/List/Filters/RatingFilter.tsx | 14 +- .../List/Filters/SidebarDurationFilter.tsx | 10 +- .../components/List/Filters/StudiosFilter.tsx | 26 +- .../components/List/Filters/TagsFilter.tsx | 26 +- .../components/List/ListOperationButtons.tsx | 108 ++ ui/v2.5/src/components/List/styles.scss | 3 +- ui/v2.5/src/components/MainNavbar.tsx | 1 - .../PerformerGalleriesPanel.tsx | 4 +- ui/v2.5/src/components/Scenes/SceneList.tsx | 925 ++++++++---------- .../StudioDetails/StudioGalleriesPanel.tsx | 4 +- .../Tags/TagDetails/TagGalleriesPanel.tsx | 4 +- ui/v2.5/src/docs/en/Manual/UIPluginApi.md | 4 + ui/v2.5/src/index.scss | 2 - ui/v2.5/src/pluginApi.d.ts | 2 + 19 files changed, 1084 insertions(+), 745 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/Galleries.tsx b/ui/v2.5/src/components/Galleries/Galleries.tsx index c845a153c..388ce6720 100644 --- a/ui/v2.5/src/components/Galleries/Galleries.tsx +++ b/ui/v2.5/src/components/Galleries/Galleries.tsx @@ -4,7 +4,7 @@ import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; import Gallery from "./GalleryDetails/Gallery"; import GalleryCreate from "./GalleryDetails/GalleryCreate"; -import { GalleryList } from "./GalleryList"; +import { FilteredGalleryList } from "./GalleryList"; import { View } from "../List/views"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { ErrorMessage } from "../Shared/ErrorMessage"; @@ -40,7 +40,7 @@ const GalleryImage: React.FC> = ({ }; const Galleries: React.FC = () => { - return ; + return ; }; const GalleryRoutes: React.FC = () => { diff --git a/ui/v2.5/src/components/Galleries/GalleryList.tsx b/ui/v2.5/src/components/Galleries/GalleryList.tsx index 4afbab620..de0d23c19 100644 --- a/ui/v2.5/src/components/Galleries/GalleryList.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryList.tsx @@ -1,10 +1,10 @@ -import React, { useState } from "react"; -import { useIntl } from "react-intl"; +import React, { useCallback, useEffect } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; import cloneDeep from "lodash-es/cloneDeep"; -import { useHistory } from "react-router-dom"; +import { useHistory, useLocation } from "react-router-dom"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; -import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList"; +import { useFilteredItemList } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { queryFindGalleries, useFindGalleries } from "src/core/StashService"; @@ -16,17 +16,167 @@ import { GenerateDialog } from "../Dialogs/GenerateDialog"; import { GalleryListTable } from "./GalleryListTable"; import { GalleryCardGrid } from "./GalleryCardGrid"; import { View } from "../List/views"; -import { PatchComponent } from "src/patch"; -import { IItemListOperation } from "../List/FilteredListToolbar"; -import { useModal } from "src/hooks/modal"; +import useFocus from "src/utils/focus"; +import { + Sidebar, + SidebarPane, + SidebarPaneContent, + SidebarStateContext, + useSidebarState, +} from "../Shared/Sidebar"; +import { useCloseEditDelete, useFilterOperations } from "../List/util"; +import { + FilteredSidebarHeader, + useFilteredSidebarKeybinds, +} from "../List/Filters/FilterSidebar"; +import cx from "classnames"; +import { LoadedContent } from "../List/PagedList"; +import { Pagination, PaginationIndex } from "../List/Pagination"; +import { PatchComponent, PatchContainerComponent } from "src/patch"; +import { SidebarStudiosFilter } from "../List/Filters/StudiosFilter"; +import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter"; +import { SidebarTagsFilter } from "../List/Filters/TagsFilter"; +import { SidebarRatingFilter } from "../List/Filters/RatingFilter"; +import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter"; +import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organized"; +import { Button } from "react-bootstrap"; +import { ListOperations } from "../List/ListOperationButtons"; +import { + FilteredListToolbar, + IItemListOperation, +} from "../List/FilteredListToolbar"; +import { FilterTags } from "../List/FilterTags"; -function getItems(result: GQL.FindGalleriesQueryResult) { - return result?.data?.findGalleries?.galleries ?? []; -} +const GalleryList: React.FC<{ + galleries: GQL.SlimGalleryDataFragment[]; + filter: ListFilterModel; + selectedIds: Set; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; +}> = PatchComponent( + "GalleryList", + ({ galleries, filter, selectedIds, onSelectChange }) => { + if (galleries.length === 0) { + return null; + } -function getCount(result: GQL.FindGalleriesQueryResult) { - return result?.data?.findGalleries?.count ?? 0; -} + if (filter.displayMode === DisplayMode.Grid) { + return ( + + ); + } + if (filter.displayMode === DisplayMode.List) { + return ( + + ); + } + if (filter.displayMode === DisplayMode.Wall) { + return ( +
+ {galleries.map((gallery) => ( + + onSelectChange(gallery.id, selected, shiftKey) + } + selecting={selectedIds.size > 0} + /> + ))} +
+ ); + } + + return null; + } +); + +const GalleryFilterSidebarSections = PatchContainerComponent( + "FilteredGalleryList.SidebarSections" +); + +const SidebarContent: React.FC<{ + filter: ListFilterModel; + setFilter: (filter: ListFilterModel) => void; + filterHook?: (filter: ListFilterModel) => ListFilterModel; + view?: View; + sidebarOpen: boolean; + onClose?: () => void; + showEditFilter: (editingCriterion?: string) => void; + count?: number; + focus?: ReturnType; +}> = ({ + filter, + setFilter, + filterHook, + view, + showEditFilter, + sidebarOpen, + onClose, + count, + focus, +}) => { + const showResultsId = + count !== undefined ? "actions.show_count_results" : "actions.show_results"; + + const hideStudios = view === View.StudioScenes; + + return ( + <> + + + + {!hideStudios && ( + + )} + + + + } + data-type={OrganizedCriterionOption.type} + option={OrganizedCriterionOption} + filter={filter} + setFilter={setFilter} + /> + + +
+ +
+ + ); +}; interface IGalleryList { filterHook?: (filter: ListFilterModel) => ListFilterModel; @@ -35,208 +185,324 @@ interface IGalleryList { extraOperations?: IItemListOperation[]; } -export const GalleryList: React.FC = PatchComponent( - "GalleryList", - ({ filterHook, view, alterQuery, extraOperations = [] }) => { +function useViewRandom(filter: ListFilterModel, count: number) { + const history = useHistory(); + + const viewRandom = useCallback(async () => { + // query for a random scene + if (count === 0) { + return; + } + + const index = Math.floor(Math.random() * count); + const filterCopy = cloneDeep(filter); + filterCopy.itemsPerPage = 1; + filterCopy.currentPage = index + 1; + const singleResult = await queryFindGalleries(filterCopy); + if (singleResult.data.findGalleries.galleries.length === 1) { + const { id } = singleResult.data.findGalleries.galleries[0]; + // navigate to the image player page + history.push(`/galleries/${id}`); + } + }, [history, filter, count]); + + return viewRandom; +} + +function useAddKeybinds(filter: ListFilterModel, count: number) { + const viewRandom = useViewRandom(filter, count); + + useEffect(() => { + Mousetrap.bind("p r", () => { + viewRandom(); + }); + + return () => { + Mousetrap.unbind("p r"); + }; + }, [viewRandom]); +} + +export const FilteredGalleryList = PatchComponent( + "FilteredGalleryList", + (props: IGalleryList) => { const intl = useIntl(); const history = useHistory(); - const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); - const [isExportAll, setIsExportAll] = useState(false); - const { modal, showModal, closeModal } = useModal(); + const location = useLocation(); - const filterMode = GQL.FilterMode.Galleries; + const searchFocus = useFocus(); + + const { filterHook, view, alterQuery } = props; + + // States + const { + showSidebar, + setShowSidebar, + sectionOpen, + setSectionOpen, + loading: sidebarStateLoading, + } = useSidebarState(view); + + const { filterState, queryResult, modalState, listSelect, showEditFilter } = + useFilteredItemList({ + filterStateProps: { + filterMode: GQL.FilterMode.Galleries, + view, + useURL: alterQuery, + }, + queryResultProps: { + useResult: useFindGalleries, + getCount: (r) => r.data?.findGalleries.count ?? 0, + getItems: (r) => r.data?.findGalleries.galleries ?? [], + filterHook, + }, + }); + + const { filter, setFilter } = filterState; + + const { effectiveFilter, result, cachedResult, items, totalCount } = + queryResult; + + const { + selectedIds, + selectedItems, + onSelectChange, + onSelectAll, + onSelectNone, + onInvertSelection, + hasSelection, + } = listSelect; + + const { modal, showModal, closeModal } = modalState; + + // Utility hooks + const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({ + filter, + setFilter, + }); + + useAddKeybinds(filter, totalCount); + useFilteredSidebarKeybinds({ + showSidebar, + setShowSidebar, + }); + + useEffect(() => { + Mousetrap.bind("e", () => { + if (hasSelection) { + onEdit?.(); + } + }); + + Mousetrap.bind("d d", () => { + if (hasSelection) { + onDelete?.(); + } + }); + + return () => { + Mousetrap.unbind("e"); + Mousetrap.unbind("d d"); + }; + }); + + const onCloseEditDelete = useCloseEditDelete({ + closeModal, + onSelectNone, + result, + }); + + function onCreateNew() { + let queryParam = new URLSearchParams(location.search).get("q"); + let newPath = "/galleries/new"; + if (queryParam) { + newPath += "?q=" + encodeURIComponent(queryParam); + } + history.push(newPath); + } + + const viewRandom = useViewRandom(filter, totalCount); + + function onExport(all: boolean) { + showModal( + closeModal()} + /> + ); + } + + function onEdit() { + showModal( + + ); + } + + function onDelete() { + showModal( + + ); + } + + function onGenerate() { + showModal( + closeModal()} + /> + ); + } const otherOperations = [ - ...extraOperations, + { + text: intl.formatMessage({ id: "actions.select_all" }), + onClick: () => onSelectAll(), + isDisplayed: () => totalCount > 0, + }, + { + text: intl.formatMessage({ id: "actions.select_none" }), + onClick: () => onSelectNone(), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.invert_selection" }), + onClick: () => onInvertSelection(), + isDisplayed: () => totalCount > 0, + }, { text: intl.formatMessage({ id: "actions.view_random" }), onClick: viewRandom, }, { text: `${intl.formatMessage({ id: "actions.generate" })}…`, - onClick: ( - _result: GQL.FindGalleriesQueryResult, - _filter: ListFilterModel, - selectedIds: Set - ) => { - showModal( - closeModal()} - /> - ); - return Promise.resolve(); - }, - isDisplayed: showWhenSelected, + onClick: onGenerate, + isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.export" }), - onClick: onExport, - isDisplayed: showWhenSelected, + onClick: () => onExport(false), + isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.export_all" }), - onClick: onExportAll, + onClick: () => onExport(true), }, ]; - function addKeybinds( - result: GQL.FindGalleriesQueryResult, - filter: ListFilterModel - ) { - Mousetrap.bind("p r", () => { - viewRandom(result, filter); - }); + // render + if (sidebarStateLoading) return null; - return () => { - Mousetrap.unbind("p r"); - }; - } - - async function viewRandom( - result: GQL.FindGalleriesQueryResult, - filter: ListFilterModel - ) { - // query for a random image - if (result.data?.findGalleries) { - const { count } = result.data.findGalleries; - - const index = Math.floor(Math.random() * count); - const filterCopy = cloneDeep(filter); - filterCopy.itemsPerPage = 1; - filterCopy.currentPage = index + 1; - const singleResult = await queryFindGalleries(filterCopy); - if (singleResult.data.findGalleries.galleries.length === 1) { - const { id } = singleResult.data.findGalleries.galleries[0]; - // navigate to the image player page - history.push(`/galleries/${id}`); - } - } - } - - async function onExport() { - setIsExportAll(false); - setIsExportDialogOpen(true); - } - - async function onExportAll() { - setIsExportAll(true); - setIsExportDialogOpen(true); - } - - function renderContent( - result: GQL.FindGalleriesQueryResult, - filter: ListFilterModel, - selectedIds: Set, - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void - ) { - function maybeRenderGalleryExportDialog() { - if (isExportDialogOpen) { - return ( - setIsExportDialogOpen(false)} - /> - ); - } - } - - function renderGalleries() { - if (!result.data?.findGalleries) return; - - if (filter.displayMode === DisplayMode.Grid) { - return ( - - ); - } - if (filter.displayMode === DisplayMode.List) { - return ( - - ); - } - if (filter.displayMode === DisplayMode.Wall) { - return ( -
-
- {result.data.findGalleries.galleries.map((gallery) => ( - - onSelectChange(gallery.id, selected, shiftKey) - } - selecting={selectedIds.size > 0} - /> - ))} -
-
- ); - } - } - - return ( - <> - {maybeRenderGalleryExportDialog()} - {modal} - {renderGalleries()} - - ); - } - - function renderEditDialog( - selectedImages: GQL.SlimGalleryDataFragment[], - onClose: (applied: boolean) => void - ) { - return ( - - ); - } - - function renderDeleteDialog( - selectedImages: GQL.SlimGalleryDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ( - - ); - } + const operations = ( + + ); return ( - - - + {modal} + + + + setShowSidebar(false)}> + setShowSidebar(false)} + count={cachedResult.loading ? undefined : totalCount} + focus={searchFocus} + /> + + setShowSidebar(!showSidebar)} + > + + + showEditFilter(c.criterionOption.type)} + onRemoveCriterion={removeCriterion} + onRemoveAll={clearAllCriteria} + /> + +
+ setFilter(filter.changePage(page))} + /> + +
+ + + + + + {totalCount > filter.itemsPerPage && ( +
+
+ +
+
+ )} +
+
+
+
); } ); diff --git a/ui/v2.5/src/components/Galleries/styles.scss b/ui/v2.5/src/components/Galleries/styles.scss index 9890e887b..c53175313 100644 --- a/ui/v2.5/src/components/Galleries/styles.scss +++ b/ui/v2.5/src/components/Galleries/styles.scss @@ -229,7 +229,6 @@ div.GalleryWall { display: flex; flex-wrap: wrap; margin: 0 auto; - width: 96vw; /* Prevents last row from consuming all space and stretching images to oblivion */ &::after { diff --git a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx index 200c16917..2e63cd465 100644 --- a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx @@ -18,6 +18,7 @@ import { Option } from "./SidebarListFilter"; import { CriterionModifier, FilterMode, + GalleryFilterType, InputMaybe, IntCriterionInput, SceneFilterType, @@ -515,12 +516,14 @@ export function makeQueryVariables(query: string, extraProps: {}) { interface IFilterType { scenes_filter?: InputMaybe; scene_count?: InputMaybe; + galleries_filter?: InputMaybe; + gallery_count?: InputMaybe; } export function setObjectFilter( out: IFilterType, mode: FilterMode, - relatedFilterOutput: SceneFilterType + relatedFilterOutput: SceneFilterType | GalleryFilterType ) { const empty = Object.keys(relatedFilterOutput).length === 0; @@ -535,5 +538,17 @@ export function setObjectFilter( } out.scenes_filter = relatedFilterOutput; break; + case FilterMode.Galleries: + // if empty, only get objects with galleries + if (empty) { + out.gallery_count = { + modifier: CriterionModifier.GreaterThan, + value: 0, + }; + } + out.galleries_filter = relatedFilterOutput; + break; + default: + throw new Error("Invalid filter mode"); } } diff --git a/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx b/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx index 3df19593f..7e0dee855 100644 --- a/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx @@ -1,5 +1,8 @@ import React, { ReactNode, useMemo } from "react"; -import { PerformersCriterion } from "src/models/list-filter/criteria/performers"; +import { + PerformersCriterion, + PerformersCriterionOption, +} from "src/models/list-filter/criteria/performers"; import { CriterionModifier, FindPerformersForSelectQueryVariables, @@ -18,6 +21,7 @@ import { useLabeledIdFilterState, } from "./LabeledIdFilter"; import { SidebarListFilter } from "./SidebarListFilter"; +import { FormattedMessage } from "react-intl"; interface IPerformersFilter { criterion: PerformersCriterion; @@ -106,12 +110,19 @@ const PerformersFilter: React.FC = ({ export const SidebarPerformersFilter: React.FC<{ title?: ReactNode; - option: CriterionOption; + option?: CriterionOption; filter: ListFilterModel; setFilter: (f: ListFilterModel) => void; filterHook?: (f: ListFilterModel) => ListFilterModel; sectionID?: string; -}> = ({ title, option, filter, setFilter, filterHook, sectionID }) => { +}> = ({ + title = , + option = PerformersCriterionOption, + filter, + setFilter, + filterHook, + sectionID = "performers", +}) => { const state = useLabeledIdFilterState({ filter, setFilter, @@ -120,7 +131,14 @@ export const SidebarPerformersFilter: React.FC<{ useQuery: usePerformerQueryFilter, }); - return ; + return ( + + ); }; export default PerformersFilter; diff --git a/ui/v2.5/src/components/List/Filters/RatingFilter.tsx b/ui/v2.5/src/components/List/Filters/RatingFilter.tsx index 9f5c8f8c9..8a07d54f9 100644 --- a/ui/v2.5/src/components/List/Filters/RatingFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/RatingFilter.tsx @@ -13,7 +13,10 @@ import { defaultRatingSystemOptions, } from "src/utils/rating"; import { useConfigurationContext } from "src/hooks/Config"; -import { RatingCriterion } from "src/models/list-filter/criteria/rating"; +import { + RatingCriterion, + RatingCriterionOption, +} from "src/models/list-filter/criteria/rating"; import { ListFilterModel } from "src/models/list-filter/filter"; import { Option, SidebarListFilter } from "./SidebarListFilter"; @@ -74,7 +77,7 @@ export const RatingFilter: React.FC = ({ interface ISidebarFilter { title?: React.ReactNode; - option: CriterionOption; + option?: CriterionOption; filter: ListFilterModel; setFilter: (f: ListFilterModel) => void; sectionID?: string; @@ -84,11 +87,11 @@ const any = "any"; const none = "none"; export const SidebarRatingFilter: React.FC = ({ - title, - option, + title = , + option = RatingCriterionOption, filter, setFilter, - sectionID, + sectionID = "rating", }) => { const intl = useIntl(); @@ -193,6 +196,7 @@ export const SidebarRatingFilter: React.FC = ({ return ( <> void; sectionID?: string; @@ -55,11 +57,11 @@ function snapToStep(value: number): number { } export const SidebarDurationFilter: React.FC = ({ - title, - option, + title = , + option = DurationCriterionOption, filter, setFilter, - sectionID, + sectionID = "duration", }) => { const criteria = filter.criteriaFor(option.type) as DurationCriterion[]; const criterion = criteria.length > 0 ? criteria[0] : null; diff --git a/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx b/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx index e922e688a..3e28bd927 100644 --- a/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx @@ -5,7 +5,10 @@ import { useFindStudiosForSelectQuery, } from "src/core/generated-graphql"; import { HierarchicalObjectsFilter } from "./SelectableFilter"; -import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; +import { + StudiosCriterion, + StudiosCriterionOption, +} from "src/models/list-filter/criteria/studios"; import { sortByRelevance } from "src/utils/query"; import { CriterionOption } from "src/models/list-filter/criteria/criterion"; import { ListFilterModel } from "src/models/list-filter/filter"; @@ -16,6 +19,7 @@ import { useLabeledIdFilterState, } from "./LabeledIdFilter"; import { SidebarListFilter } from "./SidebarListFilter"; +import { FormattedMessage } from "react-intl"; interface IStudiosFilter { criterion: StudiosCriterion; @@ -94,12 +98,19 @@ const StudiosFilter: React.FC = ({ export const SidebarStudiosFilter: React.FC<{ title?: ReactNode; - option: CriterionOption; + option?: CriterionOption; filter: ListFilterModel; setFilter: (f: ListFilterModel) => void; filterHook?: (f: ListFilterModel) => ListFilterModel; sectionID?: string; -}> = ({ title, option, filter, setFilter, filterHook, sectionID }) => { +}> = ({ + title = , + option = StudiosCriterionOption, + filter, + setFilter, + filterHook, + sectionID = "studios", +}) => { const state = useLabeledIdFilterState({ filter, setFilter, @@ -111,7 +122,14 @@ export const SidebarStudiosFilter: React.FC<{ includeSubMessageID: "subsidiary_studios", }); - return ; + return ( + + ); }; export default StudiosFilter; diff --git a/ui/v2.5/src/components/List/Filters/TagsFilter.tsx b/ui/v2.5/src/components/List/Filters/TagsFilter.tsx index f4c618ffa..446a90331 100644 --- a/ui/v2.5/src/components/List/Filters/TagsFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/TagsFilter.tsx @@ -16,7 +16,11 @@ import { useLabeledIdFilterState, } from "./LabeledIdFilter"; import { SidebarListFilter } from "./SidebarListFilter"; -import { TagsCriterion } from "src/models/list-filter/criteria/tags"; +import { + TagsCriterion, + TagsCriterionOption, +} from "src/models/list-filter/criteria/tags"; +import { FormattedMessage } from "react-intl"; interface ITagsFilter { criterion: TagsCriterion; @@ -99,12 +103,19 @@ const TagsFilter: React.FC = ({ criterion, setCriterion }) => { export const SidebarTagsFilter: React.FC<{ title?: ReactNode; - option: CriterionOption; + option?: CriterionOption; filter: ListFilterModel; setFilter: (f: ListFilterModel) => void; filterHook?: (f: ListFilterModel) => ListFilterModel; sectionID?: string; -}> = ({ title, option, filter, setFilter, filterHook, sectionID }) => { +}> = ({ + title = , + option = TagsCriterionOption, + filter, + setFilter, + filterHook, + sectionID = "tags", +}) => { const state = useLabeledIdFilterState({ filter, setFilter, @@ -115,7 +126,14 @@ export const SidebarTagsFilter: React.FC<{ includeSubMessageID: "sub_tags", }); - return ; + return ( + + ); }; export default TagsFilter; diff --git a/ui/v2.5/src/components/List/ListOperationButtons.tsx b/ui/v2.5/src/components/List/ListOperationButtons.tsx index b377cedba..314c28bf8 100644 --- a/ui/v2.5/src/components/List/ListOperationButtons.tsx +++ b/ui/v2.5/src/components/List/ListOperationButtons.tsx @@ -6,7 +6,10 @@ import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; import { Icon } from "../Shared/Icon"; import { faEllipsisH, + faPencil, faPencilAlt, + faPlay, + faPlus, faTrash, } from "@fortawesome/free-solid-svg-icons"; import cx from "classnames"; @@ -264,3 +267,108 @@ export const ListOperationButtons: React.FC = ({ ); }; + +interface IListOperations { + text: string; + onClick: () => void; + isDisplayed?: () => boolean; + className?: string; +} + +export const ListOperations: React.FC<{ + items: number; + hasSelection?: boolean; + operations?: IListOperations[]; + onEdit?: () => void; + onDelete?: () => void; + onPlay?: () => void; + onCreateNew?: () => void; + entityType?: string; + operationsClassName?: string; + operationsMenuClassName?: string; +}> = ({ + items, + hasSelection = false, + operations = [], + onEdit, + onDelete, + onPlay, + onCreateNew, + entityType, + operationsClassName = "list-operations", + operationsMenuClassName, +}) => { + const intl = useIntl(); + + return ( +
+ + {!!items && onPlay && ( + + )} + {!hasSelection && onCreateNew && ( + + )} + + {hasSelection && (onEdit || onDelete) && ( + <> + {onEdit && ( + + )} + {onDelete && ( + + )} + + )} + + {operations.length > 0 && ( + + {operations.map((o) => { + if (o.isDisplayed && !o.isDisplayed()) { + return null; + } + + return ( + + ); + })} + + )} + +
+ ); +}; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 5f1b4da2a..8a7fdf8cf 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -1120,7 +1120,8 @@ input[type="range"].zoom-slider { justify-content: flex-end; } -.scene-list-toolbar .selected-items-info { +.scene-list-toolbar .selected-items-info, +.gallery-list-toolbar .selected-items-info { justify-content: flex-start; } diff --git a/ui/v2.5/src/components/MainNavbar.tsx b/ui/v2.5/src/components/MainNavbar.tsx index caee46f0c..ac1be2c13 100644 --- a/ui/v2.5/src/components/MainNavbar.tsx +++ b/ui/v2.5/src/components/MainNavbar.tsx @@ -132,7 +132,6 @@ const allMenuItems: IMenuItem[] = [ href: "/galleries", icon: faImages, hotkey: "g l", - userCreatable: true, }, { name: "performers", diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx index 5a9d0b81d..44b0401e9 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx @@ -1,6 +1,6 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; -import { GalleryList } from "src/components/Galleries/GalleryList"; +import { FilteredGalleryList } from "src/components/Galleries/GalleryList"; import { usePerformerFilterHook } from "src/core/performers"; import { View } from "src/components/List/views"; import { PatchComponent } from "src/patch"; @@ -14,7 +14,7 @@ export const PerformerGalleriesPanel: React.FC = PatchComponent("PerformerGalleriesPanel", ({ active, performer }) => { const filterHook = usePerformerFilterHook(performer); return ( - ; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; fromGroupId?: string; -}> = ({ scenes, filter, selectedIds, onSelectChange, fromGroupId }) => { - const queue = useMemo(() => SceneQueue.fromListFilterModel(filter), [filter]); +}> = PatchComponent( + "SceneList", + ({ scenes, filter, selectedIds, onSelectChange, fromGroupId }) => { + const queue = useMemo( + () => SceneQueue.fromListFilterModel(filter), + [filter] + ); + + if (scenes.length === 0 && filter.displayMode !== DisplayMode.Tagger) { + return null; + } + + if (filter.displayMode === DisplayMode.Grid) { + return ( + + ); + } + if (filter.displayMode === DisplayMode.List) { + return ( + + ); + } + if (filter.displayMode === DisplayMode.Wall) { + return ( + + ); + } + if (filter.displayMode === DisplayMode.Tagger) { + return ( + + ); + } - if (scenes.length === 0 && filter.displayMode !== DisplayMode.Tagger) { return null; } - - if (filter.displayMode === DisplayMode.Grid) { - return ( - - ); - } - if (filter.displayMode === DisplayMode.List) { - return ( - - ); - } - if (filter.displayMode === DisplayMode.Wall) { - return ( - - ); - } - if (filter.displayMode === DisplayMode.Tagger) { - return ( - - ); - } - - return null; -}; +); const ScenesFilterSidebarSections = PatchContainerComponent( "FilteredSceneList.SidebarSections" @@ -298,48 +287,23 @@ const SidebarContent: React.FC<{ {!hideStudios && ( } - data-type={StudiosCriterionOption.type} - option={StudiosCriterionOption} filter={filter} setFilter={setFilter} filterHook={filterHook} - sectionID="studios" /> )} } - data-type={PerformersCriterionOption.type} - option={PerformersCriterionOption} filter={filter} setFilter={setFilter} filterHook={filterHook} - sectionID="performers" /> } - data-type={TagsCriterionOption.type} - option={TagsCriterionOption} filter={filter} setFilter={setFilter} filterHook={filterHook} - sectionID="tags" - /> - } - data-type={RatingCriterionOption.type} - option={RatingCriterionOption} - filter={filter} - setFilter={setFilter} - sectionID="rating" - /> - } - option={DurationCriterionOption} - filter={filter} - setFilter={setFilter} - sectionID="duration" /> + + } data-type={HasMarkersCriterionOption.type} @@ -374,102 +338,6 @@ const SidebarContent: React.FC<{ ); }; -interface IOperations { - text: string; - onClick: () => void; - isDisplayed?: () => boolean; - className?: string; -} - -const SceneListOperations: React.FC<{ - items: number; - hasSelection: boolean; - operations: IOperations[]; - onEdit: () => void; - onDelete: () => void; - onPlay: () => void; - onCreateNew: () => void; -}> = PatchComponent( - "SceneListOperations", - ({ - items, - hasSelection, - operations, - onEdit, - onDelete, - onPlay, - onCreateNew, - }) => { - const intl = useIntl(); - - return ( -
- - {!!items && ( - - )} - {!hasSelection && ( - - )} - - {hasSelection && ( - <> - - - - )} - - - {operations.map((o) => { - if (o.isDisplayed && !o.isDisplayed()) { - return null; - } - - return ( - - ); - })} - - -
- ); - } -); - interface IFilteredScenes { filterHook?: (filter: ListFilterModel) => ListFilterModel; defaultSort?: string; @@ -478,362 +346,381 @@ interface IFilteredScenes { fromGroupId?: string; } -export const FilteredSceneList = (props: IFilteredScenes) => { - const intl = useIntl(); - const history = useHistory(); +export const FilteredSceneList = PatchComponent( + "FilteredSceneList", + (props: IFilteredScenes) => { + const intl = useIntl(); + const history = useHistory(); + const location = useLocation(); - const searchFocus = useFocus(); + const searchFocus = useFocus(); - const { filterHook, defaultSort, view, alterQuery, fromGroupId } = props; + const { filterHook, defaultSort, view, alterQuery, fromGroupId } = props; - // States - const { - showSidebar, - setShowSidebar, - loading: sidebarStateLoading, - sectionOpen, - setSectionOpen, - } = useSidebarState(view); + // States + const { + showSidebar, + setShowSidebar, + loading: sidebarStateLoading, + sectionOpen, + setSectionOpen, + } = useSidebarState(view); - const { filterState, queryResult, modalState, listSelect, showEditFilter } = - useFilteredItemList({ - filterStateProps: { - filterMode: GQL.FilterMode.Scenes, - defaultSort, - view, - useURL: alterQuery, - }, - queryResultProps: { - useResult: useFindScenes, - getCount: (r) => r.data?.findScenes.count ?? 0, - getItems: (r) => r.data?.findScenes.scenes ?? [], - filterHook, - }, + const { filterState, queryResult, modalState, listSelect, showEditFilter } = + useFilteredItemList({ + filterStateProps: { + filterMode: GQL.FilterMode.Scenes, + defaultSort, + view, + useURL: alterQuery, + }, + queryResultProps: { + useResult: useFindScenes, + getCount: (r) => r.data?.findScenes.count ?? 0, + getItems: (r) => r.data?.findScenes.scenes ?? [], + filterHook, + }, + }); + + const { filter, setFilter } = filterState; + + const { effectiveFilter, result, cachedResult, items, totalCount } = + queryResult; + + const { + selectedIds, + selectedItems, + onSelectChange, + onSelectAll, + onSelectNone, + onInvertSelection, + hasSelection, + } = listSelect; + + const { modal, showModal, closeModal } = modalState; + + // Utility hooks + const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({ + filter, + setFilter, }); - const { filter, setFilter } = filterState; + useAddKeybinds(filter, totalCount); + useFilteredSidebarKeybinds({ + showSidebar, + setShowSidebar, + }); - const { effectiveFilter, result, cachedResult, items, totalCount } = - queryResult; + const onCloseEditDelete = useCloseEditDelete({ + closeModal, + onSelectNone, + result, + }); - const { - selectedIds, - selectedItems, - onSelectChange, - onSelectAll, - onSelectNone, - onInvertSelection, - hasSelection, - } = listSelect; + const onEdit = useCallback(() => { + showModal( + + ); + }, [showModal, selectedItems, onCloseEditDelete]); - const { modal, showModal, closeModal } = modalState; + const onDelete = useCallback(() => { + showModal( + + ); + }, [showModal, selectedItems, onCloseEditDelete]); - // Utility hooks - const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({ - filter, - setFilter, - }); + useEffect(() => { + Mousetrap.bind("e", () => { + if (hasSelection) { + onEdit?.(); + } + }); - useAddKeybinds(filter, totalCount); - useFilteredSidebarKeybinds({ - showSidebar, - setShowSidebar, - }); + Mousetrap.bind("d d", () => { + if (hasSelection) { + onDelete?.(); + } + }); - const onCloseEditDelete = useCloseEditDelete({ - closeModal, - onSelectNone, - result, - }); + return () => { + Mousetrap.unbind("e"); + Mousetrap.unbind("d d"); + }; + }, [onSelectAll, onSelectNone, hasSelection, onEdit, onDelete]); + useZoomKeybinds({ + zoomIndex: filter.zoomIndex, + onChangeZoom: (zoom) => setFilter(filter.setZoom(zoom)), + }); - const onEdit = useCallback(() => { - showModal( - + const metadataByline = useMemo(() => { + if (cachedResult.loading) return null; + + return renderMetadataByline(cachedResult) ?? null; + }, [cachedResult]); + + const queue = useMemo( + () => SceneQueue.fromListFilterModel(filter), + [filter] ); - }, [showModal, selectedItems, onCloseEditDelete]); - const onDelete = useCallback(() => { - showModal( - - ); - }, [showModal, selectedItems, onCloseEditDelete]); + const playRandom = usePlayRandom(effectiveFilter, totalCount); + const playSelected = usePlaySelected(selectedIds); + const playFirst = usePlayFirst(); - useEffect(() => { - Mousetrap.bind("e", () => { - if (hasSelection) { - onEdit?.(); + function onCreateNew() { + let queryParam = new URLSearchParams(location.search).get("q"); + let newPath = "/scenes/new"; + if (queryParam) { + newPath += "?q=" + encodeURIComponent(queryParam); } - }); - - Mousetrap.bind("d d", () => { - if (hasSelection) { - onDelete?.(); - } - }); - - return () => { - Mousetrap.unbind("e"); - Mousetrap.unbind("d d"); - }; - }, [onSelectAll, onSelectNone, hasSelection, onEdit, onDelete]); - useZoomKeybinds({ - zoomIndex: filter.zoomIndex, - onChangeZoom: (zoom) => setFilter(filter.setZoom(zoom)), - }); - - const metadataByline = useMemo(() => { - if (cachedResult.loading) return null; - - return renderMetadataByline(cachedResult) ?? null; - }, [cachedResult]); - - const queue = useMemo(() => SceneQueue.fromListFilterModel(filter), [filter]); - - const playRandom = usePlayRandom(effectiveFilter, totalCount); - const playSelected = usePlaySelected(selectedIds); - const playFirst = usePlayFirst(); - - function onCreateNew() { - history.push("/scenes/new"); - } - - function onPlay() { - if (items.length === 0) { - return; + history.push(newPath); } - // if there are selected items, play those - if (hasSelection) { - playSelected(); - return; + function onPlay() { + if (items.length === 0) { + return; + } + + // if there are selected items, play those + if (hasSelection) { + playSelected(); + return; + } + + // otherwise, play the first item in the list + const sceneID = items[0].id; + playFirst(queue, sceneID, 0); } - // otherwise, play the first item in the list - const sceneID = items[0].id; - playFirst(queue, sceneID, 0); - } + function onExport(all: boolean) { + showModal( + closeModal()} + /> + ); + } - function onExport(all: boolean) { - showModal( - closeModal()} + function onMerge() { + const selected = + selectedItems.map((s) => { + return { + id: s.id, + title: objectTitle(s), + }; + }) ?? []; + showModal( + { + closeModal(); + if (mergedID) { + history.push(`/scenes/${mergedID}`); + } + }} + show + /> + ); + } + + const otherOperations = [ + { + text: intl.formatMessage({ id: "actions.play" }), + onClick: () => onPlay(), + isDisplayed: () => items.length > 0, + className: "play-item", + }, + { + text: intl.formatMessage( + { id: "actions.create_entity" }, + { entityType: intl.formatMessage({ id: "scene" }) } + ), + onClick: () => onCreateNew(), + isDisplayed: () => !hasSelection, + className: "create-new-item", + }, + { + text: intl.formatMessage({ id: "actions.select_all" }), + onClick: () => onSelectAll(), + isDisplayed: () => totalCount > 0, + }, + { + text: intl.formatMessage({ id: "actions.select_none" }), + onClick: () => onSelectNone(), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.invert_selection" }), + onClick: () => onInvertSelection(), + isDisplayed: () => totalCount > 0, + }, + { + text: intl.formatMessage({ id: "actions.play_random" }), + onClick: playRandom, + isDisplayed: () => totalCount > 1, + }, + { + text: `${intl.formatMessage({ id: "actions.generate" })}…`, + onClick: () => + showModal( + closeModal()} + /> + ), + isDisplayed: () => hasSelection, + }, + { + text: `${intl.formatMessage({ id: "actions.identify" })}…`, + onClick: () => + showModal( + closeModal()} + /> + ), + isDisplayed: () => hasSelection, + }, + { + text: `${intl.formatMessage({ id: "actions.merge" })}…`, + onClick: () => onMerge(), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.export" }), + onClick: () => onExport(false), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.export_all" }), + onClick: () => onExport(true), + }, + ]; + + // render + if (sidebarStateLoading) return null; + + const operations = ( + ); - } - function onMerge() { - const selected = - selectedItems.map((s) => { - return { - id: s.id, - title: objectTitle(s), - }; - }) ?? []; - showModal( - { - closeModal(); - if (mergedID) { - history.push(`/scenes/${mergedID}`); - } - }} - show - /> - ); - } + return ( + +
+ {modal} - const otherOperations = [ - { - text: intl.formatMessage({ id: "actions.play" }), - onClick: () => onPlay(), - isDisplayed: () => items.length > 0, - className: "play-item", - }, - { - text: intl.formatMessage( - { id: "actions.create_entity" }, - { entityType: intl.formatMessage({ id: "scene" }) } - ), - onClick: () => onCreateNew(), - isDisplayed: () => !hasSelection, - className: "create-new-item", - }, - { - text: intl.formatMessage({ id: "actions.select_all" }), - onClick: () => onSelectAll(), - isDisplayed: () => totalCount > 0, - }, - { - text: intl.formatMessage({ id: "actions.select_none" }), - onClick: () => onSelectNone(), - isDisplayed: () => hasSelection, - }, - { - text: intl.formatMessage({ id: "actions.invert_selection" }), - onClick: () => onInvertSelection(), - isDisplayed: () => totalCount > 0, - }, - { - text: intl.formatMessage({ id: "actions.play_random" }), - onClick: playRandom, - isDisplayed: () => totalCount > 1, - }, - { - text: `${intl.formatMessage({ id: "actions.generate" })}…`, - onClick: () => - showModal( - closeModal()} - /> - ), - isDisplayed: () => hasSelection, - }, - { - text: `${intl.formatMessage({ id: "actions.identify" })}…`, - onClick: () => - showModal( - closeModal()} - /> - ), - isDisplayed: () => hasSelection, - }, - { - text: `${intl.formatMessage({ id: "actions.merge" })}…`, - onClick: () => onMerge(), - isDisplayed: () => hasSelection, - }, - { - text: intl.formatMessage({ id: "actions.export" }), - onClick: () => onExport(false), - isDisplayed: () => hasSelection, - }, - { - text: intl.formatMessage({ id: "actions.export_all" }), - onClick: () => onExport(true), - }, - ]; - - // render - if (sidebarStateLoading) return null; - - const operations = ( - - ); - - return ( - -
- {modal} - - - - setShowSidebar(false)}> - setShowSidebar(false)} - count={cachedResult.loading ? undefined : totalCount} - focus={searchFocus} - /> - - setShowSidebar(!showSidebar)} - > - - - showEditFilter(c.criterionOption.type)} - onRemoveCriterion={removeCriterion} - onRemoveAll={clearAllCriteria} - /> - -
- setFilter(filter.changePage(page))} + + + setShowSidebar(false)}> + setShowSidebar(false)} + count={cachedResult.loading ? undefined : totalCount} + focus={searchFocus} /> - + setShowSidebar(!showSidebar)} + > + -
- - + showEditFilter(c.criterionOption.type) + } + onRemoveCriterion={removeCriterion} + onRemoveAll={clearAllCriteria} /> - - {totalCount > filter.itemsPerPage && ( -
-
- -
+
+ setFilter(filter.changePage(page))} + /> +
- )} - - - -
- - ); -}; + + + + + + {totalCount > filter.itemsPerPage && ( +
+
+ +
+
+ )} +
+
+
+
+
+ ); + } +); export default FilteredSceneList; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioGalleriesPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioGalleriesPanel.tsx index 340586b94..f5a1aba32 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioGalleriesPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioGalleriesPanel.tsx @@ -1,6 +1,6 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; -import { GalleryList } from "src/components/Galleries/GalleryList"; +import { FilteredGalleryList } from "src/components/Galleries/GalleryList"; import { useStudioFilterHook } from "src/core/studios"; import { View } from "src/components/List/views"; @@ -17,7 +17,7 @@ export const StudioGalleriesPanel: React.FC = ({ }) => { const filterHook = useStudioFilterHook(studio, showChildStudioContent); return ( - = ({ }) => { const filterHook = useTagFilterHook(tag, showSubTagContent); return ( - ; ExternalLinkButtons: React.FC; ExternalLinksButton: React.FC; + FilteredGalleryList: React.FC; + FilteredSceneList: React.FC; FolderSelect: React.FC; FrontPage: React.FC; GalleryCard: React.FC; From 9eda7c2f602db5a26e53f405c2f8299211f7911a Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:01:29 +1100 Subject: [PATCH 050/177] Studio custom fields backend support (#6156) --- graphql/schema/types/filters.graphql | 2 + graphql/schema/types/studio.graphql | 6 + internal/api/loaders/dataloaders.go | 21 +- internal/api/resolver_model_studio.go | 13 + internal/api/resolver_mutation_studio.go | 8 +- internal/autotag/integration_test.go | 7 +- internal/identify/scene_test.go | 2 +- internal/identify/studio_test.go | 6 +- pkg/gallery/import.go | 2 +- pkg/gallery/import_test.go | 8 +- pkg/group/import.go | 2 +- pkg/group/import_test.go | 8 +- pkg/image/import.go | 2 +- pkg/image/import_test.go | 8 +- pkg/models/mocks/StudioReaderWriter.go | 54 +- pkg/models/model_scraped_item.go | 4 +- pkg/models/model_scraped_item_test.go | 2 +- pkg/models/model_studio.go | 21 + pkg/models/repository_studio.go | 6 +- pkg/models/studio.go | 7 + pkg/scene/import.go | 2 +- pkg/scene/import_test.go | 8 +- pkg/sqlite/database.go | 2 +- .../migrations/76_studio_custom_fields.up.sql | 9 + pkg/sqlite/setup_test.go | 25 +- pkg/sqlite/studio.go | 30 +- pkg/sqlite/studio_filter.go | 7 + pkg/sqlite/studio_test.go | 576 +++++++++++++++++- pkg/sqlite/tables.go | 1 + pkg/studio/import.go | 6 +- pkg/studio/import_test.go | 18 +- pkg/studio/validate.go | 2 +- 32 files changed, 796 insertions(+), 79 deletions(-) create mode 100644 pkg/sqlite/migrations/76_studio_custom_fields.up.sql diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 4e70b7353..d0d6a4b65 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -487,6 +487,8 @@ input StudioFilterType { created_at: TimestampCriterionInput "Filter by last update time" updated_at: TimestampCriterionInput + + custom_fields: [CustomFieldCriterionInput!] } input GalleryFilterType { diff --git a/graphql/schema/types/studio.graphql b/graphql/schema/types/studio.graphql index a1e1659ec..3e991ce96 100644 --- a/graphql/schema/types/studio.graphql +++ b/graphql/schema/types/studio.graphql @@ -26,6 +26,8 @@ type Studio { groups: [Group!]! movies: [Movie!]! @deprecated(reason: "use groups instead") o_counter: Int + + custom_fields: Map! } input StudioCreateInput { @@ -44,6 +46,8 @@ input StudioCreateInput { aliases: [String!] tag_ids: [ID!] ignore_auto_tag: Boolean + + custom_fields: Map } input StudioUpdateInput { @@ -63,6 +67,8 @@ input StudioUpdateInput { aliases: [String!] tag_ids: [ID!] ignore_auto_tag: Boolean + + custom_fields: CustomFieldsInput } input BulkStudioUpdateInput { diff --git a/internal/api/loaders/dataloaders.go b/internal/api/loaders/dataloaders.go index 38f72b0a1..4676966c9 100644 --- a/internal/api/loaders/dataloaders.go +++ b/internal/api/loaders/dataloaders.go @@ -59,7 +59,9 @@ type Loaders struct { PerformerByID *PerformerLoader PerformerCustomFields *CustomFieldsLoader - StudioByID *StudioLoader + StudioByID *StudioLoader + StudioCustomFields *CustomFieldsLoader + TagByID *TagLoader GroupByID *GroupLoader FileByID *FileLoader @@ -99,6 +101,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler { maxBatch: maxBatch, fetch: m.fetchPerformerCustomFields(ctx), }, + StudioCustomFields: &CustomFieldsLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchStudioCustomFields(ctx), + }, StudioByID: &StudioLoader{ wait: wait, maxBatch: maxBatch, @@ -253,6 +260,18 @@ func (m Middleware) fetchStudios(ctx context.Context) func(keys []int) ([]*model } } +func (m Middleware) fetchStudioCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) { + return func(keys []int) (ret []models.CustomFieldMap, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Studio.GetCustomFieldsBulk(ctx, keys) + return err + }) + + return ret, toErrorSlice(err) + } +} + func (m Middleware) fetchTags(ctx context.Context) func(keys []int) ([]*models.Tag, []error) { return func(keys []int) (ret []*models.Tag, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { diff --git a/internal/api/resolver_model_studio.go b/internal/api/resolver_model_studio.go index fabcf38bd..b54455920 100644 --- a/internal/api/resolver_model_studio.go +++ b/internal/api/resolver_model_studio.go @@ -207,6 +207,19 @@ func (r *studioResolver) Groups(ctx context.Context, obj *models.Studio) (ret [] return ret, nil } +func (r *studioResolver) CustomFields(ctx context.Context, obj *models.Studio) (map[string]interface{}, error) { + m, err := loaders.From(ctx).StudioCustomFields.Load(obj.ID) + if err != nil { + return nil, err + } + + if m == nil { + return make(map[string]interface{}), nil + } + + return m, nil +} + // deprecated func (r *studioResolver) Movies(ctx context.Context, obj *models.Studio) (ret []*models.Group, err error) { return r.Groups(ctx, obj) diff --git a/internal/api/resolver_mutation_studio.go b/internal/api/resolver_mutation_studio.go index fdd700490..e3e1c6395 100644 --- a/internal/api/resolver_mutation_studio.go +++ b/internal/api/resolver_mutation_studio.go @@ -31,7 +31,7 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio } // Populate a new studio from the input - newStudio := models.NewStudio() + newStudio := models.NewCreateStudioInput() newStudio.Name = strings.TrimSpace(input.Name) newStudio.Rating = input.Rating100 @@ -61,6 +61,7 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } + newStudio.CustomFields = convertMapJSONNumbers(input.CustomFields) // Process the base 64 encoded image string var imageData []byte @@ -152,6 +153,11 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio } } + updatedStudio.CustomFields = input.CustomFields + // convert json.Numbers to int/float + updatedStudio.CustomFields.Full = convertMapJSONNumbers(updatedStudio.CustomFields.Full) + updatedStudio.CustomFields.Partial = convertMapJSONNumbers(updatedStudio.CustomFields.Partial) + // Process the base 64 encoded image string var imageData []byte imageIncluded := translator.hasField("image") diff --git a/internal/autotag/integration_test.go b/internal/autotag/integration_test.go index fc83df848..605082b98 100644 --- a/internal/autotag/integration_test.go +++ b/internal/autotag/integration_test.go @@ -101,16 +101,15 @@ func createPerformer(ctx context.Context, pqb models.PerformerWriter) error { func createStudio(ctx context.Context, qb models.StudioWriter, name string) (*models.Studio, error) { // create the studio - studio := models.Studio{ - Name: name, - } + studio := models.NewCreateStudioInput() + studio.Name = name err := qb.Create(ctx, &studio) if err != nil { return nil, err } - return &studio, nil + return studio.Studio, nil } func createTag(ctx context.Context, qb models.TagWriter) error { diff --git a/internal/identify/scene_test.go b/internal/identify/scene_test.go index a76aef516..0eec61c4e 100644 --- a/internal/identify/scene_test.go +++ b/internal/identify/scene_test.go @@ -27,7 +27,7 @@ func Test_sceneRelationships_studio(t *testing.T) { db := mocks.NewDatabase() db.Studio.On("Create", testCtx, mock.Anything).Run(func(args mock.Arguments) { - s := args.Get(1).(*models.Studio) + s := args.Get(1).(*models.CreateStudioInput) s.ID = validStoredIDInt }).Return(nil) diff --git a/internal/identify/studio_test.go b/internal/identify/studio_test.go index 5424a6a93..083675650 100644 --- a/internal/identify/studio_test.go +++ b/internal/identify/studio_test.go @@ -21,13 +21,13 @@ func Test_createMissingStudio(t *testing.T) { db := mocks.NewDatabase() - db.Studio.On("Create", testCtx, mock.MatchedBy(func(p *models.Studio) bool { + db.Studio.On("Create", testCtx, mock.MatchedBy(func(p *models.CreateStudioInput) bool { return p.Name == validName })).Run(func(args mock.Arguments) { - s := args.Get(1).(*models.Studio) + s := args.Get(1).(*models.CreateStudioInput) s.ID = createdID }).Return(nil) - db.Studio.On("Create", testCtx, mock.MatchedBy(func(p *models.Studio) bool { + db.Studio.On("Create", testCtx, mock.MatchedBy(func(p *models.CreateStudioInput) bool { return p.Name == invalidName })).Return(errors.New("error creating studio")) diff --git a/pkg/gallery/import.go b/pkg/gallery/import.go index 0068b3f1c..543d4cf48 100644 --- a/pkg/gallery/import.go +++ b/pkg/gallery/import.go @@ -126,7 +126,7 @@ func (i *Importer) populateStudio(ctx context.Context) error { } func (i *Importer) createStudio(ctx context.Context, name string) (int, error) { - newStudio := models.NewStudio() + newStudio := models.NewCreateStudioInput() newStudio.Name = name err := i.StudioWriter.Create(ctx, &newStudio) diff --git a/pkg/gallery/import_test.go b/pkg/gallery/import_test.go index b64f80d8f..4248f51bc 100644 --- a/pkg/gallery/import_test.go +++ b/pkg/gallery/import_test.go @@ -115,9 +115,9 @@ func TestImporterPreImportWithMissingStudio(t *testing.T) { } db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Times(3) - db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.Studio")).Run(func(args mock.Arguments) { - s := args.Get(1).(*models.Studio) - s.ID = existingStudioID + db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Run(func(args mock.Arguments) { + s := args.Get(1).(*models.CreateStudioInput) + s.Studio.ID = existingStudioID }).Return(nil) err := i.PreImport(testCtx) @@ -147,7 +147,7 @@ func TestImporterPreImportWithMissingStudioCreateErr(t *testing.T) { } db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Once() - db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.Studio")).Return(errors.New("Create error")) + db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) diff --git a/pkg/group/import.go b/pkg/group/import.go index 3fc7db8f1..a73c3998e 100644 --- a/pkg/group/import.go +++ b/pkg/group/import.go @@ -203,7 +203,7 @@ func (i *Importer) populateStudio(ctx context.Context) error { } func (i *Importer) createStudio(ctx context.Context, name string) (int, error) { - newStudio := models.NewStudio() + newStudio := models.NewCreateStudioInput() newStudio.Name = name err := i.StudioWriter.Create(ctx, &newStudio) diff --git a/pkg/group/import_test.go b/pkg/group/import_test.go index c4ca47442..50b8b2dd1 100644 --- a/pkg/group/import_test.go +++ b/pkg/group/import_test.go @@ -121,9 +121,9 @@ func TestImporterPreImportWithMissingStudio(t *testing.T) { } db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Times(3) - db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.Studio")).Run(func(args mock.Arguments) { - s := args.Get(1).(*models.Studio) - s.ID = existingStudioID + db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Run(func(args mock.Arguments) { + s := args.Get(1).(*models.CreateStudioInput) + s.Studio.ID = existingStudioID }).Return(nil) err := i.PreImport(testCtx) @@ -156,7 +156,7 @@ func TestImporterPreImportWithMissingStudioCreateErr(t *testing.T) { } db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Once() - db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.Studio")).Return(errors.New("Create error")) + db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) diff --git a/pkg/image/import.go b/pkg/image/import.go index bf92a6ae8..77b6d7477 100644 --- a/pkg/image/import.go +++ b/pkg/image/import.go @@ -159,7 +159,7 @@ func (i *Importer) populateStudio(ctx context.Context) error { } func (i *Importer) createStudio(ctx context.Context, name string) (int, error) { - newStudio := models.NewStudio() + newStudio := models.NewCreateStudioInput() newStudio.Name = name err := i.StudioWriter.Create(ctx, &newStudio) diff --git a/pkg/image/import_test.go b/pkg/image/import_test.go index 286e51fe3..98b3972b9 100644 --- a/pkg/image/import_test.go +++ b/pkg/image/import_test.go @@ -77,9 +77,9 @@ func TestImporterPreImportWithMissingStudio(t *testing.T) { } db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Times(3) - db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.Studio")).Run(func(args mock.Arguments) { - s := args.Get(1).(*models.Studio) - s.ID = existingStudioID + db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Run(func(args mock.Arguments) { + s := args.Get(1).(*models.CreateStudioInput) + s.Studio.ID = existingStudioID }).Return(nil) err := i.PreImport(testCtx) @@ -109,7 +109,7 @@ func TestImporterPreImportWithMissingStudioCreateErr(t *testing.T) { } db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Once() - db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.Studio")).Return(errors.New("Create error")) + db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) diff --git a/pkg/models/mocks/StudioReaderWriter.go b/pkg/models/mocks/StudioReaderWriter.go index 481565d6f..f57a73aa1 100644 --- a/pkg/models/mocks/StudioReaderWriter.go +++ b/pkg/models/mocks/StudioReaderWriter.go @@ -80,11 +80,11 @@ func (_m *StudioReaderWriter) CountByTagID(ctx context.Context, tagID int) (int, } // Create provides a mock function with given fields: ctx, newStudio -func (_m *StudioReaderWriter) Create(ctx context.Context, newStudio *models.Studio) error { +func (_m *StudioReaderWriter) Create(ctx context.Context, newStudio *models.CreateStudioInput) error { ret := _m.Called(ctx, newStudio) var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *models.Studio) error); ok { + if rf, ok := ret.Get(0).(func(context.Context, *models.CreateStudioInput) error); ok { r0 = rf(ctx, newStudio) } else { r0 = ret.Error(0) @@ -291,6 +291,52 @@ func (_m *StudioReaderWriter) GetAliases(ctx context.Context, relatedID int) ([] return r0, r1 } +// GetCustomFields provides a mock function with given fields: ctx, id +func (_m *StudioReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) { + ret := _m.Called(ctx, id) + + var r0 map[string]interface{} + if rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]interface{}) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetCustomFieldsBulk provides a mock function with given fields: ctx, ids +func (_m *StudioReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) { + ret := _m.Called(ctx, ids) + + var r0 []models.CustomFieldMap + if rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok { + r0 = rf(ctx, ids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.CustomFieldMap) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { + r1 = rf(ctx, ids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetImage provides a mock function with given fields: ctx, studioID func (_m *StudioReaderWriter) GetImage(ctx context.Context, studioID int) ([]byte, error) { ret := _m.Called(ctx, studioID) @@ -479,11 +525,11 @@ func (_m *StudioReaderWriter) QueryForAutoTag(ctx context.Context, words []strin } // Update provides a mock function with given fields: ctx, updatedStudio -func (_m *StudioReaderWriter) Update(ctx context.Context, updatedStudio *models.Studio) error { +func (_m *StudioReaderWriter) Update(ctx context.Context, updatedStudio *models.UpdateStudioInput) error { ret := _m.Called(ctx, updatedStudio) var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *models.Studio) error); ok { + if rf, ok := ret.Get(0).(func(context.Context, *models.UpdateStudioInput) error); ok { r0 = rf(ctx, updatedStudio) } else { r0 = ret.Error(0) diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 4254a9876..bd6db10c8 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -27,9 +27,9 @@ type ScrapedStudio struct { func (ScrapedStudio) IsScrapedContent() {} -func (s *ScrapedStudio) ToStudio(endpoint string, excluded map[string]bool) *Studio { +func (s *ScrapedStudio) ToStudio(endpoint string, excluded map[string]bool) *CreateStudioInput { // Populate a new studio from the input - ret := NewStudio() + ret := NewCreateStudioInput() ret.Name = strings.TrimSpace(s.Name) if s.RemoteSiteID != nil && endpoint != "" && *s.RemoteSiteID != "" { diff --git a/pkg/models/model_scraped_item_test.go b/pkg/models/model_scraped_item_test.go index b6b44025f..545543652 100644 --- a/pkg/models/model_scraped_item_test.go +++ b/pkg/models/model_scraped_item_test.go @@ -113,7 +113,7 @@ func Test_scrapedToStudioInput(t *testing.T) { got.StashIDs.List()[stid].UpdatedAt = time.Time{} } } - assert.Equal(t, tt.want, got) + assert.Equal(t, tt.want, got.Studio) }) } } diff --git a/pkg/models/model_studio.go b/pkg/models/model_studio.go index 8c7a687af..ee6fae2d2 100644 --- a/pkg/models/model_studio.go +++ b/pkg/models/model_studio.go @@ -23,6 +23,18 @@ type Studio struct { StashIDs RelatedStashIDs `json:"stash_ids"` } +type CreateStudioInput struct { + *Studio + + CustomFields map[string]interface{} `json:"custom_fields"` +} + +type UpdateStudioInput struct { + *Studio + + CustomFields CustomFieldsInput `json:"custom_fields"` +} + func NewStudio() Studio { currentTime := time.Now() return Studio{ @@ -31,6 +43,13 @@ func NewStudio() Studio { } } +func NewCreateStudioInput() CreateStudioInput { + s := NewStudio() + return CreateStudioInput{ + Studio: &s, + } +} + // StudioPartial represents part of a Studio object. It is used to update the database entry. type StudioPartial struct { ID int @@ -48,6 +67,8 @@ type StudioPartial struct { URLs *UpdateStrings TagIDs *UpdateIDs StashIDs *UpdateStashIDs + + CustomFields CustomFieldsInput } func NewStudioPartial() StudioPartial { diff --git a/pkg/models/repository_studio.go b/pkg/models/repository_studio.go index 99f98bffc..54fb6ed47 100644 --- a/pkg/models/repository_studio.go +++ b/pkg/models/repository_studio.go @@ -42,12 +42,12 @@ type StudioCounter interface { // StudioCreator provides methods to create studios. type StudioCreator interface { - Create(ctx context.Context, newStudio *Studio) error + Create(ctx context.Context, newStudio *CreateStudioInput) error } // StudioUpdater provides methods to update studios. type StudioUpdater interface { - Update(ctx context.Context, updatedStudio *Studio) error + Update(ctx context.Context, updatedStudio *UpdateStudioInput) error UpdatePartial(ctx context.Context, updatedStudio StudioPartial) (*Studio, error) UpdateImage(ctx context.Context, studioID int, image []byte) error } @@ -79,6 +79,8 @@ type StudioReader interface { TagIDLoader URLLoader + CustomFieldsReader + All(ctx context.Context) ([]*Studio, error) GetImage(ctx context.Context, studioID int) ([]byte, error) HasImage(ctx context.Context, studioID int) (bool, error) diff --git a/pkg/models/studio.go b/pkg/models/studio.go index fd306b16c..be5d54445 100644 --- a/pkg/models/studio.go +++ b/pkg/models/studio.go @@ -46,6 +46,9 @@ type StudioFilterType struct { CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at UpdatedAt *TimestampCriterionInput `json:"updated_at"` + + // Filter by custom fields + CustomFields []CustomFieldCriterionInput `json:"custom_fields"` } type StudioCreateInput struct { @@ -62,6 +65,8 @@ type StudioCreateInput struct { Aliases []string `json:"aliases"` TagIds []string `json:"tag_ids"` IgnoreAutoTag *bool `json:"ignore_auto_tag"` + + CustomFields map[string]interface{} `json:"custom_fields"` } type StudioUpdateInput struct { @@ -79,4 +84,6 @@ type StudioUpdateInput struct { Aliases []string `json:"aliases"` TagIds []string `json:"tag_ids"` IgnoreAutoTag *bool `json:"ignore_auto_tag"` + + CustomFields CustomFieldsInput `json:"custom_fields"` } diff --git a/pkg/scene/import.go b/pkg/scene/import.go index efffd380d..b3f0f1ff1 100644 --- a/pkg/scene/import.go +++ b/pkg/scene/import.go @@ -213,7 +213,7 @@ func (i *Importer) populateStudio(ctx context.Context) error { } func (i *Importer) createStudio(ctx context.Context, name string) (int, error) { - newStudio := models.NewStudio() + newStudio := models.NewCreateStudioInput() newStudio.Name = name err := i.StudioWriter.Create(ctx, &newStudio) diff --git a/pkg/scene/import_test.go b/pkg/scene/import_test.go index a6e3edcdf..558b72ba2 100644 --- a/pkg/scene/import_test.go +++ b/pkg/scene/import_test.go @@ -241,9 +241,9 @@ func TestImporterPreImportWithMissingStudio(t *testing.T) { } db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Times(3) - db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.Studio")).Run(func(args mock.Arguments) { - s := args.Get(1).(*models.Studio) - s.ID = existingStudioID + db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Run(func(args mock.Arguments) { + s := args.Get(1).(*models.CreateStudioInput) + s.Studio.ID = existingStudioID }).Return(nil) err := i.PreImport(testCtx) @@ -273,7 +273,7 @@ func TestImporterPreImportWithMissingStudioCreateErr(t *testing.T) { } db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Once() - db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.Studio")).Return(errors.New("Create error")) + db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 0ea3d7170..a87f6706f 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 75 +var appSchemaVersion uint = 76 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/76_studio_custom_fields.up.sql b/pkg/sqlite/migrations/76_studio_custom_fields.up.sql new file mode 100644 index 000000000..81a72d4d4 --- /dev/null +++ b/pkg/sqlite/migrations/76_studio_custom_fields.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE `studio_custom_fields` ( + `studio_id` integer NOT NULL, + `field` varchar(64) NOT NULL, + `value` BLOB NOT NULL, + PRIMARY KEY (`studio_id`, `field`), + foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE +); + +CREATE INDEX `index_studio_custom_fields_field_value` ON `studio_custom_fields` (`field`, `value`); diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 843b8b4c2..361b5cb79 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1765,7 +1765,19 @@ func getStudioNullStringValue(index int, field string) string { return ret.String } -func createStudio(ctx context.Context, sqb *sqlite.StudioStore, name string, parentID *int) (*models.Studio, error) { +func getStudioCustomFields(index int) map[string]interface{} { + if index%5 == 0 { + return nil + } + + return map[string]interface{}{ + "string": getStudioStringValue(index, "custom"), + "int": int64(index % 5), + "real": float64(index) / 10, + } +} + +func createStudio(ctx context.Context, sqb *sqlite.StudioStore, name string, parentID *int, customFields map[string]interface{}) (*models.Studio, error) { studio := models.Studio{ Name: name, } @@ -1774,7 +1786,7 @@ func createStudio(ctx context.Context, sqb *sqlite.StudioStore, name string, par studio.ParentID = parentID } - err := createStudioFromModel(ctx, sqb, &studio) + err := createStudioFromModel(ctx, sqb, &studio, customFields) if err != nil { return nil, err } @@ -1782,8 +1794,11 @@ func createStudio(ctx context.Context, sqb *sqlite.StudioStore, name string, par return &studio, nil } -func createStudioFromModel(ctx context.Context, sqb *sqlite.StudioStore, studio *models.Studio) error { - err := sqb.Create(ctx, studio) +func createStudioFromModel(ctx context.Context, sqb *sqlite.StudioStore, studio *models.Studio, customFields map[string]interface{}) error { + err := sqb.Create(ctx, &models.CreateStudioInput{ + Studio: studio, + CustomFields: customFields, + }) if err != nil { return fmt.Errorf("Error creating studio %v+: %s", studio, err.Error()) @@ -1845,7 +1860,7 @@ func createStudios(ctx context.Context, n int, o int) error { alias := getStudioStringValue(i, "Alias") studio.Aliases = models.NewRelatedStrings([]string{alias}) } - err := createStudioFromModel(ctx, sqb, &studio) + err := createStudioFromModel(ctx, sqb, &studio, getStudioCustomFields(i)) if err != nil { return err diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index e328818da..d0c5c220c 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -141,6 +141,7 @@ var ( type StudioStore struct { blobJoinQueryBuilder + customFieldsStore tagRelationshipStore tableMgr *table @@ -152,6 +153,10 @@ func NewStudioStore(blobStore *BlobStore) *StudioStore { blobStore: blobStore, joinTable: studioTable, }, + customFieldsStore: customFieldsStore{ + table: studiosCustomFieldsTable, + fk: studiosCustomFieldsTable.Col(studioIDColumn), + }, tagRelationshipStore: tagRelationshipStore{ idRelationshipStore: idRelationshipStore{ joinTable: studiosTagsTableMgr, @@ -170,11 +175,11 @@ func (qb *StudioStore) selectDataset() *goqu.SelectDataset { return dialect.From(qb.table()).Select(qb.table().All()) } -func (qb *StudioStore) Create(ctx context.Context, newObject *models.Studio) error { +func (qb *StudioStore) Create(ctx context.Context, newObject *models.CreateStudioInput) error { var err error var r studioRow - r.fromStudio(*newObject) + r.fromStudio(*newObject.Studio) id, err := qb.tableMgr.insertID(ctx, r) if err != nil { @@ -208,12 +213,17 @@ func (qb *StudioStore) Create(ctx context.Context, newObject *models.Studio) err } } + const partial = false + if err := qb.setCustomFields(ctx, id, newObject.CustomFields, partial); err != nil { + return err + } + updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) } - *newObject = *updated + *newObject.Studio = *updated return nil } @@ -254,13 +264,17 @@ func (qb *StudioStore) UpdatePartial(ctx context.Context, input models.StudioPar } } - return qb.Find(ctx, input.ID) + if err := qb.SetCustomFields(ctx, input.ID, input.CustomFields); err != nil { + return nil, err + } + + return qb.find(ctx, input.ID) } // This is only used by the Import/Export functionality -func (qb *StudioStore) Update(ctx context.Context, updatedObject *models.Studio) error { +func (qb *StudioStore) Update(ctx context.Context, updatedObject *models.UpdateStudioInput) error { var r studioRow - r.fromStudio(*updatedObject) + r.fromStudio(*updatedObject.Studio) if err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { return err @@ -288,6 +302,10 @@ func (qb *StudioStore) Update(ctx context.Context, updatedObject *models.Studio) } } + if err := qb.SetCustomFields(ctx, updatedObject.ID, updatedObject.CustomFields); err != nil { + return err + } + return nil } diff --git a/pkg/sqlite/studio_filter.go b/pkg/sqlite/studio_filter.go index 83a917701..cd7fc4440 100644 --- a/pkg/sqlite/studio_filter.go +++ b/pkg/sqlite/studio_filter.go @@ -117,6 +117,13 @@ func (qb *studioFilterHandler) criterionHandler() criterionHandler { studioRepository.galleries.innerJoin(f, "", "studios.id") }, }, + + &customFieldsFilterHandler{ + table: studiosCustomFieldsTable.GetTable(), + fkCol: studioIDColumn, + c: studioFilter.CustomFields, + idCol: "studios.id", + }, } } diff --git a/pkg/sqlite/studio_test.go b/pkg/sqlite/studio_test.go index 003877c77..074c77d6f 100644 --- a/pkg/sqlite/studio_test.go +++ b/pkg/sqlite/studio_test.go @@ -11,6 +11,7 @@ import ( "strconv" "strings" "testing" + "time" "github.com/stashapp/stash/pkg/models" "github.com/stretchr/testify/assert" @@ -47,6 +48,559 @@ func TestStudioFindByName(t *testing.T) { }) } +func loadStudioRelationships(ctx context.Context, expected models.Studio, actual *models.Studio) error { + if expected.Aliases.Loaded() { + if err := actual.LoadAliases(ctx, db.Studio); err != nil { + return err + } + } + if expected.URLs.Loaded() { + if err := actual.LoadURLs(ctx, db.Studio); err != nil { + return err + } + } + if expected.TagIDs.Loaded() { + if err := actual.LoadTagIDs(ctx, db.Studio); err != nil { + return err + } + } + if expected.StashIDs.Loaded() { + if err := actual.LoadStashIDs(ctx, db.Studio); err != nil { + return err + } + } + + return nil +} + +func Test_StudioStore_Create(t *testing.T) { + var ( + name = "name" + details = "details" + url = "url" + rating = 3 + aliases = []string{"alias1", "alias2"} + ignoreAutoTag = true + favorite = true + endpoint1 = "endpoint1" + endpoint2 = "endpoint2" + stashID1 = "stashid1" + stashID2 = "stashid2" + createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + ) + + tests := []struct { + name string + newObject models.CreateStudioInput + wantErr bool + }{ + { + "full", + models.CreateStudioInput{ + Studio: &models.Studio{ + Name: name, + URLs: models.NewRelatedStrings([]string{url}), + Favorite: favorite, + Rating: &rating, + Details: details, + IgnoreAutoTag: ignoreAutoTag, + TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithStudio], tagIDs[tagIdx1WithDupName]}), + Aliases: models.NewRelatedStrings(aliases), + StashIDs: models.NewRelatedStashIDs([]models.StashID{ + { + StashID: stashID1, + Endpoint: endpoint1, + UpdatedAt: epochTime, + }, + { + StashID: stashID2, + Endpoint: endpoint2, + UpdatedAt: epochTime, + }, + }), + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, + CustomFields: testCustomFields, + }, + false, + }, + { + "invalid tag id", + models.CreateStudioInput{ + Studio: &models.Studio{ + Name: name, + TagIDs: models.NewRelatedIDs([]int{invalidID}), + }, + }, + true, + }, + } + + qb := db.Studio + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + p := tt.newObject + if err := qb.Create(ctx, &p); (err != nil) != tt.wantErr { + t.Errorf("StudioStore.Create() error = %v, wantErr = %v", err, tt.wantErr) + } + + if tt.wantErr { + assert.Zero(p.ID) + return + } + + assert.NotZero(p.ID) + + copy := *tt.newObject.Studio + copy.ID = p.ID + + // load relationships + if err := loadStudioRelationships(ctx, copy, p.Studio); err != nil { + t.Errorf("loadStudioRelationships() error = %v", err) + return + } + + assert.Equal(copy, *p.Studio) + + // ensure can find the Studio + found, err := qb.Find(ctx, p.ID) + if err != nil { + t.Errorf("StudioStore.Find() error = %v", err) + } + + if !assert.NotNil(found) { + return + } + + // load relationships + if err := loadStudioRelationships(ctx, copy, found); err != nil { + t.Errorf("loadStudioRelationships() error = %v", err) + return + } + assert.Equal(copy, *found) + + // ensure custom fields are set + cf, err := qb.GetCustomFields(ctx, p.ID) + if err != nil { + t.Errorf("StudioStore.GetCustomFields() error = %v", err) + return + } + + assert.Equal(tt.newObject.CustomFields, cf) + + return + }) + } +} + +func Test_StudioStore_Update(t *testing.T) { + var ( + name = "name" + details = "details" + url = "url" + rating = 3 + aliases = []string{"aliasX", "aliasY"} + ignoreAutoTag = true + favorite = true + endpoint1 = "endpoint1" + endpoint2 = "endpoint2" + stashID1 = "stashid1" + stashID2 = "stashid2" + createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + ) + + tests := []struct { + name string + updatedObject models.UpdateStudioInput + wantErr bool + }{ + { + "full", + models.UpdateStudioInput{ + Studio: &models.Studio{ + ID: studioIDs[studioIdxWithGallery], + Name: name, + URLs: models.NewRelatedStrings([]string{url}), + Favorite: favorite, + Rating: &rating, + Details: details, + IgnoreAutoTag: ignoreAutoTag, + Aliases: models.NewRelatedStrings(aliases), + TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithStudio]}), + StashIDs: models.NewRelatedStashIDs([]models.StashID{ + { + StashID: stashID1, + Endpoint: endpoint1, + UpdatedAt: epochTime, + }, + { + StashID: stashID2, + Endpoint: endpoint2, + UpdatedAt: epochTime, + }, + }), + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, + }, + false, + }, + { + "clear nullables", + models.UpdateStudioInput{ + Studio: &models.Studio{ + ID: studioIDs[studioIdxWithGallery], + Name: name, // name is mandatory + URLs: models.NewRelatedStrings([]string{}), + Aliases: models.NewRelatedStrings([]string{}), + TagIDs: models.NewRelatedIDs([]int{}), + StashIDs: models.NewRelatedStashIDs([]models.StashID{}), + }, + }, + false, + }, + { + "clear tag ids", + models.UpdateStudioInput{ + Studio: &models.Studio{ + ID: studioIDs[sceneIdxWithTag], + Name: name, // name is mandatory + TagIDs: models.NewRelatedIDs([]int{}), + }, + }, + false, + }, + { + "set custom fields", + models.UpdateStudioInput{ + Studio: &models.Studio{ + ID: studioIDs[studioIdxWithGallery], + Name: name, // name is mandatory + }, + CustomFields: models.CustomFieldsInput{ + Full: testCustomFields, + }, + }, + false, + }, + { + "clear custom fields", + models.UpdateStudioInput{ + Studio: &models.Studio{ + ID: studioIDs[studioIdxWithGallery], + Name: name, // name is mandatory + }, + CustomFields: models.CustomFieldsInput{ + Full: map[string]interface{}{}, + }, + }, + false, + }, + { + "invalid tag id", + models.UpdateStudioInput{ + Studio: &models.Studio{ + ID: studioIDs[sceneIdxWithGallery], + Name: name, // name is mandatory + TagIDs: models.NewRelatedIDs([]int{invalidID}), + }, + }, + true, + }, + } + + qb := db.Studio + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + copy := *tt.updatedObject.Studio + + if err := qb.Update(ctx, &tt.updatedObject); (err != nil) != tt.wantErr { + t.Errorf("StudioStore.Update() error = %v, wantErr %v", err, tt.wantErr) + } + + if tt.wantErr { + return + } + + s, err := qb.Find(ctx, tt.updatedObject.ID) + if err != nil { + t.Errorf("StudioStore.Find() error = %v", err) + } + + // load relationships + if err := loadStudioRelationships(ctx, copy, s); err != nil { + t.Errorf("loadStudioRelationships() error = %v", err) + return + } + + assert.Equal(copy, *s) + + // ensure custom fields are correct + if tt.updatedObject.CustomFields.Full != nil { + cf, err := qb.GetCustomFields(ctx, tt.updatedObject.ID) + if err != nil { + t.Errorf("StudioStore.GetCustomFields() error = %v", err) + return + } + + assert.Equal(tt.updatedObject.CustomFields.Full, cf) + } + }) + } +} + +func clearStudioPartial() models.StudioPartial { + nullString := models.OptionalString{Set: true, Null: true} + nullInt := models.OptionalInt{Set: true, Null: true} + + // leave mandatory fields + return models.StudioPartial{ + URLs: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet}, + Aliases: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet}, + Rating: nullInt, + Details: nullString, + TagIDs: &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet}, + StashIDs: &models.UpdateStashIDs{Mode: models.RelationshipUpdateModeSet}, + } +} + +func Test_StudioStore_UpdatePartial(t *testing.T) { + var ( + name = "name" + details = "details" + url = "url" + aliases = []string{"aliasX", "aliasY"} + rating = 3 + ignoreAutoTag = true + favorite = true + endpoint1 = "endpoint1" + endpoint2 = "endpoint2" + stashID1 = "stashid1" + stashID2 = "stashid2" + createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + ) + + tests := []struct { + name string + id int + partial models.StudioPartial + want models.Studio + wantErr bool + }{ + { + "full", + studioIDs[studioIdxWithDupName], + models.StudioPartial{ + Name: models.NewOptionalString(name), + URLs: &models.UpdateStrings{ + Values: []string{url}, + Mode: models.RelationshipUpdateModeSet, + }, + Aliases: &models.UpdateStrings{ + Values: aliases, + Mode: models.RelationshipUpdateModeSet, + }, + Favorite: models.NewOptionalBool(favorite), + Rating: models.NewOptionalInt(rating), + Details: models.NewOptionalString(details), + IgnoreAutoTag: models.NewOptionalBool(ignoreAutoTag), + TagIDs: &models.UpdateIDs{ + IDs: []int{tagIDs[tagIdx1WithStudio], tagIDs[tagIdx1WithDupName]}, + Mode: models.RelationshipUpdateModeSet, + }, + StashIDs: &models.UpdateStashIDs{ + StashIDs: []models.StashID{ + { + StashID: stashID1, + Endpoint: endpoint1, + UpdatedAt: epochTime, + }, + { + StashID: stashID2, + Endpoint: endpoint2, + UpdatedAt: epochTime, + }, + }, + Mode: models.RelationshipUpdateModeSet, + }, + CreatedAt: models.NewOptionalTime(createdAt), + UpdatedAt: models.NewOptionalTime(updatedAt), + }, + models.Studio{ + ID: studioIDs[studioIdxWithDupName], + Name: name, + URLs: models.NewRelatedStrings([]string{url}), + Aliases: models.NewRelatedStrings(aliases), + Favorite: favorite, + Rating: &rating, + Details: details, + IgnoreAutoTag: ignoreAutoTag, + TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithStudio]}), + StashIDs: models.NewRelatedStashIDs([]models.StashID{ + { + StashID: stashID1, + Endpoint: endpoint1, + UpdatedAt: epochTime, + }, + { + StashID: stashID2, + Endpoint: endpoint2, + UpdatedAt: epochTime, + }, + }), + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, + false, + }, + { + "clear all", + studioIDs[studioIdxWithTwoTags], + clearStudioPartial(), + models.Studio{ + ID: studioIDs[studioIdxWithTwoTags], + Name: getStudioStringValue(studioIdxWithTwoTags, "Name"), + Favorite: getStudioBoolValue(studioIdxWithTwoTags), + Aliases: models.NewRelatedStrings([]string{}), + TagIDs: models.NewRelatedIDs([]int{}), + StashIDs: models.NewRelatedStashIDs([]models.StashID{}), + IgnoreAutoTag: getIgnoreAutoTag(studioIdxWithTwoTags), + }, + false, + }, + { + "invalid id", + invalidID, + models.StudioPartial{Name: models.NewOptionalString(name)}, + models.Studio{}, + true, + }, + } + for _, tt := range tests { + qb := db.Studio + + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + tt.partial.ID = tt.id + + got, err := qb.UpdatePartial(ctx, tt.partial) + if (err != nil) != tt.wantErr { + t.Errorf("StudioStore.UpdatePartial() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr { + return + } + + if err := loadStudioRelationships(ctx, tt.want, got); err != nil { + t.Errorf("loadStudioRelationships() error = %v", err) + return + } + + assert.Equal(tt.want, *got) + + s, err := qb.Find(ctx, tt.id) + if err != nil { + t.Errorf("StudioStore.Find() error = %v", err) + } + + // load relationships + if err := loadStudioRelationships(ctx, tt.want, s); err != nil { + t.Errorf("loadStudioRelationships() error = %v", err) + return + } + + assert.Equal(tt.want, *s) + }) + } +} + +func Test_StudioStore_UpdatePartialCustomFields(t *testing.T) { + tests := []struct { + name string + id int + partial models.StudioPartial + expected map[string]interface{} // nil to use the partial + }{ + { + "set custom fields", + studioIDs[studioIdxWithGallery], + models.StudioPartial{ + CustomFields: models.CustomFieldsInput{ + Full: testCustomFields, + }, + }, + nil, + }, + { + "clear custom fields", + studioIDs[studioIdxWithGallery], + models.StudioPartial{ + CustomFields: models.CustomFieldsInput{ + Full: map[string]interface{}{}, + }, + }, + nil, + }, + { + "partial custom fields", + studioIDs[studioIdxWithGallery], + models.StudioPartial{ + CustomFields: models.CustomFieldsInput{ + Partial: map[string]interface{}{ + "string": "bbb", + "new_field": "new", + }, + }, + }, + map[string]interface{}{ + "int": int64(2), + "real": 0.7, + "string": "bbb", + "new_field": "new", + }, + }, + } + for _, tt := range tests { + qb := db.Studio + + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + tt.partial.ID = tt.id + + _, err := qb.UpdatePartial(ctx, tt.partial) + if err != nil { + t.Errorf("StudioStore.UpdatePartial() error = %v", err) + return + } + + // ensure custom fields are correct + cf, err := qb.GetCustomFields(ctx, tt.id) + if err != nil { + t.Errorf("StudioStore.GetCustomFields() error = %v", err) + return + } + if tt.expected == nil { + assert.Equal(tt.partial.CustomFields.Full, cf) + } else { + assert.Equal(tt.expected, cf) + } + }) + } +} + func TestStudioQueryNameOr(t *testing.T) { const studio1Idx = 1 const studio2Idx = 2 @@ -82,14 +636,6 @@ func TestStudioQueryNameOr(t *testing.T) { }) } -func loadStudioRelationships(ctx context.Context, t *testing.T, s *models.Studio) error { - if err := s.LoadURLs(ctx, db.Studio); err != nil { - return err - } - - return nil -} - func TestStudioQueryNameAndUrl(t *testing.T) { const studioIdx = 1 studioName := getStudioStringValue(studioIdx, "Name") @@ -311,13 +857,13 @@ func TestStudioDestroyParent(t *testing.T) { // create parent and child studios if err := withTxn(func(ctx context.Context) error { - createdParent, err := createStudio(ctx, db.Studio, parentName, nil) + createdParent, err := createStudio(ctx, db.Studio, parentName, nil, nil) if err != nil { return fmt.Errorf("Error creating parent studio: %s", err.Error()) } parentID := createdParent.ID - createdChild, err := createStudio(ctx, db.Studio, childName, &parentID) + createdChild, err := createStudio(ctx, db.Studio, childName, &parentID, nil) if err != nil { return fmt.Errorf("Error creating child studio: %s", err.Error()) } @@ -373,13 +919,13 @@ func TestStudioUpdateClearParent(t *testing.T) { // create parent and child studios if err := withTxn(func(ctx context.Context) error { - createdParent, err := createStudio(ctx, db.Studio, parentName, nil) + createdParent, err := createStudio(ctx, db.Studio, parentName, nil, nil) if err != nil { return fmt.Errorf("Error creating parent studio: %s", err.Error()) } parentID := createdParent.ID - createdChild, err := createStudio(ctx, db.Studio, childName, &parentID) + createdChild, err := createStudio(ctx, db.Studio, childName, &parentID, nil) if err != nil { return fmt.Errorf("Error creating child studio: %s", err.Error()) } @@ -414,7 +960,7 @@ func TestStudioUpdateStudioImage(t *testing.T) { // create studio to test against const name = "TestStudioUpdateStudioImage" - created, err := createStudio(ctx, db.Studio, name, nil) + created, err := createStudio(ctx, db.Studio, name, nil, nil) if err != nil { return fmt.Errorf("Error creating studio: %s", err.Error()) } @@ -578,7 +1124,7 @@ func TestStudioStashIDs(t *testing.T) { // create studio to test against const name = "TestStudioStashIDs" - created, err := createStudio(ctx, db.Studio, name, nil) + created, err := createStudio(ctx, db.Studio, name, nil, nil) if err != nil { return fmt.Errorf("Error creating studio: %s", err.Error()) } @@ -990,7 +1536,7 @@ func TestStudioAlias(t *testing.T) { // create studio to test against const name = "TestStudioAlias" - created, err := createStudio(ctx, db.Studio, name, nil) + created, err := createStudio(ctx, db.Studio, name, nil, nil) if err != nil { return fmt.Errorf("Error creating studio: %s", err.Error()) } diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 7cddf25cc..bfc5199fe 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -40,6 +40,7 @@ var ( studiosURLsJoinTable = goqu.T(studioURLsTable) studiosTagsJoinTable = goqu.T(studiosTagsTable) studiosStashIDsJoinTable = goqu.T("studio_stash_ids") + studiosCustomFieldsTable = goqu.T("studio_custom_fields") groupsURLsJoinTable = goqu.T(groupURLsTable) groupsTagsJoinTable = goqu.T(groupsTagsTable) diff --git a/pkg/studio/import.go b/pkg/studio/import.go index 405852e53..d5284ce02 100644 --- a/pkg/studio/import.go +++ b/pkg/studio/import.go @@ -153,7 +153,7 @@ func (i *Importer) populateParentStudio(ctx context.Context) error { } func (i *Importer) createParentStudio(ctx context.Context, name string) (int, error) { - newStudio := models.NewStudio() + newStudio := models.NewCreateStudioInput() newStudio.Name = name err := i.ReaderWriter.Create(ctx, &newStudio) @@ -194,7 +194,7 @@ func (i *Importer) FindExistingID(ctx context.Context) (*int, error) { } func (i *Importer) Create(ctx context.Context) (*int, error) { - err := i.ReaderWriter.Create(ctx, &i.studio) + err := i.ReaderWriter.Create(ctx, &models.CreateStudioInput{Studio: &i.studio}) if err != nil { return nil, fmt.Errorf("error creating studio: %v", err) } @@ -206,7 +206,7 @@ func (i *Importer) Create(ctx context.Context) (*int, error) { func (i *Importer) Update(ctx context.Context, id int) error { studio := i.studio studio.ID = id - err := i.ReaderWriter.Update(ctx, &studio) + err := i.ReaderWriter.Update(ctx, &models.UpdateStudioInput{Studio: &studio}) if err != nil { return fmt.Errorf("error updating existing studio: %v", err) } diff --git a/pkg/studio/import_test.go b/pkg/studio/import_test.go index 882b8ca56..6648ebe0d 100644 --- a/pkg/studio/import_test.go +++ b/pkg/studio/import_test.go @@ -206,9 +206,9 @@ func TestImporterPreImportWithMissingParent(t *testing.T) { } db.Studio.On("FindByName", testCtx, missingParentStudioName, false).Return(nil, nil).Times(3) - db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.Studio")).Run(func(args mock.Arguments) { - s := args.Get(1).(*models.Studio) - s.ID = existingStudioID + db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Run(func(args mock.Arguments) { + s := args.Get(1).(*models.CreateStudioInput) + s.Studio.ID = existingStudioID }).Return(nil) err := i.PreImport(testCtx) @@ -240,7 +240,7 @@ func TestImporterPreImportWithMissingParentCreateErr(t *testing.T) { } db.Studio.On("FindByName", testCtx, missingParentStudioName, false).Return(nil, nil).Once() - db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.Studio")).Return(errors.New("Create error")) + db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) @@ -327,11 +327,11 @@ func TestCreate(t *testing.T) { } errCreate := errors.New("Create error") - db.Studio.On("Create", testCtx, &studio).Run(func(args mock.Arguments) { - s := args.Get(1).(*models.Studio) + db.Studio.On("Create", testCtx, &models.CreateStudioInput{Studio: &studio}).Run(func(args mock.Arguments) { + s := args.Get(1).(*models.CreateStudioInput) s.ID = studioID }).Return(nil).Once() - db.Studio.On("Create", testCtx, &studioErr).Return(errCreate).Once() + db.Studio.On("Create", testCtx, &models.CreateStudioInput{Studio: &studioErr}).Return(errCreate).Once() id, err := i.Create(testCtx) assert.Equal(t, studioID, *id) @@ -366,7 +366,7 @@ func TestUpdate(t *testing.T) { // id needs to be set for the mock input studio.ID = studioID - db.Studio.On("Update", testCtx, &studio).Return(nil).Once() + db.Studio.On("Update", testCtx, &models.UpdateStudioInput{Studio: &studio}).Return(nil).Once() err := i.Update(testCtx, studioID) assert.Nil(t, err) @@ -375,7 +375,7 @@ func TestUpdate(t *testing.T) { // need to set id separately studioErr.ID = errImageID - db.Studio.On("Update", testCtx, &studioErr).Return(errUpdate).Once() + db.Studio.On("Update", testCtx, &models.UpdateStudioInput{Studio: &studioErr}).Return(errUpdate).Once() err = i.Update(testCtx, errImageID) assert.NotNil(t, err) diff --git a/pkg/studio/validate.go b/pkg/studio/validate.go index 1654a2e78..526400066 100644 --- a/pkg/studio/validate.go +++ b/pkg/studio/validate.go @@ -75,7 +75,7 @@ func ValidateAliases(ctx context.Context, id int, aliases []string, qb models.St return nil } -func ValidateCreate(ctx context.Context, studio models.Studio, qb models.StudioQueryer) error { +func ValidateCreate(ctx context.Context, studio models.CreateStudioInput, qb models.StudioQueryer) error { if err := validateName(ctx, 0, studio.Name, qb); err != nil { return err } From f629191b282aae08e315e47f5f7432b2333176e2 Mon Sep 17 00:00:00 2001 From: CJ <72030708+cj12312021@users.noreply.github.com> Date: Wed, 4 Feb 2026 20:35:58 -0600 Subject: [PATCH 051/177] Future support for filtering tags list by current filter on Performers page (#6091) --- graphql/schema/types/filters.graphql | 2 ++ pkg/models/tag.go | 2 ++ pkg/sqlite/tag.go | 15 ++++++++++++--- pkg/sqlite/tag_filter.go | 9 +++++++++ .../List/Filters/LabeledIdFilter.tsx | 19 ++++++++++++++++--- 5 files changed, 41 insertions(+), 6 deletions(-) diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index d0d6a4b65..52eec6785 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -642,6 +642,8 @@ input TagFilterType { images_filter: ImageFilterType "Filter by related galleries that meet this criteria" galleries_filter: GalleryFilterType + "Filter by related performers that meet this criteria" + performers_filter: PerformerFilterType "Filter by creation time" created_at: TimestampCriterionInput diff --git a/pkg/models/tag.go b/pkg/models/tag.go index 69d4f9e3c..5ff2df6ad 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -50,6 +50,8 @@ type TagFilterType struct { ImagesFilter *ImageFilterType `json:"images_filter"` // Filter by related galleries that meet this criteria GalleriesFilter *GalleryFilterType `json:"galleries_filter"` + // Filter by related performers that meet this criteria + PerformersFilter *PerformerFilterType `json:"performers_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index dd730c62c..b1d773290 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -104,9 +104,10 @@ type tagRepositoryType struct { aliases stringRepository stashIDs stashIDRepository - scenes joinRepository - images joinRepository - galleries joinRepository + scenes joinRepository + images joinRepository + galleries joinRepository + performers joinRepository } var ( @@ -152,6 +153,14 @@ var ( fkColumn: galleryIDColumn, foreignTable: galleryTable, }, + performers: joinRepository{ + repository: repository{ + tableName: performersTagsTable, + idColumn: tagIDColumn, + }, + fkColumn: performerIDColumn, + foreignTable: performerTable, + }, } ) diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index 344b7de91..dadc351ee 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -127,6 +127,15 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler { tagRepository.galleries.innerJoin(f, "", "tags.id") }, }, + + &relatedFilterHandler{ + relatedIDCol: "performers_tags.performer_id", + relatedRepo: performerRepository.repository, + relatedHandler: &performerFilterHandler{tagFilter.PerformersFilter}, + joinFn: func(f *filterBuilder) { + tagRepository.performers.innerJoin(f, "", "tags.id") + }, + }, } } diff --git a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx index 2e63cd465..4012ff628 100644 --- a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx @@ -21,6 +21,7 @@ import { GalleryFilterType, InputMaybe, IntCriterionInput, + PerformerFilterType, SceneFilterType, } from "src/core/generated-graphql"; import { useIntl } from "react-intl"; @@ -516,6 +517,8 @@ export function makeQueryVariables(query: string, extraProps: {}) { interface IFilterType { scenes_filter?: InputMaybe; scene_count?: InputMaybe; + performers_filter?: InputMaybe; + performer_count?: InputMaybe; galleries_filter?: InputMaybe; gallery_count?: InputMaybe; } @@ -523,7 +526,7 @@ interface IFilterType { export function setObjectFilter( out: IFilterType, mode: FilterMode, - relatedFilterOutput: SceneFilterType | GalleryFilterType + relatedFilterOutput: SceneFilterType | PerformerFilterType | GalleryFilterType ) { const empty = Object.keys(relatedFilterOutput).length === 0; @@ -536,7 +539,17 @@ export function setObjectFilter( value: 0, }; } - out.scenes_filter = relatedFilterOutput; + out.scenes_filter = relatedFilterOutput as SceneFilterType; + break; + case FilterMode.Performers: + // if empty, only get objects with performers + if (empty) { + out.performer_count = { + modifier: CriterionModifier.GreaterThan, + value: 0, + }; + } + out.performers_filter = relatedFilterOutput as PerformerFilterType; break; case FilterMode.Galleries: // if empty, only get objects with galleries @@ -546,7 +559,7 @@ export function setObjectFilter( value: 0, }; } - out.galleries_filter = relatedFilterOutput; + out.galleries_filter = relatedFilterOutput as GalleryFilterType; break; default: throw new Error("Invalid filter mode"); From b278525647a17b2ff0f3f19b03a2eb68e78fb52d Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 6 Feb 2026 12:35:05 +1100 Subject: [PATCH 052/177] Tag custom fields support for backend (#6546) * Fix custom field import/export for studio * Update studio unit tests * Add tag create and update unit tests * Add custom fields to tag filter graphql * Add unit tests for tag filtering * Add filter unit tests for studio --- graphql/schema/types/filters.graphql | 2 + graphql/schema/types/tag.graphql | 5 + internal/api/loaders/dataloaders.go | 26 +- internal/api/resolver_model_tag.go | 13 + internal/api/resolver_mutation_tag.go | 16 +- internal/autotag/integration_test.go | 2 +- internal/identify/scene.go | 4 +- internal/identify/scene_test.go | 12 +- pkg/gallery/import.go | 4 +- pkg/gallery/import_test.go | 8 +- pkg/group/import.go | 4 +- pkg/group/import_test.go | 8 +- pkg/image/import.go | 4 +- pkg/image/import_test.go | 8 +- pkg/models/custom_fields.go | 4 + pkg/models/jsonschema/studio.go | 2 + pkg/models/jsonschema/tag.go | 23 +- pkg/models/mocks/TagReaderWriter.go | 68 +- pkg/models/model_tag.go | 14 + pkg/models/repository_tag.go | 7 +- pkg/models/tag.go | 3 + pkg/performer/import.go | 4 +- pkg/performer/import_test.go | 8 +- pkg/scene/import.go | 4 +- pkg/scene/import_test.go | 8 +- pkg/sqlite/anonymise.go | 8 + pkg/sqlite/database.go | 2 +- .../migrations/77_tag_custom_fields.up.sql | 9 + pkg/sqlite/setup_test.go | 17 +- pkg/sqlite/studio_test.go | 245 +++++++ pkg/sqlite/tables.go | 1 + pkg/sqlite/tag.go | 28 +- pkg/sqlite/tag_filter.go | 7 + pkg/sqlite/tag_test.go | 650 +++++++++++++++++- pkg/studio/export.go | 7 + pkg/studio/export_test.go | 67 +- pkg/studio/import.go | 24 +- pkg/studio/import_test.go | 11 +- pkg/tag/export.go | 6 + pkg/tag/export_test.go | 83 ++- pkg/tag/import.go | 31 +- pkg/tag/import_test.go | 34 +- 42 files changed, 1356 insertions(+), 135 deletions(-) create mode 100644 pkg/sqlite/migrations/77_tag_custom_fields.up.sql diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 52eec6785..f89cee3e2 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -650,6 +650,8 @@ input TagFilterType { "Filter by last update time" updated_at: TimestampCriterionInput + + custom_fields: [CustomFieldCriterionInput!] } input ImageFilterType { diff --git a/graphql/schema/types/tag.graphql b/graphql/schema/types/tag.graphql index a69b83548..2210c900e 100644 --- a/graphql/schema/types/tag.graphql +++ b/graphql/schema/types/tag.graphql @@ -24,6 +24,7 @@ type Tag { parent_count: Int! # Resolver child_count: Int! # Resolver + custom_fields: Map! } input TagCreateInput { @@ -41,6 +42,8 @@ input TagCreateInput { parent_ids: [ID!] child_ids: [ID!] + + custom_fields: Map } input TagUpdateInput { @@ -59,6 +62,8 @@ input TagUpdateInput { parent_ids: [ID!] child_ids: [ID!] + + custom_fields: CustomFieldsInput } input TagDestroyInput { diff --git a/internal/api/loaders/dataloaders.go b/internal/api/loaders/dataloaders.go index 4676966c9..ecb0bbac2 100644 --- a/internal/api/loaders/dataloaders.go +++ b/internal/api/loaders/dataloaders.go @@ -62,10 +62,11 @@ type Loaders struct { StudioByID *StudioLoader StudioCustomFields *CustomFieldsLoader - TagByID *TagLoader - GroupByID *GroupLoader - FileByID *FileLoader - FolderByID *FolderLoader + TagByID *TagLoader + TagCustomFields *CustomFieldsLoader + GroupByID *GroupLoader + FileByID *FileLoader + FolderByID *FolderLoader } type Middleware struct { @@ -116,6 +117,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler { maxBatch: maxBatch, fetch: m.fetchTags(ctx), }, + TagCustomFields: &CustomFieldsLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchTagCustomFields(ctx), + }, GroupByID: &GroupLoader{ wait: wait, maxBatch: maxBatch, @@ -283,6 +289,18 @@ func (m Middleware) fetchTags(ctx context.Context) func(keys []int) ([]*models.T } } +func (m Middleware) fetchTagCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) { + return func(keys []int) (ret []models.CustomFieldMap, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Tag.GetCustomFieldsBulk(ctx, keys) + return err + }) + + return ret, toErrorSlice(err) + } +} + func (m Middleware) fetchGroups(ctx context.Context) func(keys []int) ([]*models.Group, []error) { return func(keys []int) (ret []*models.Group, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { diff --git a/internal/api/resolver_model_tag.go b/internal/api/resolver_model_tag.go index deae41f21..7518036b0 100644 --- a/internal/api/resolver_model_tag.go +++ b/internal/api/resolver_model_tag.go @@ -181,3 +181,16 @@ func (r *tagResolver) ChildCount(ctx context.Context, obj *models.Tag) (ret int, return ret, nil } + +func (r *tagResolver) CustomFields(ctx context.Context, obj *models.Tag) (map[string]interface{}, error) { + m, err := loaders.From(ctx).TagCustomFields.Load(obj.ID) + if err != nil { + return nil, err + } + + if m == nil { + return make(map[string]interface{}), nil + } + + return m, nil +} diff --git a/internal/api/resolver_mutation_tag.go b/internal/api/resolver_mutation_tag.go index 8fb295d40..31c7980f6 100644 --- a/internal/api/resolver_mutation_tag.go +++ b/internal/api/resolver_mutation_tag.go @@ -31,7 +31,10 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput) } // Populate a new tag from the input - newTag := models.NewTag() + newTag := models.CreateTagInput{ + Tag: &models.Tag{}, + } + *newTag.Tag = models.NewTag() newTag.Name = strings.TrimSpace(input.Name) newTag.SortName = translator.string(input.SortName) @@ -60,6 +63,8 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput) return nil, fmt.Errorf("converting child tag ids: %w", err) } + newTag.CustomFields = convertMapJSONNumbers(input.CustomFields) + // Process the base 64 encoded image string var imageData []byte if input.Image != nil { @@ -73,7 +78,7 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput) if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Tag - if err := tag.ValidateCreate(ctx, newTag, qb); err != nil { + if err := tag.ValidateCreate(ctx, *newTag.Tag, qb); err != nil { return err } @@ -137,6 +142,13 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) return nil, fmt.Errorf("converting child tag ids: %w", err) } + if input.CustomFields != nil { + updatedTag.CustomFields = *input.CustomFields + // convert json.Numbers to int/float + updatedTag.CustomFields.Full = convertMapJSONNumbers(updatedTag.CustomFields.Full) + updatedTag.CustomFields.Partial = convertMapJSONNumbers(updatedTag.CustomFields.Partial) + } + var imageData []byte imageIncluded := translator.hasField("image") if input.Image != nil { diff --git a/internal/autotag/integration_test.go b/internal/autotag/integration_test.go index 605082b98..27cce014e 100644 --- a/internal/autotag/integration_test.go +++ b/internal/autotag/integration_test.go @@ -118,7 +118,7 @@ func createTag(ctx context.Context, qb models.TagWriter) error { Name: testName, } - err := qb.Create(ctx, &tag) + err := qb.Create(ctx, &models.CreateTagInput{Tag: &tag}) if err != nil { return err } diff --git a/internal/identify/scene.go b/internal/identify/scene.go index 789674693..b82a04301 100644 --- a/internal/identify/scene.go +++ b/internal/identify/scene.go @@ -167,7 +167,9 @@ func (g sceneRelationships) tags(ctx context.Context) ([]int, error) { } else if createMissing { newTag := t.ToTag(endpoint, nil) - err := g.tagCreator.Create(ctx, newTag) + err := g.tagCreator.Create(ctx, &models.CreateTagInput{ + Tag: newTag, + }) if err != nil { return nil, fmt.Errorf("error creating tag: %w", err) } diff --git a/internal/identify/scene_test.go b/internal/identify/scene_test.go index 0eec61c4e..862bbbff8 100644 --- a/internal/identify/scene_test.go +++ b/internal/identify/scene_test.go @@ -368,14 +368,14 @@ func Test_sceneRelationships_tags(t *testing.T) { db := mocks.NewDatabase() - db.Tag.On("Create", testCtx, mock.MatchedBy(func(p *models.Tag) bool { - return p.Name == validName + db.Tag.On("Create", testCtx, mock.MatchedBy(func(p *models.CreateTagInput) bool { + return p.Tag.Name == validName })).Run(func(args mock.Arguments) { - t := args.Get(1).(*models.Tag) - t.ID = validStoredIDInt + t := args.Get(1).(*models.CreateTagInput) + t.Tag.ID = validStoredIDInt }).Return(nil) - db.Tag.On("Create", testCtx, mock.MatchedBy(func(p *models.Tag) bool { - return p.Name == invalidName + db.Tag.On("Create", testCtx, mock.MatchedBy(func(p *models.CreateTagInput) bool { + return p.Tag.Name == invalidName })).Return(errors.New("error creating tag")) tr := sceneRelationships{ diff --git a/pkg/gallery/import.go b/pkg/gallery/import.go index 543d4cf48..22f3e6c44 100644 --- a/pkg/gallery/import.go +++ b/pkg/gallery/import.go @@ -249,7 +249,9 @@ func (i *Importer) createTags(ctx context.Context, names []string) ([]*models.Ta newTag := models.NewTag() newTag.Name = name - err := i.TagWriter.Create(ctx, &newTag) + err := i.TagWriter.Create(ctx, &models.CreateTagInput{ + Tag: &newTag, + }) if err != nil { return nil, err } diff --git a/pkg/gallery/import_test.go b/pkg/gallery/import_test.go index 4248f51bc..932f84d48 100644 --- a/pkg/gallery/import_test.go +++ b/pkg/gallery/import_test.go @@ -289,9 +289,9 @@ func TestImporterPreImportWithMissingTag(t *testing.T) { } db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3) - db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Run(func(args mock.Arguments) { - t := args.Get(1).(*models.Tag) - t.ID = existingTagID + db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Run(func(args mock.Arguments) { + t := args.Get(1).(*models.CreateTagInput) + t.Tag.ID = existingTagID }).Return(nil) err := i.PreImport(testCtx) @@ -323,7 +323,7 @@ func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) { } db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Once() - db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Return(errors.New("Create error")) + db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) diff --git a/pkg/group/import.go b/pkg/group/import.go index a73c3998e..d7acad47c 100644 --- a/pkg/group/import.go +++ b/pkg/group/import.go @@ -126,7 +126,9 @@ func createTags(ctx context.Context, tagWriter models.TagFinderCreator, names [] newTag := models.NewTag() newTag.Name = name - err := tagWriter.Create(ctx, &newTag) + err := tagWriter.Create(ctx, &models.CreateTagInput{ + Tag: &newTag, + }) if err != nil { return nil, err } diff --git a/pkg/group/import_test.go b/pkg/group/import_test.go index 50b8b2dd1..387ceb87e 100644 --- a/pkg/group/import_test.go +++ b/pkg/group/import_test.go @@ -212,9 +212,9 @@ func TestImporterPreImportWithMissingTag(t *testing.T) { } db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3) - db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Run(func(args mock.Arguments) { - t := args.Get(1).(*models.Tag) - t.ID = existingTagID + db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Run(func(args mock.Arguments) { + t := args.Get(1).(*models.CreateTagInput) + t.Tag.ID = existingTagID }).Return(nil) err := i.PreImport(testCtx) @@ -247,7 +247,7 @@ func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) { } db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Once() - db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Return(errors.New("Create error")) + db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) diff --git a/pkg/image/import.go b/pkg/image/import.go index 77b6d7477..c7ef7f00c 100644 --- a/pkg/image/import.go +++ b/pkg/image/import.go @@ -407,7 +407,9 @@ func createTags(ctx context.Context, tagWriter models.TagCreator, names []string newTag := models.NewTag() newTag.Name = name - err := tagWriter.Create(ctx, &newTag) + err := tagWriter.Create(ctx, &models.CreateTagInput{ + Tag: &newTag, + }) if err != nil { return nil, err } diff --git a/pkg/image/import_test.go b/pkg/image/import_test.go index 98b3972b9..5d01d4b97 100644 --- a/pkg/image/import_test.go +++ b/pkg/image/import_test.go @@ -251,9 +251,9 @@ func TestImporterPreImportWithMissingTag(t *testing.T) { } db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3) - db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Run(func(args mock.Arguments) { - t := args.Get(1).(*models.Tag) - t.ID = existingTagID + db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Run(func(args mock.Arguments) { + t := args.Get(1).(*models.CreateTagInput) + t.Tag.ID = existingTagID }).Return(nil) err := i.PreImport(testCtx) @@ -285,7 +285,7 @@ func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) { } db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Once() - db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Return(errors.New("Create error")) + db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) diff --git a/pkg/models/custom_fields.go b/pkg/models/custom_fields.go index 5c3acd18b..3212d676f 100644 --- a/pkg/models/custom_fields.go +++ b/pkg/models/custom_fields.go @@ -17,3 +17,7 @@ type CustomFieldsReader interface { GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]CustomFieldMap, error) } + +type CustomFieldsWriter interface { + SetCustomFields(ctx context.Context, id int, fields CustomFieldsInput) error +} diff --git a/pkg/models/jsonschema/studio.go b/pkg/models/jsonschema/studio.go index a3706df66..7684b4317 100644 --- a/pkg/models/jsonschema/studio.go +++ b/pkg/models/jsonschema/studio.go @@ -25,6 +25,8 @@ type Studio struct { Tags []string `json:"tags,omitempty"` IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"` + CustomFields map[string]interface{} `json:"custom_fields,omitempty"` + // deprecated - for import only URL string `json:"url,omitempty"` } diff --git a/pkg/models/jsonschema/tag.go b/pkg/models/jsonschema/tag.go index faab1bfb2..e7b16b13f 100644 --- a/pkg/models/jsonschema/tag.go +++ b/pkg/models/jsonschema/tag.go @@ -11,17 +11,18 @@ import ( ) type Tag struct { - Name string `json:"name,omitempty"` - SortName string `json:"sort_name,omitempty"` - Description string `json:"description,omitempty"` - Favorite bool `json:"favorite,omitempty"` - Aliases []string `json:"aliases,omitempty"` - Image string `json:"image,omitempty"` - Parents []string `json:"parents,omitempty"` - IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"` - StashIDs []models.StashID `json:"stash_ids,omitempty"` - CreatedAt json.JSONTime `json:"created_at,omitempty"` - UpdatedAt json.JSONTime `json:"updated_at,omitempty"` + Name string `json:"name,omitempty"` + SortName string `json:"sort_name,omitempty"` + Description string `json:"description,omitempty"` + Favorite bool `json:"favorite,omitempty"` + Aliases []string `json:"aliases,omitempty"` + Image string `json:"image,omitempty"` + Parents []string `json:"parents,omitempty"` + IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"` + StashIDs []models.StashID `json:"stash_ids,omitempty"` + CreatedAt json.JSONTime `json:"created_at,omitempty"` + UpdatedAt json.JSONTime `json:"updated_at,omitempty"` + CustomFields map[string]interface{} `json:"custom_fields,omitempty"` } func (s Tag) Filename() string { diff --git a/pkg/models/mocks/TagReaderWriter.go b/pkg/models/mocks/TagReaderWriter.go index ac6b10584..95a3b7a87 100644 --- a/pkg/models/mocks/TagReaderWriter.go +++ b/pkg/models/mocks/TagReaderWriter.go @@ -101,11 +101,11 @@ func (_m *TagReaderWriter) CountByParentTagID(ctx context.Context, parentID int) } // Create provides a mock function with given fields: ctx, newTag -func (_m *TagReaderWriter) Create(ctx context.Context, newTag *models.Tag) error { +func (_m *TagReaderWriter) Create(ctx context.Context, newTag *models.CreateTagInput) error { ret := _m.Called(ctx, newTag) var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *models.Tag) error); ok { + if rf, ok := ret.Get(0).(func(context.Context, *models.CreateTagInput) error); ok { r0 = rf(ctx, newTag) } else { r0 = ret.Error(0) @@ -542,6 +542,52 @@ func (_m *TagReaderWriter) GetChildIDs(ctx context.Context, relatedID int) ([]in return r0, r1 } +// GetCustomFields provides a mock function with given fields: ctx, id +func (_m *TagReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) { + ret := _m.Called(ctx, id) + + var r0 map[string]interface{} + if rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]interface{}) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetCustomFieldsBulk provides a mock function with given fields: ctx, ids +func (_m *TagReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) { + ret := _m.Called(ctx, ids) + + var r0 []models.CustomFieldMap + if rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok { + r0 = rf(ctx, ids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.CustomFieldMap) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { + r1 = rf(ctx, ids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetImage provides a mock function with given fields: ctx, tagID func (_m *TagReaderWriter) GetImage(ctx context.Context, tagID int) ([]byte, error) { ret := _m.Called(ctx, tagID) @@ -699,12 +745,26 @@ func (_m *TagReaderWriter) QueryForAutoTag(ctx context.Context, words []string) return r0, r1 } +// SetCustomFields provides a mock function with given fields: ctx, id, fields +func (_m *TagReaderWriter) SetCustomFields(ctx context.Context, id int, fields models.CustomFieldsInput) error { + ret := _m.Called(ctx, id, fields) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, models.CustomFieldsInput) error); ok { + r0 = rf(ctx, id, fields) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Update provides a mock function with given fields: ctx, updatedTag -func (_m *TagReaderWriter) Update(ctx context.Context, updatedTag *models.Tag) error { +func (_m *TagReaderWriter) Update(ctx context.Context, updatedTag *models.UpdateTagInput) error { ret := _m.Called(ctx, updatedTag) var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *models.Tag) error); ok { + if rf, ok := ret.Get(0).(func(context.Context, *models.UpdateTagInput) error); ok { r0 = rf(ctx, updatedTag) } else { r0 = ret.Error(0) diff --git a/pkg/models/model_tag.go b/pkg/models/model_tag.go index 4cd038f7e..aee468639 100644 --- a/pkg/models/model_tag.go +++ b/pkg/models/model_tag.go @@ -29,6 +29,18 @@ func NewTag() Tag { } } +type CreateTagInput struct { + *Tag + + CustomFields map[string]interface{} `json:"custom_fields"` +} + +type UpdateTagInput struct { + *Tag + + CustomFields CustomFieldsInput `json:"custom_fields"` +} + func (s *Tag) LoadAliases(ctx context.Context, l AliasLoader) error { return s.Aliases.load(func() ([]string, error) { return l.GetAliases(ctx, s.ID) @@ -66,6 +78,8 @@ type TagPartial struct { ParentIDs *UpdateIDs ChildIDs *UpdateIDs StashIDs *UpdateStashIDs + + CustomFields CustomFieldsInput } func NewTagPartial() TagPartial { diff --git a/pkg/models/repository_tag.go b/pkg/models/repository_tag.go index a7f828f0b..ba403cf2d 100644 --- a/pkg/models/repository_tag.go +++ b/pkg/models/repository_tag.go @@ -51,12 +51,12 @@ type TagCounter interface { // TagCreator provides methods to create tags. type TagCreator interface { - Create(ctx context.Context, newTag *Tag) error + Create(ctx context.Context, newTag *CreateTagInput) error } // TagUpdater provides methods to update tags. type TagUpdater interface { - Update(ctx context.Context, updatedTag *Tag) error + Update(ctx context.Context, updatedTag *UpdateTagInput) error UpdatePartial(ctx context.Context, id int, updateTag TagPartial) (*Tag, error) UpdateAliases(ctx context.Context, tagID int, aliases []string) error UpdateImage(ctx context.Context, tagID int, image []byte) error @@ -77,6 +77,7 @@ type TagFinderCreator interface { type TagCreatorUpdater interface { TagCreator TagUpdater + CustomFieldsWriter } // TagReader provides all methods to read tags. @@ -89,6 +90,7 @@ type TagReader interface { AliasLoader TagRelationLoader StashIDLoader + CustomFieldsReader All(ctx context.Context) ([]*Tag, error) GetImage(ctx context.Context, tagID int) ([]byte, error) @@ -100,6 +102,7 @@ type TagWriter interface { TagCreator TagUpdater TagDestroyer + CustomFieldsWriter Merge(ctx context.Context, source []int, destination int) error } diff --git a/pkg/models/tag.go b/pkg/models/tag.go index 5ff2df6ad..0f39d8861 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -56,4 +56,7 @@ type TagFilterType struct { CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at UpdatedAt *TimestampCriterionInput `json:"updated_at"` + + // Filter by custom fields + CustomFields []CustomFieldCriterionInput `json:"custom_fields"` } diff --git a/pkg/performer/import.go b/pkg/performer/import.go index 622af2b1a..a8e3f7a7a 100644 --- a/pkg/performer/import.go +++ b/pkg/performer/import.go @@ -107,7 +107,9 @@ func createTags(ctx context.Context, tagWriter models.TagFinderCreator, names [] newTag := models.NewTag() newTag.Name = name - err := tagWriter.Create(ctx, &newTag) + err := tagWriter.Create(ctx, &models.CreateTagInput{ + Tag: &newTag, + }) if err != nil { return nil, err } diff --git a/pkg/performer/import_test.go b/pkg/performer/import_test.go index 0a3f86291..455a6e7a3 100644 --- a/pkg/performer/import_test.go +++ b/pkg/performer/import_test.go @@ -111,9 +111,9 @@ func TestImporterPreImportWithMissingTag(t *testing.T) { } db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3) - db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Run(func(args mock.Arguments) { - t := args.Get(1).(*models.Tag) - t.ID = existingTagID + db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Run(func(args mock.Arguments) { + t := args.Get(1).(*models.CreateTagInput) + t.Tag.ID = existingTagID }).Return(nil) err := i.PreImport(testCtx) @@ -146,7 +146,7 @@ func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) { } db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Once() - db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Return(errors.New("Create error")) + db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) diff --git a/pkg/scene/import.go b/pkg/scene/import.go index b3f0f1ff1..58604e1a5 100644 --- a/pkg/scene/import.go +++ b/pkg/scene/import.go @@ -549,7 +549,9 @@ func createTags(ctx context.Context, tagWriter models.TagCreator, names []string newTag := models.NewTag() newTag.Name = name - err := tagWriter.Create(ctx, &newTag) + err := tagWriter.Create(ctx, &models.CreateTagInput{ + Tag: &newTag, + }) if err != nil { return nil, err } diff --git a/pkg/scene/import_test.go b/pkg/scene/import_test.go index 558b72ba2..4936ec2bb 100644 --- a/pkg/scene/import_test.go +++ b/pkg/scene/import_test.go @@ -508,9 +508,9 @@ func TestImporterPreImportWithMissingTag(t *testing.T) { } db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3) - db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Run(func(args mock.Arguments) { - t := args.Get(1).(*models.Tag) - t.ID = existingTagID + db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Run(func(args mock.Arguments) { + t := args.Get(1).(*models.CreateTagInput) + t.Tag.ID = existingTagID }).Return(nil) err := i.PreImport(testCtx) @@ -542,7 +542,7 @@ func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) { } db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Once() - db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Return(errors.New("Create error")) + db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) diff --git a/pkg/sqlite/anonymise.go b/pkg/sqlite/anonymise.go index 764f569c0..e3b7492cc 100644 --- a/pkg/sqlite/anonymise.go +++ b/pkg/sqlite/anonymise.go @@ -678,6 +678,10 @@ func (db *Anonymiser) anonymiseStudios(ctx context.Context) error { return err } + if err := db.anonymiseCustomFields(ctx, goqu.T(studiosCustomFieldsTable.GetTable()), "studio_id"); err != nil { + return err + } + return nil } @@ -873,6 +877,10 @@ func (db *Anonymiser) anonymiseTags(ctx context.Context) error { return err } + if err := db.anonymiseCustomFields(ctx, goqu.T(tagsCustomFieldsTable.GetTable()), "tag_id"); err != nil { + return err + } + return nil } diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index a87f6706f..51889ff20 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 76 +var appSchemaVersion uint = 77 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/77_tag_custom_fields.up.sql b/pkg/sqlite/migrations/77_tag_custom_fields.up.sql new file mode 100644 index 000000000..b34a5f794 --- /dev/null +++ b/pkg/sqlite/migrations/77_tag_custom_fields.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE `tag_custom_fields` ( + `tag_id` integer NOT NULL, + `field` varchar(64) NOT NULL, + `value` BLOB NOT NULL, + PRIMARY KEY (`tag_id`, `field`), + foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE +); + +CREATE INDEX `index_tag_custom_fields_field_value` ON `tag_custom_fields` (`field`, `value`); \ No newline at end of file diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 361b5cb79..bdb83b1df 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1709,6 +1709,18 @@ func tagStashID(i int) models.StashID { } } +func getTagCustomFields(index int) map[string]interface{} { + if index%5 == 0 { + return nil + } + + return map[string]interface{}{ + "string": getTagStringValue(index, "custom"), + "int": int64(index % 5), + "real": float64(index) / 10, + } +} + // createTags creates n tags with plain Name and o tags with camel cased NaMe included func createTags(ctx context.Context, tqb models.TagReaderWriter, n int, o int) error { const namePlain = "Name" @@ -1736,7 +1748,10 @@ func createTags(ctx context.Context, tqb models.TagReaderWriter, n int, o int) e }) } - err := tqb.Create(ctx, &tag) + err := tqb.Create(ctx, &models.CreateTagInput{ + Tag: &tag, + CustomFields: getTagCustomFields(i), + }) if err != nil { return fmt.Errorf("Error creating tag %v+: %s", tag, err.Error()) diff --git a/pkg/sqlite/studio_test.go b/pkg/sqlite/studio_test.go index 074c77d6f..968f43413 100644 --- a/pkg/sqlite/studio_test.go +++ b/pkg/sqlite/studio_test.go @@ -1694,6 +1694,251 @@ func TestStudioQueryFast(t *testing.T) { }) } +func studiesToIDs(i []*models.Studio) []int { + ret := make([]int, len(i)) + for i, v := range i { + ret[i] = v.ID + } + + return ret +} + +func TestStudioQueryCustomFields(t *testing.T) { + tests := []struct { + name string + filter *models.StudioFilterType + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "equals", + &models.StudioFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierEquals, + Value: []any{getStudioStringValue(studioIdxWithTwoScenes, "custom")}, + }, + }, + }, + []int{studioIdxWithTwoScenes}, + nil, + false, + }, + { + "not equals", + &models.StudioFilterType{ + Name: &models.StringCriterionInput{ + Value: getStudioStringValue(studioIdxWithTwoScenes, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotEquals, + Value: []any{getStudioStringValue(studioIdxWithTwoScenes, "custom")}, + }, + }, + }, + nil, + []int{studioIdxWithTwoScenes}, + false, + }, + { + "includes", + &models.StudioFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierIncludes, + Value: []any{getStudioStringValue(studioIdxWithTwoScenes, "custom")[9:]}, + }, + }, + }, + []int{studioIdxWithTwoScenes}, + nil, + false, + }, + { + "excludes", + &models.StudioFilterType{ + Name: &models.StringCriterionInput{ + Value: getStudioStringValue(studioIdxWithTwoScenes, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierExcludes, + Value: []any{getStudioStringValue(studioIdxWithTwoScenes, "custom")[9:]}, + }, + }, + }, + nil, + []int{studioIdxWithTwoScenes}, + false, + }, + { + "regex", + &models.StudioFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierMatchesRegex, + Value: []any{".*1_custom"}, + }, + }, + }, + []int{studioIdxWithTwoScenes}, + nil, + false, + }, + { + "invalid regex", + &models.StudioFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierMatchesRegex, + Value: []any{"["}, + }, + }, + }, + nil, + nil, + true, + }, + { + "not matches regex", + &models.StudioFilterType{ + Name: &models.StringCriterionInput{ + Value: getStudioStringValue(studioIdxWithTwoScenes, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotMatchesRegex, + Value: []any{".*1_custom"}, + }, + }, + }, + nil, + []int{studioIdxWithTwoScenes}, + false, + }, + { + "invalid not matches regex", + &models.StudioFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotMatchesRegex, + Value: []any{"["}, + }, + }, + }, + nil, + nil, + true, + }, + { + "null", + &models.StudioFilterType{ + Name: &models.StringCriterionInput{ + Value: getStudioStringValue(studioIdxWithTwoScenes, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "not existing", + Modifier: models.CriterionModifierIsNull, + }, + }, + }, + []int{studioIdxWithTwoScenes}, + nil, + false, + }, + { + "not null", + &models.StudioFilterType{ + Name: &models.StringCriterionInput{ + Value: getStudioStringValue(studioIdxWithTwoScenes, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotNull, + }, + }, + }, + []int{studioIdxWithTwoScenes}, + nil, + false, + }, + { + "between", + &models.StudioFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "real", + Modifier: models.CriterionModifierBetween, + Value: []any{0.15, 0.25}, + }, + }, + }, + []int{studioIdxWithGroup}, + nil, + false, + }, + { + "not between", + &models.StudioFilterType{ + Name: &models.StringCriterionInput{ + Value: getStudioStringValue(studioIdxWithGroup, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "real", + Modifier: models.CriterionModifierNotBetween, + Value: []any{0.15, 0.25}, + }, + }, + }, + nil, + []int{studioIdxWithGroup}, + false, + }, + } + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + studios, _, err := db.Studio.Query(ctx, tt.filter, nil) + if (err != nil) != tt.wantErr { + t.Errorf("StudioStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } + + ids := studiesToIDs(studios) + include := indexesToIDs(studioIDs, tt.includeIdxs) + exclude := indexesToIDs(studioIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } +} + // TODO Create // TODO Update // TODO Destroy diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index bfc5199fe..f46190a30 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -49,6 +49,7 @@ var ( tagsAliasesJoinTable = goqu.T(tagAliasesTable) tagRelationsJoinTable = goqu.T(tagRelationsTable) tagsStashIDsJoinTable = goqu.T("tag_stash_ids") + tagsCustomFieldsTable = goqu.T("tag_custom_fields") ) var ( diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index b1d773290..ea18664d9 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -166,6 +166,7 @@ var ( type TagStore struct { blobJoinQueryBuilder + customFieldsStore tableMgr *table } @@ -176,6 +177,10 @@ func NewTagStore(blobStore *BlobStore) *TagStore { blobStore: blobStore, joinTable: tagTable, }, + customFieldsStore: customFieldsStore{ + table: tagsCustomFieldsTable, + fk: tagsCustomFieldsTable.Col(tagIDColumn), + }, tableMgr: tagTableMgr, } } @@ -188,9 +193,9 @@ func (qb *TagStore) selectDataset() *goqu.SelectDataset { return dialect.From(qb.table()).Select(qb.table().All()) } -func (qb *TagStore) Create(ctx context.Context, newObject *models.Tag) error { +func (qb *TagStore) Create(ctx context.Context, newObject *models.CreateTagInput) error { var r tagRow - r.fromTag(*newObject) + r.fromTag(*newObject.Tag) id, err := qb.tableMgr.insertID(ctx, r) if err != nil { @@ -221,12 +226,17 @@ func (qb *TagStore) Create(ctx context.Context, newObject *models.Tag) error { } } + const partial = false + if err := qb.setCustomFields(ctx, id, newObject.CustomFields, partial); err != nil { + return err + } + updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) } - *newObject = *updated + *newObject.Tag = *updated return nil } @@ -270,12 +280,16 @@ func (qb *TagStore) UpdatePartial(ctx context.Context, id int, partial models.Ta } } + if err := qb.SetCustomFields(ctx, id, partial.CustomFields); err != nil { + return nil, err + } + return qb.find(ctx, id) } -func (qb *TagStore) Update(ctx context.Context, updatedObject *models.Tag) error { +func (qb *TagStore) Update(ctx context.Context, updatedObject *models.UpdateTagInput) error { var r tagRow - r.fromTag(*updatedObject) + r.fromTag(*updatedObject.Tag) if err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { return err @@ -305,6 +319,10 @@ func (qb *TagStore) Update(ctx context.Context, updatedObject *models.Tag) error } } + if err := qb.SetCustomFields(ctx, updatedObject.ID, updatedObject.CustomFields); err != nil { + return err + } + return nil } diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index dadc351ee..2f4e79149 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -101,6 +101,13 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler { ×tampCriterionHandler{tagFilter.CreatedAt, "tags.created_at", nil}, ×tampCriterionHandler{tagFilter.UpdatedAt, "tags.updated_at", nil}, + &customFieldsFilterHandler{ + table: tagsCustomFieldsTable.GetTable(), + fkCol: tagIDColumn, + c: tagFilter.CustomFields, + idCol: "tags.id", + }, + &relatedFilterHandler{ relatedIDCol: "scenes_tags.scene_id", relatedRepo: sceneRepository.repository, diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index f1bac19b2..b673de3f9 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -1012,8 +1012,10 @@ func TestTagUpdateTagImage(t *testing.T) { // create tag to test against const name = "TestTagUpdateTagImage" - tag := models.Tag{ - Name: name, + tag := models.CreateTagInput{ + Tag: &models.Tag{ + Name: name, + }, } err := qb.Create(ctx, &tag) if err != nil { @@ -1032,15 +1034,17 @@ func TestTagUpdateAlias(t *testing.T) { // create tag to test against const name = "TestTagUpdateAlias" - tag := models.Tag{ - Name: name, + tag := models.CreateTagInput{ + Tag: &models.Tag{ + Name: name, + }, } err := qb.Create(ctx, &tag) if err != nil { return fmt.Errorf("Error creating tag: %s", err.Error()) } - aliases := []string{"alias1", "alias2"} + aliases := []string{"updatedAlias1", "updatedAlias2"} err = qb.UpdateAliases(ctx, tag.ID, aliases) if err != nil { return fmt.Errorf("Error updating tag aliases: %s", err.Error()) @@ -1065,8 +1069,10 @@ func TestTagStashIDs(t *testing.T) { // create tag to test against const name = "TestTagStashIDs" - tag := models.Tag{ - Name: name, + tag := models.CreateTagInput{ + Tag: &models.Tag{ + Name: name, + }, } err := qb.Create(ctx, &tag) if err != nil { @@ -1089,9 +1095,11 @@ func TestTagFindByStashID(t *testing.T) { const name = "TestTagFindByStashID" const stashID = "stashid" const endpoint = "endpoint" - tag := models.Tag{ - Name: name, - StashIDs: models.NewRelatedStashIDs([]models.StashID{{StashID: stashID, Endpoint: endpoint}}), + tag := models.CreateTagInput{ + Tag: &models.Tag{ + Name: name, + StashIDs: models.NewRelatedStashIDs([]models.StashID{{StashID: stashID, Endpoint: endpoint}}), + }, } err := qb.Create(ctx, &tag) if err != nil { @@ -1263,8 +1271,626 @@ func TestTagMerge(t *testing.T) { } } -// TODO Create -// TODO Update +func loadTagRelationships(ctx context.Context, expected models.Tag, actual *models.Tag) error { + if expected.Aliases.Loaded() { + if err := actual.LoadAliases(ctx, db.Tag); err != nil { + return err + } + } + if expected.ParentIDs.Loaded() { + if err := actual.LoadParentIDs(ctx, db.Tag); err != nil { + return err + } + } + if expected.ChildIDs.Loaded() { + if err := actual.LoadChildIDs(ctx, db.Tag); err != nil { + return err + } + } + if expected.StashIDs.Loaded() { + if err := actual.LoadStashIDs(ctx, db.Tag); err != nil { + return err + } + } + + return nil +} + +func Test_TagStore_Create(t *testing.T) { + var ( + name = "name" + sortName = "sortName" + description = "description" + favorite = true + ignoreAutoTag = true + aliases = []string{"alias1", "alias2"} + endpoint1 = "endpoint1" + endpoint2 = "endpoint2" + stashID1 = "stashid1" + stashID2 = "stashid2" + createdAt = epochTime + updatedAt = epochTime + ) + + tests := []struct { + name string + newObject models.CreateTagInput + wantErr bool + }{ + { + "full", + models.CreateTagInput{ + Tag: &models.Tag{ + Name: name, + SortName: sortName, + Description: description, + Favorite: favorite, + IgnoreAutoTag: ignoreAutoTag, + Aliases: models.NewRelatedStrings(aliases), + ParentIDs: models.NewRelatedIDs([]int{tagIDs[tagIdxWithScene]}), + ChildIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithScene]}), + StashIDs: models.NewRelatedStashIDs([]models.StashID{ + { + StashID: stashID1, + Endpoint: endpoint1, + UpdatedAt: epochTime, + }, + { + StashID: stashID2, + Endpoint: endpoint2, + UpdatedAt: epochTime, + }, + }), + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, + CustomFields: testCustomFields, + }, + false, + }, + { + "invalid parent id", + models.CreateTagInput{ + Tag: &models.Tag{ + Name: name, + ParentIDs: models.NewRelatedIDs([]int{invalidID}), + }, + }, + true, + }, + { + "invalid child id", + models.CreateTagInput{ + Tag: &models.Tag{ + Name: name, + ChildIDs: models.NewRelatedIDs([]int{invalidID}), + }, + }, + true, + }, + } + + qb := db.Tag + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + p := tt.newObject + if err := qb.Create(ctx, &p); (err != nil) != tt.wantErr { + t.Errorf("TagStore.Create() error = %v, wantErr = %v", err, tt.wantErr) + } + + if tt.wantErr { + assert.Zero(p.ID) + return + } + + assert.NotZero(p.ID) + + copy := *tt.newObject.Tag + copy.ID = p.ID + + // load relationships + if err := loadTagRelationships(ctx, copy, p.Tag); err != nil { + t.Errorf("loadTagRelationships() error = %v", err) + return + } + + assert.Equal(copy, *p.Tag) + + // ensure can find the tag + found, err := qb.Find(ctx, p.ID) + if err != nil { + t.Errorf("TagStore.Find() error = %v", err) + } + + if !assert.NotNil(found) { + return + } + + // load relationships + if err := loadTagRelationships(ctx, copy, found); err != nil { + t.Errorf("loadTagRelationships() error = %v", err) + return + } + assert.Equal(copy, *found) + + // ensure custom fields are set + cf, err := qb.GetCustomFields(ctx, p.ID) + if err != nil { + t.Errorf("TagStore.GetCustomFields() error = %v", err) + return + } + + assert.Equal(tt.newObject.CustomFields, cf) + + return + }) + } +} + +func Test_TagStore_Update(t *testing.T) { + var ( + name = "name" + sortName = "sortName" + description = "description" + favorite = true + ignoreAutoTag = true + aliases = []string{"alias1", "alias2"} + endpoint1 = "endpoint1" + endpoint2 = "endpoint2" + stashID1 = "stashid1" + stashID2 = "stashid2" + createdAt = epochTime + updatedAt = epochTime + ) + + tests := []struct { + name string + updatedObject models.UpdateTagInput + wantErr bool + }{ + { + "full", + models.UpdateTagInput{ + Tag: &models.Tag{ + ID: tagIDs[tagIdxWithGallery], + Name: name, + SortName: sortName, + Description: description, + Favorite: favorite, + IgnoreAutoTag: ignoreAutoTag, + Aliases: models.NewRelatedStrings(aliases), + ParentIDs: models.NewRelatedIDs([]int{tagIDs[tagIdxWithScene]}), + ChildIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithScene]}), + StashIDs: models.NewRelatedStashIDs([]models.StashID{ + { + StashID: stashID1, + Endpoint: endpoint1, + UpdatedAt: epochTime, + }, + { + StashID: stashID2, + Endpoint: endpoint2, + UpdatedAt: epochTime, + }, + }), + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, + CustomFields: models.CustomFieldsInput{ + Full: map[string]interface{}{ + "string": "updated", + "int": int64(999), + "real": 9.99, + }, + }, + }, + false, + }, + { + "set custom fields", + models.UpdateTagInput{ + Tag: &models.Tag{ + ID: tagIDs[tagIdxWithGallery], + Name: tagNames[tagIdxWithGallery], + }, + CustomFields: models.CustomFieldsInput{ + Full: testCustomFields, + }, + }, + false, + }, + { + "clear custom fields", + models.UpdateTagInput{ + Tag: &models.Tag{ + ID: tagIDs[tagIdxWithGallery], + Name: tagNames[tagIdxWithGallery], + }, + CustomFields: models.CustomFieldsInput{ + Full: map[string]interface{}{}, + }, + }, + false, + }, + { + "invalid parent id", + models.UpdateTagInput{ + Tag: &models.Tag{ + ID: tagIDs[tagIdxWithGallery], + Name: tagNames[tagIdxWithGallery], + ParentIDs: models.NewRelatedIDs([]int{invalidID}), + }, + }, + true, + }, + { + "invalid child id", + models.UpdateTagInput{ + Tag: &models.Tag{ + ID: tagIDs[tagIdxWithGallery], + Name: tagNames[tagIdxWithGallery], + ChildIDs: models.NewRelatedIDs([]int{invalidID}), + }, + }, + true, + }, + } + + qb := db.Tag + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + p := tt.updatedObject + if err := qb.Update(ctx, &p); (err != nil) != tt.wantErr { + t.Errorf("TagStore.Update() error = %v, wantErr = %v", err, tt.wantErr) + } + + if tt.wantErr { + return + } + + s, err := qb.Find(ctx, tt.updatedObject.ID) + if err != nil { + t.Errorf("TagStore.Find() error = %v", err) + return + } + + // load relationships + if err := loadTagRelationships(ctx, *tt.updatedObject.Tag, s); err != nil { + t.Errorf("loadTagRelationships() error = %v", err) + return + } + + assert.Equal(*tt.updatedObject.Tag, *s) + + // ensure custom fields are correct + if tt.updatedObject.CustomFields.Full != nil { + cf, err := qb.GetCustomFields(ctx, tt.updatedObject.ID) + if err != nil { + t.Errorf("TagStore.GetCustomFields() error = %v", err) + return + } + + assert.Equal(tt.updatedObject.CustomFields.Full, cf) + } + }) + } +} + +func Test_TagStore_UpdatePartialCustomFields(t *testing.T) { + tests := []struct { + name string + id int + partial models.TagPartial + expected map[string]interface{} // nil to use the partial + }{ + { + "set custom fields", + tagIDs[tagIdxWithGallery], + models.TagPartial{ + CustomFields: models.CustomFieldsInput{ + Full: testCustomFields, + }, + }, + nil, + }, + { + "clear custom fields", + tagIDs[tagIdxWithGallery], + models.TagPartial{ + CustomFields: models.CustomFieldsInput{ + Full: map[string]interface{}{}, + }, + }, + nil, + }, + { + "partial custom fields", + tagIDs[tagIdxWithGallery], + models.TagPartial{ + CustomFields: models.CustomFieldsInput{ + Partial: map[string]interface{}{ + "string": "bbb", + "new_field": "new", + }, + }, + }, + map[string]interface{}{ + "int": int64(2), + "real": float64(1.7), + "string": "bbb", + "new_field": "new", + }, + }, + } + for _, tt := range tests { + qb := db.Tag + + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + _, err := qb.UpdatePartial(ctx, tt.id, tt.partial) + if err != nil { + t.Errorf("TagStore.UpdatePartial() error = %v", err) + return + } + + // ensure custom fields are correct + cf, err := qb.GetCustomFields(ctx, tt.id) + if err != nil { + t.Errorf("TagStore.GetCustomFields() error = %v", err) + return + } + if tt.expected == nil { + assert.Equal(tt.partial.CustomFields.Full, cf) + } else { + assert.Equal(tt.expected, cf) + } + }) + } +} + +func TestTagQueryCustomFields(t *testing.T) { + tests := []struct { + name string + filter *models.TagFilterType + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "equals", + &models.TagFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierEquals, + Value: []any{getTagStringValue(tagIdxWithGallery, "custom")}, + }, + }, + }, + []int{tagIdxWithGallery}, + nil, + false, + }, + { + "not equals", + &models.TagFilterType{ + Name: &models.StringCriterionInput{ + Value: getTagStringValue(tagIdxWithGallery, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotEquals, + Value: []any{getTagStringValue(tagIdxWithGallery, "custom")}, + }, + }, + }, + nil, + []int{tagIdxWithGallery}, + false, + }, + { + "includes", + &models.TagFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierIncludes, + Value: []any{getTagStringValue(tagIdxWithGallery, "custom")[9:]}, + }, + }, + }, + []int{tagIdxWithGallery}, + nil, + false, + }, + { + "excludes", + &models.TagFilterType{ + Name: &models.StringCriterionInput{ + Value: getTagStringValue(tagIdxWithGallery, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierExcludes, + Value: []any{getTagStringValue(tagIdxWithGallery, "custom")[9:]}, + }, + }, + }, + nil, + []int{tagIdxWithGallery}, + false, + }, + { + "regex", + &models.TagFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierMatchesRegex, + Value: []any{".*17_custom"}, + }, + }, + }, + []int{tagIdxWithGallery}, + nil, + false, + }, + { + "invalid regex", + &models.TagFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierMatchesRegex, + Value: []any{"["}, + }, + }, + }, + nil, + nil, + true, + }, + { + "not matches regex", + &models.TagFilterType{ + Name: &models.StringCriterionInput{ + Value: getTagStringValue(tagIdxWithGallery, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotMatchesRegex, + Value: []any{".*17_custom"}, + }, + }, + }, + nil, + []int{tagIdxWithGallery}, + false, + }, + { + "invalid not matches regex", + &models.TagFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotMatchesRegex, + Value: []any{"["}, + }, + }, + }, + nil, + nil, + true, + }, + { + "null", + &models.TagFilterType{ + Name: &models.StringCriterionInput{ + Value: getTagStringValue(tagIdxWithGallery, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "not existing", + Modifier: models.CriterionModifierIsNull, + }, + }, + }, + []int{tagIdxWithGallery}, + nil, + false, + }, + { + "not null", + &models.TagFilterType{ + Name: &models.StringCriterionInput{ + Value: getTagStringValue(tagIdxWithGallery, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotNull, + }, + }, + }, + []int{tagIdxWithGallery}, + nil, + false, + }, + { + "between", + &models.TagFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "real", + Modifier: models.CriterionModifierBetween, + Value: []any{0.15, 0.25}, + }, + }, + }, + []int{tagIdx2WithScene}, + nil, + false, + }, + { + "not between", + &models.TagFilterType{ + Name: &models.StringCriterionInput{ + Value: getTagStringValue(tagIdx2WithScene, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "real", + Modifier: models.CriterionModifierNotBetween, + Value: []any{0.15, 0.25}, + }, + }, + }, + nil, + []int{tagIdx2WithScene}, + false, + }, + } + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + tags, _, err := db.Tag.Query(ctx, tt.filter, nil) + if (err != nil) != tt.wantErr { + t.Errorf("TagStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } + + ids := tagsToIDs(tags) + include := indexesToIDs(tagIDs, tt.includeIdxs) + exclude := indexesToIDs(tagIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } +} + // TODO Destroy // TODO Find // TODO FindBySceneID diff --git a/pkg/studio/export.go b/pkg/studio/export.go index 1440c3cdd..c3a50668f 100644 --- a/pkg/studio/export.go +++ b/pkg/studio/export.go @@ -17,6 +17,7 @@ type FinderImageStashIDGetter interface { models.URLLoader models.StashIDLoader GetImage(ctx context.Context, studioID int) ([]byte, error) + models.CustomFieldsReader } // ToJSON converts a Studio object into its JSON equivalent. @@ -60,6 +61,12 @@ func ToJSON(ctx context.Context, reader FinderImageStashIDGetter, studio *models } newStudioJSON.StashIDs = studio.StashIDs.List() + var err error + newStudioJSON.CustomFields, err = reader.GetCustomFields(ctx, studio.ID) + if err != nil { + return nil, fmt.Errorf("getting studio custom fields: %v", err) + } + image, err := reader.GetImage(ctx, studio.ID) if err != nil { logger.Errorf("Error getting studio image: %v", err) diff --git a/pkg/studio/export_test.go b/pkg/studio/export_test.go index c333c0ad5..e41e6f36c 100644 --- a/pkg/studio/export_test.go +++ b/pkg/studio/export_test.go @@ -18,18 +18,24 @@ const ( errImageID = 3 missingParentStudioID = 4 errStudioID = 5 + customFieldsID = 6 parentStudioID = 10 missingStudioID = 11 errParentStudioID = 12 + errCustomFieldsID = 13 ) var ( - studioName = "testStudio" - url = "url" - details = "details" - parentStudioName = "parentStudio" - autoTagIgnored = true + studioName = "testStudio" + url = "url" + details = "details" + parentStudioName = "parentStudio" + autoTagIgnored = true + emptyCustomFields = make(map[string]interface{}) + customFields = map[string]interface{}{ + "customField1": "customValue1", + } ) var studioID = 1 @@ -91,7 +97,7 @@ func createEmptyStudio(id int) models.Studio { } } -func createFullJSONStudio(parentStudio, image string, aliases []string) *jsonschema.Studio { +func createFullJSONStudio(parentStudio, image string, aliases []string, customFields map[string]interface{}) *jsonschema.Studio { return &jsonschema.Studio{ Name: studioName, URLs: []string{url}, @@ -109,6 +115,7 @@ func createFullJSONStudio(parentStudio, image string, aliases []string) *jsonsch Aliases: aliases, StashIDs: stashIDs, IgnoreAutoTag: autoTagIgnored, + CustomFields: customFields, } } @@ -120,16 +127,18 @@ func createEmptyJSONStudio() *jsonschema.Studio { UpdatedAt: json.JSONTime{ Time: updateTime, }, - Aliases: []string{}, - URLs: []string{}, - StashIDs: []models.StashID{}, + Aliases: []string{}, + URLs: []string{}, + StashIDs: []models.StashID{}, + CustomFields: emptyCustomFields, } } type testScenario struct { - input models.Studio - expected *jsonschema.Studio - err bool + input models.Studio + customFields map[string]interface{} + expected *jsonschema.Studio + err bool } var scenarios []testScenario @@ -138,30 +147,48 @@ func initTestTable() { scenarios = []testScenario{ { createFullStudio(studioID, parentStudioID), - createFullJSONStudio(parentStudioName, image, []string{"alias"}), + emptyCustomFields, + createFullJSONStudio(parentStudioName, image, []string{"alias"}, emptyCustomFields), + false, + }, + { + createFullStudio(customFieldsID, parentStudioID), + customFields, + createFullJSONStudio(parentStudioName, image, []string{"alias"}, customFields), false, }, { createEmptyStudio(noImageID), + emptyCustomFields, createEmptyJSONStudio(), false, }, { createFullStudio(errImageID, parentStudioID), - createFullJSONStudio(parentStudioName, "", []string{"alias"}), + emptyCustomFields, + createFullJSONStudio(parentStudioName, "", []string{"alias"}, emptyCustomFields), // failure to get image is not an error false, }, { createFullStudio(missingParentStudioID, missingStudioID), - createFullJSONStudio("", image, []string{"alias"}), + emptyCustomFields, + createFullJSONStudio("", image, []string{"alias"}, emptyCustomFields), false, }, { createFullStudio(errStudioID, errParentStudioID), + emptyCustomFields, nil, true, }, + { + createFullStudio(errCustomFieldsID, parentStudioID), + customFields, + nil, + // failure to get custom fields should cause an error + true, + }, } } @@ -177,6 +204,7 @@ func TestToJSON(t *testing.T) { db.Studio.On("GetImage", testCtx, errImageID).Return(nil, imageErr).Once() db.Studio.On("GetImage", testCtx, missingParentStudioID).Return(imageBytes, nil).Maybe() db.Studio.On("GetImage", testCtx, errStudioID).Return(imageBytes, nil).Maybe() + db.Studio.On("GetImage", testCtx, customFieldsID).Return(imageBytes, nil).Once() parentStudioErr := errors.New("error getting parent studio") @@ -184,6 +212,15 @@ func TestToJSON(t *testing.T) { db.Studio.On("Find", testCtx, missingStudioID).Return(nil, nil) db.Studio.On("Find", testCtx, errParentStudioID).Return(nil, parentStudioErr) + customFieldsErr := errors.New("error getting custom fields") + + db.Studio.On("GetCustomFields", testCtx, studioID).Return(emptyCustomFields, nil).Once() + db.Studio.On("GetCustomFields", testCtx, customFieldsID).Return(customFields, nil).Once() + db.Studio.On("GetCustomFields", testCtx, missingParentStudioID).Return(emptyCustomFields, nil).Once() + db.Studio.On("GetCustomFields", testCtx, noImageID).Return(emptyCustomFields, nil).Once() + db.Studio.On("GetCustomFields", testCtx, errImageID).Return(emptyCustomFields, nil).Once() + db.Studio.On("GetCustomFields", testCtx, errCustomFieldsID).Return(nil, customFieldsErr).Once() + for i, s := range scenarios { studio := s.input json, err := ToJSON(testCtx, db.Studio, &studio) diff --git a/pkg/studio/import.go b/pkg/studio/import.go index d5284ce02..d9e52100c 100644 --- a/pkg/studio/import.go +++ b/pkg/studio/import.go @@ -26,13 +26,15 @@ type Importer struct { Input jsonschema.Studio MissingRefBehaviour models.ImportMissingRefEnum - ID int - studio models.Studio - imageData []byte + ID int + studio models.Studio + customFields models.CustomFieldMap + imageData []byte } func (i *Importer) PreImport(ctx context.Context) error { i.studio = studioJSONtoStudio(i.Input) + i.customFields = i.Input.CustomFields if err := i.populateParentStudio(ctx); err != nil { return err @@ -110,7 +112,9 @@ func createTags(ctx context.Context, tagWriter models.TagFinderCreator, names [] newTag := models.NewTag() newTag.Name = name - err := tagWriter.Create(ctx, &newTag) + err := tagWriter.Create(ctx, &models.CreateTagInput{ + Tag: &newTag, + }) if err != nil { return nil, err } @@ -194,7 +198,10 @@ func (i *Importer) FindExistingID(ctx context.Context) (*int, error) { } func (i *Importer) Create(ctx context.Context) (*int, error) { - err := i.ReaderWriter.Create(ctx, &models.CreateStudioInput{Studio: &i.studio}) + err := i.ReaderWriter.Create(ctx, &models.CreateStudioInput{ + Studio: &i.studio, + CustomFields: i.customFields, + }) if err != nil { return nil, fmt.Errorf("error creating studio: %v", err) } @@ -206,7 +213,12 @@ func (i *Importer) Create(ctx context.Context) (*int, error) { func (i *Importer) Update(ctx context.Context, id int) error { studio := i.studio studio.ID = id - err := i.ReaderWriter.Update(ctx, &models.UpdateStudioInput{Studio: &studio}) + err := i.ReaderWriter.Update(ctx, &models.UpdateStudioInput{ + Studio: &studio, + CustomFields: models.CustomFieldsInput{ + Full: i.customFields, + }, + }) if err != nil { return fmt.Errorf("error updating existing studio: %v", err) } diff --git a/pkg/studio/import_test.go b/pkg/studio/import_test.go index 6648ebe0d..4eb757293 100644 --- a/pkg/studio/import_test.go +++ b/pkg/studio/import_test.go @@ -62,7 +62,7 @@ func TestImporterPreImport(t *testing.T) { assert.Nil(t, err) - i.Input = *createFullJSONStudio(studioName, image, []string{"alias"}) + i.Input = *createFullJSONStudio(studioName, image, []string{"alias"}, customFields) i.Input.ParentStudio = "" err = i.PreImport(testCtx) @@ -71,6 +71,7 @@ func TestImporterPreImport(t *testing.T) { expectedStudio := createFullStudio(0, 0) expectedStudio.ParentID = nil assert.Equal(t, expectedStudio, i.studio) + assert.Equal(t, models.CustomFieldMap(customFields), i.customFields) } func TestImporterPreImportWithTag(t *testing.T) { @@ -121,9 +122,9 @@ func TestImporterPreImportWithMissingTag(t *testing.T) { } db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3) - db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Run(func(args mock.Arguments) { - t := args.Get(1).(*models.Tag) - t.ID = existingTagID + db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Run(func(args mock.Arguments) { + t := args.Get(1).(*models.CreateTagInput) + t.Tag.ID = existingTagID }).Return(nil) err := i.PreImport(testCtx) @@ -156,7 +157,7 @@ func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) { } db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Once() - db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Return(errors.New("Create error")) + db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) diff --git a/pkg/tag/export.go b/pkg/tag/export.go index b07418667..fc7115209 100644 --- a/pkg/tag/export.go +++ b/pkg/tag/export.go @@ -16,6 +16,7 @@ type FinderAliasImageGetter interface { GetAliases(ctx context.Context, studioID int) ([]string, error) GetImage(ctx context.Context, tagID int) ([]byte, error) FindByChildTagID(ctx context.Context, childID int) ([]*models.Tag, error) + GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) models.StashIDLoader } @@ -63,6 +64,11 @@ func ToJSON(ctx context.Context, reader FinderAliasImageGetter, tag *models.Tag) newTagJSON.Parents = GetNames(parents) + newTagJSON.CustomFields, err = reader.GetCustomFields(ctx, tag.ID) + if err != nil { + return nil, fmt.Errorf("getting tag custom fields: %v", err) + } + return &newTagJSON, nil } diff --git a/pkg/tag/export_test.go b/pkg/tag/export_test.go index 84e082f30..cba2d4ebf 100644 --- a/pkg/tag/export_test.go +++ b/pkg/tag/export_test.go @@ -14,12 +14,14 @@ import ( ) const ( - tagID = 1 - noImageID = 2 - errImageID = 3 - errAliasID = 4 - withParentsID = 5 - errParentsID = 6 + tagID = iota + 1 + customFieldsID + noImageID + errImageID + errAliasID + withParentsID + errParentsID + errCustomFieldsID ) const ( @@ -32,6 +34,11 @@ var ( autoTagIgnored = true createTime = time.Date(2001, 01, 01, 0, 0, 0, 0, time.UTC) updateTime = time.Date(2002, 01, 01, 0, 0, 0, 0, time.UTC) + + emptyCustomFields = make(map[string]interface{}) + customFields = map[string]interface{}{ + "customField1": "customValue1", + } ) func createTag(id int) models.Tag { @@ -47,8 +54,8 @@ func createTag(id int) models.Tag { } } -func createJSONTag(aliases []string, image string, parents []string) *jsonschema.Tag { - return &jsonschema.Tag{ +func createJSONTag(aliases []string, image string, parents []string, withCustomFields bool) *jsonschema.Tag { + ret := &jsonschema.Tag{ Name: tagName, SortName: sortName, Favorite: true, @@ -61,15 +68,23 @@ func createJSONTag(aliases []string, image string, parents []string) *jsonschema UpdatedAt: json.JSONTime{ Time: updateTime, }, - Image: image, - Parents: parents, + Image: image, + Parents: parents, + CustomFields: emptyCustomFields, } + + if withCustomFields { + ret.CustomFields = customFields + } + + return ret } type testScenario struct { - tag models.Tag - expected *jsonschema.Tag - err bool + tag models.Tag + customFields map[string]interface{} + expected *jsonschema.Tag + err bool } var scenarios []testScenario @@ -78,32 +93,50 @@ func initTestTable() { scenarios = []testScenario{ { createTag(tagID), - createJSONTag([]string{"alias"}, image, nil), + emptyCustomFields, + createJSONTag([]string{"alias"}, image, nil, false), + false, + }, + { + createTag(customFieldsID), + customFields, + createJSONTag([]string{"alias"}, image, nil, true), false, }, { createTag(noImageID), - createJSONTag(nil, "", nil), + emptyCustomFields, + createJSONTag(nil, "", nil, false), false, }, { createTag(errImageID), - createJSONTag(nil, "", nil), + emptyCustomFields, + createJSONTag(nil, "", nil, false), // getting the image should not cause an error false, }, { createTag(errAliasID), + emptyCustomFields, nil, true, }, { createTag(withParentsID), - createJSONTag(nil, image, []string{"parent"}), + emptyCustomFields, + createJSONTag(nil, image, []string{"parent"}, false), false, }, { createTag(errParentsID), + emptyCustomFields, + nil, + true, + }, + { + createTag(errCustomFieldsID), + customFields, nil, true, }, @@ -118,32 +151,48 @@ func TestToJSON(t *testing.T) { imageErr := errors.New("error getting image") aliasErr := errors.New("error getting aliases") parentsErr := errors.New("error getting parents") + customFieldsErr := errors.New("error getting custom fields") db.Tag.On("GetAliases", testCtx, tagID).Return([]string{"alias"}, nil).Once() + db.Tag.On("GetAliases", testCtx, customFieldsID).Return([]string{"alias"}, nil).Once() db.Tag.On("GetAliases", testCtx, noImageID).Return(nil, nil).Once() db.Tag.On("GetAliases", testCtx, errImageID).Return(nil, nil).Once() db.Tag.On("GetAliases", testCtx, errAliasID).Return(nil, aliasErr).Once() db.Tag.On("GetAliases", testCtx, withParentsID).Return(nil, nil).Once() db.Tag.On("GetAliases", testCtx, errParentsID).Return(nil, nil).Once() + db.Tag.On("GetAliases", testCtx, errCustomFieldsID).Return(nil, nil).Once() db.Tag.On("GetStashIDs", testCtx, tagID).Return(nil, nil).Once() + db.Tag.On("GetStashIDs", testCtx, customFieldsID).Return(nil, nil).Once() db.Tag.On("GetStashIDs", testCtx, noImageID).Return(nil, nil).Once() db.Tag.On("GetStashIDs", testCtx, errImageID).Return(nil, nil).Once() // errAliasID test fails before GetStashIDs is called, so no mock needed db.Tag.On("GetStashIDs", testCtx, withParentsID).Return(nil, nil).Once() db.Tag.On("GetStashIDs", testCtx, errParentsID).Return(nil, nil).Once() + db.Tag.On("GetStashIDs", testCtx, errCustomFieldsID).Return(nil, nil).Once() db.Tag.On("GetImage", testCtx, tagID).Return(imageBytes, nil).Once() + db.Tag.On("GetImage", testCtx, customFieldsID).Return(imageBytes, nil).Once() db.Tag.On("GetImage", testCtx, noImageID).Return(nil, nil).Once() db.Tag.On("GetImage", testCtx, errImageID).Return(nil, imageErr).Once() db.Tag.On("GetImage", testCtx, withParentsID).Return(imageBytes, nil).Once() db.Tag.On("GetImage", testCtx, errParentsID).Return(nil, nil).Once() + db.Tag.On("GetImage", testCtx, errCustomFieldsID).Return(nil, nil).Once() db.Tag.On("FindByChildTagID", testCtx, tagID).Return(nil, nil).Once() + db.Tag.On("FindByChildTagID", testCtx, customFieldsID).Return(nil, nil).Once() db.Tag.On("FindByChildTagID", testCtx, noImageID).Return(nil, nil).Once() db.Tag.On("FindByChildTagID", testCtx, withParentsID).Return([]*models.Tag{{Name: "parent"}}, nil).Once() db.Tag.On("FindByChildTagID", testCtx, errParentsID).Return(nil, parentsErr).Once() db.Tag.On("FindByChildTagID", testCtx, errImageID).Return(nil, nil).Once() + db.Tag.On("FindByChildTagID", testCtx, errCustomFieldsID).Return(nil, nil).Once() + + db.Tag.On("GetCustomFields", testCtx, tagID).Return(emptyCustomFields, nil).Once() + db.Tag.On("GetCustomFields", testCtx, customFieldsID).Return(customFields, nil).Once() + db.Tag.On("GetCustomFields", testCtx, noImageID).Return(emptyCustomFields, nil).Once() + db.Tag.On("GetCustomFields", testCtx, errImageID).Return(emptyCustomFields, nil).Once() + db.Tag.On("GetCustomFields", testCtx, withParentsID).Return(emptyCustomFields, nil).Once() + db.Tag.On("GetCustomFields", testCtx, errCustomFieldsID).Return(nil, customFieldsErr).Once() for i, s := range scenarios { tag := s.tag diff --git a/pkg/tag/import.go b/pkg/tag/import.go index 53b741886..501dc6795 100644 --- a/pkg/tag/import.go +++ b/pkg/tag/import.go @@ -31,8 +31,9 @@ type Importer struct { Input jsonschema.Tag MissingRefBehaviour models.ImportMissingRefEnum - tag models.Tag - imageData []byte + tag models.Tag + imageData []byte + customFields map[string]interface{} } func (i *Importer) PreImport(ctx context.Context) error { @@ -55,6 +56,8 @@ func (i *Importer) PreImport(ctx context.Context) error { } } + i.customFields = i.Input.CustomFields + return nil } @@ -78,6 +81,14 @@ func (i *Importer) PostImport(ctx context.Context, id int) error { return fmt.Errorf("error setting parents: %v", err) } + if len(i.customFields) > 0 { + if err := i.ReaderWriter.SetCustomFields(ctx, id, models.CustomFieldsInput{ + Full: i.customFields, + }); err != nil { + return fmt.Errorf("error setting tag custom fields: %v", err) + } + } + return nil } @@ -101,7 +112,10 @@ func (i *Importer) FindExistingID(ctx context.Context) (*int, error) { } func (i *Importer) Create(ctx context.Context) (*int, error) { - err := i.ReaderWriter.Create(ctx, &i.tag) + err := i.ReaderWriter.Create(ctx, &models.CreateTagInput{ + Tag: &i.tag, + CustomFields: i.customFields, + }) if err != nil { return nil, fmt.Errorf("error creating tag: %v", err) } @@ -113,7 +127,12 @@ func (i *Importer) Create(ctx context.Context) (*int, error) { func (i *Importer) Update(ctx context.Context, id int) error { tag := i.tag tag.ID = id - err := i.ReaderWriter.Update(ctx, &tag) + err := i.ReaderWriter.Update(ctx, &models.UpdateTagInput{ + Tag: &tag, + CustomFields: models.CustomFieldsInput{ + Full: i.customFields, + }, + }) if err != nil { return fmt.Errorf("error updating existing tag: %v", err) } @@ -157,7 +176,9 @@ func (i *Importer) createParent(ctx context.Context, name string) (int, error) { newTag := models.NewTag() newTag.Name = name - err := i.ReaderWriter.Create(ctx, &newTag) + err := i.ReaderWriter.Create(ctx, &models.CreateTagInput{ + Tag: &newTag, + }) if err != nil { return 0, err } diff --git a/pkg/tag/import_test.go b/pkg/tag/import_test.go index b706c4937..f6eaec88a 100644 --- a/pkg/tag/import_test.go +++ b/pkg/tag/import_test.go @@ -154,14 +154,14 @@ func TestImporterPostImportParentMissing(t *testing.T) { db.Tag.On("UpdateParentTags", testCtx, ignoreID, emptyParents).Return(nil).Once() db.Tag.On("UpdateParentTags", testCtx, ignoreFoundID, []int{103}).Return(nil).Once() - db.Tag.On("Create", testCtx, mock.MatchedBy(func(t *models.Tag) bool { - return t.Name == "Create" + db.Tag.On("Create", testCtx, mock.MatchedBy(func(input *models.CreateTagInput) bool { + return input.Tag.Name == "Create" })).Run(func(args mock.Arguments) { - t := args.Get(1).(*models.Tag) - t.ID = 100 + input := args.Get(1).(*models.CreateTagInput) + input.Tag.ID = 100 }).Return(nil).Once() - db.Tag.On("Create", testCtx, mock.MatchedBy(func(t *models.Tag) bool { - return t.Name == "CreateError" + db.Tag.On("Create", testCtx, mock.MatchedBy(func(input *models.CreateTagInput) bool { + return input.Tag.Name == "CreateError" })).Return(errors.New("failed creating parent")).Once() i.MissingRefBehaviour = models.ImportMissingRefEnumCreate @@ -261,11 +261,15 @@ func TestCreate(t *testing.T) { } errCreate := errors.New("Create error") - db.Tag.On("Create", testCtx, &tag).Run(func(args mock.Arguments) { - t := args.Get(1).(*models.Tag) - t.ID = tagID + db.Tag.On("Create", testCtx, mock.MatchedBy(func(input *models.CreateTagInput) bool { + return input.Tag.Name == tag.Name + })).Run(func(args mock.Arguments) { + input := args.Get(1).(*models.CreateTagInput) + input.Tag.ID = tagID }).Return(nil).Once() - db.Tag.On("Create", testCtx, &tagErr).Return(errCreate).Once() + db.Tag.On("Create", testCtx, mock.MatchedBy(func(input *models.CreateTagInput) bool { + return input.Tag.Name == tagErr.Name + })).Return(errCreate).Once() id, err := i.Create(testCtx) assert.Equal(t, tagID, *id) @@ -299,7 +303,10 @@ func TestUpdate(t *testing.T) { // id needs to be set for the mock input tag.ID = tagID - db.Tag.On("Update", testCtx, &tag).Return(nil).Once() + tagInput := models.UpdateTagInput{ + Tag: &tag, + } + db.Tag.On("Update", testCtx, &tagInput).Return(nil).Once() err := i.Update(testCtx, tagID) assert.Nil(t, err) @@ -308,7 +315,10 @@ func TestUpdate(t *testing.T) { // need to set id separately tagErr.ID = errImageID - db.Tag.On("Update", testCtx, &tagErr).Return(errUpdate).Once() + errInput := models.UpdateTagInput{ + Tag: &tagErr, + } + db.Tag.On("Update", testCtx, &errInput).Return(errUpdate).Once() err = i.Update(testCtx, errImageID) assert.NotNil(t, err) From 2b38361a26f516825c734fb13ae52f8d70c10b3e Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 6 Feb 2026 12:36:56 +1100 Subject: [PATCH 053/177] Revamp performer list with sidebar (#6547) * Add favourite filter * Add gender sidebar filter * Remove new performer button from navbar --- .../GroupDetails/GroupPerformersPanel.tsx | 4 +- .../List/Filters/LabeledIdFilter.tsx | 2 +- .../components/List/Filters/OptionFilter.tsx | 151 +++- .../components/List/ListOperationButtons.tsx | 2 +- ui/v2.5/src/components/MainNavbar.tsx | 1 - .../performerAppearsWithPanel.tsx | 4 +- .../components/Performers/PerformerList.tsx | 674 +++++++++++++----- .../src/components/Performers/Performers.tsx | 4 +- .../StudioDetails/StudioPerformersPanel.tsx | 4 +- .../Tags/TagDetails/TagPerformersPanel.tsx | 4 +- 10 files changed, 638 insertions(+), 212 deletions(-) diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupPerformersPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupPerformersPanel.tsx index 057b99f2a..3ec78084a 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupPerformersPanel.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupPerformersPanel.tsx @@ -1,7 +1,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useGroupFilterHook } from "src/core/groups"; -import { PerformerList } from "src/components/Performers/PerformerList"; +import { FilteredPerformerList } from "src/components/Performers/PerformerList"; import { View } from "src/components/List/views"; interface IGroupPerformersPanel { @@ -18,7 +18,7 @@ export const GroupPerformersPanel: React.FC = ({ const filterHook = useGroupFilterHook(group, showChildGroupContent); return ( - = ({ ); }; -type ModifierValue = "any" | "none" | "any_of" | "only" | "include_subs"; +export type ModifierValue = "any" | "none" | "any_of" | "only" | "include_subs"; export function getModifierCandidates(props: { modifier: CriterionModifier; diff --git a/ui/v2.5/src/components/List/Filters/OptionFilter.tsx b/ui/v2.5/src/components/List/Filters/OptionFilter.tsx index d9cfaf733..6753df09d 100644 --- a/ui/v2.5/src/components/List/Filters/OptionFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/OptionFilter.tsx @@ -1,10 +1,20 @@ import cloneDeep from "lodash-es/cloneDeep"; -import React from "react"; +import React, { useMemo } from "react"; import { Form } from "react-bootstrap"; import { CriterionValue, ModifierCriterion, + ModifierCriterionOption, } from "src/models/list-filter/criteria/criterion"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { Option, SidebarListFilter } from "./SidebarListFilter"; +import { CriterionModifier } from "src/core/generated-graphql"; +import { + getModifierCandidates, + ModifierValue, + modifierValueToModifier, +} from "./LabeledIdFilter"; +import { useIntl } from "react-intl"; interface IOptionsFilter { criterion: ModifierCriterion; @@ -83,3 +93,142 @@ export const OptionListFilter: React.FC = ({
); }; + +interface ISidebarFilter { + title?: React.ReactNode; + option: ModifierCriterionOption; + filter: ListFilterModel; + setFilter: (f: ListFilterModel) => void; + sectionID?: string; +} + +export const SidebarOptionFilter: React.FC = ({ + title, + option, + filter, + setFilter, + sectionID, +}) => { + const intl = useIntl(); + + const criteria = filter.criteriaFor( + option.type + ) as ModifierCriterion[]; + const criterion = criteria.length > 0 ? criteria[0] : null; + const { options: criterionOptions = [] } = option; + const currentValues = criteria.flatMap((c) => c.value as string[]); + + const hasNullModifiers = + option.modifierOptions.includes(CriterionModifier.IsNull) && + option.modifierOptions.includes(CriterionModifier.NotNull); + + const selected: Option[] = useMemo(() => { + if (!criterion) return []; + + if (criterion.modifier === CriterionModifier.IsNull) { + return [ + { + id: "none", + label: intl.formatMessage({ id: "criterion_modifier_values.none" }), + }, + ]; + } else if (criterion.modifier === CriterionModifier.NotNull) { + return [ + { + id: "any", + label: intl.formatMessage({ id: "criterion_modifier_values.any" }), + }, + ]; + } + + return criterionOptions + .filter((o) => currentValues.includes(o.toString())) + .map((o) => ({ + id: o.toString(), + label: o.toLocaleString(), + })); + }, [criterion, currentValues, criterionOptions, intl]); + + const modifierCandidates: Option[] = useMemo(() => { + if (!hasNullModifiers) return []; + + const c = getModifierCandidates({ + modifier: criterion?.modifier ?? option.defaultModifier, + defaultModifier: option.defaultModifier, + hasExcluded: false, + hasSelected: selected.length > 0, + singleValue: true, // so that it doesn't include any_of + }); + + return c.map((v) => { + const messageID = `criterion_modifier_values.${v}`; + + return { + id: v, + label: `(${intl.formatMessage({ + id: messageID, + })})`, + className: "modifier-object", + canExclude: false, + }; + }); + }, [criterion, option, selected, hasNullModifiers, intl]); + + const options = useMemo(() => { + const o = criterionOptions + .filter((oo) => !currentValues.includes(oo.toString())) + .map((oo) => ({ + id: oo.toString(), + label: oo.toString(), + })); + + return [...modifierCandidates, ...o]; + }, [criterionOptions, currentValues, modifierCandidates]); + + function onSelect(item: Option) { + const newCriterion = criterion ? criterion.clone() : option.makeCriterion(); + + if (item.className === "modifier-object") { + newCriterion.modifier = modifierValueToModifier(item.id as ModifierValue); + newCriterion.value = []; + setFilter(filter.replaceCriteria(option.type, [newCriterion])); + return; + } + + const cv = newCriterion.value as string[]; + if (cv.includes(item.id)) { + return; + } else { + newCriterion.value = [...cv, item.id]; + } + + setFilter(filter.replaceCriteria(option.type, [newCriterion])); + } + + function onUnselect(item: Option) { + if (item.className === "modifier-object") { + const newCriterion = criterion + ? criterion.clone() + : option.makeCriterion(); + newCriterion.modifier = option.defaultModifier; + setFilter(filter.replaceCriteria(option.type, [newCriterion])); + return; + } + + setFilter(filter.removeCriterion(option.type)); + } + + return ( + <> + + + ); +}; diff --git a/ui/v2.5/src/components/List/ListOperationButtons.tsx b/ui/v2.5/src/components/List/ListOperationButtons.tsx index 314c28bf8..c214a947a 100644 --- a/ui/v2.5/src/components/List/ListOperationButtons.tsx +++ b/ui/v2.5/src/components/List/ListOperationButtons.tsx @@ -268,7 +268,7 @@ export const ListOperationButtons: React.FC = ({ ); }; -interface IListOperations { +export interface IListOperations { text: string; onClick: () => void; isDisplayed?: () => boolean; diff --git a/ui/v2.5/src/components/MainNavbar.tsx b/ui/v2.5/src/components/MainNavbar.tsx index ac1be2c13..a73a3078b 100644 --- a/ui/v2.5/src/components/MainNavbar.tsx +++ b/ui/v2.5/src/components/MainNavbar.tsx @@ -139,7 +139,6 @@ const allMenuItems: IMenuItem[] = [ href: "/performers", icon: faUser, hotkey: "g p", - userCreatable: true, }, { name: "studios", diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/performerAppearsWithPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/performerAppearsWithPanel.tsx index 913d16625..b6973bf83 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/performerAppearsWithPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/performerAppearsWithPanel.tsx @@ -1,6 +1,6 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; -import { PerformerList } from "src/components/Performers/PerformerList"; +import { FilteredPerformerList } from "src/components/Performers/PerformerList"; import { usePerformerFilterHook } from "src/core/performers"; import { View } from "src/components/List/views"; import { PatchComponent } from "src/patch"; @@ -24,7 +24,7 @@ export const PerformerAppearsWithPanel: React.FC = const filterHook = usePerformerFilterHook(performer); return ( - { const intl = useIntl(); @@ -165,193 +187,302 @@ interface IPerformerList { extraOperations?: IItemListOperation[]; } -export const PerformerList: React.FC = PatchComponent( +const PerformerList: React.FC<{ + performers: GQL.PerformerDataFragment[]; + filter: ListFilterModel; + selectedIds: Set; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; + extraCriteria?: IPerformerCardExtraCriteria; +}> = PatchComponent( "PerformerList", - ({ filterHook, view, alterQuery, extraCriteria, extraOperations = [] }) => { + ({ performers, filter, selectedIds, onSelectChange, extraCriteria }) => { + if (performers.length === 0) { + return null; + } + + if (filter.displayMode === DisplayMode.Grid) { + return ( + + ); + } + if (filter.displayMode === DisplayMode.List) { + return ( + + ); + } + if (filter.displayMode === DisplayMode.Tagger) { + return ; + } + + return null; + } +); + +const PerformerFilterSidebarSections = PatchContainerComponent( + "FilteredPerformerList.SidebarSections" +); + +const SidebarContent: React.FC<{ + filter: ListFilterModel; + setFilter: (filter: ListFilterModel) => void; + filterHook?: (filter: ListFilterModel) => ListFilterModel; + view?: View; + sidebarOpen: boolean; + onClose?: () => void; + showEditFilter: (editingCriterion?: string) => void; + count?: number; + focus?: ReturnType; +}> = ({ + filter, + setFilter, + filterHook, + view, + showEditFilter, + sidebarOpen, + onClose, + count, + focus, +}) => { + const showResultsId = + count !== undefined ? "actions.show_count_results" : "actions.show_results"; + + const AgeCriterionOption = PerformerListFilterOptions.criterionOptions.find( + (c) => c.type === "age" + ); + + return ( + <> + + + + + + } + data-type={FavoritePerformerCriterionOption.type} + option={FavoritePerformerCriterionOption} + filter={filter} + setFilter={setFilter} + sectionID="favourite" + /> + } + option={GenderCriterionOption} + filter={filter} + setFilter={setFilter} + sectionID="gender" + /> + } + option={AgeCriterionOption!} + filter={filter} + setFilter={setFilter} + sectionID="age" + /> + + +
+ +
+ + ); +}; + +function useViewRandom(filter: ListFilterModel, count: number) { + const history = useHistory(); + + const viewRandom = useCallback(async () => { + // query for a random performer + if (count === 0) { + return; + } + + const index = Math.floor(Math.random() * count); + const filterCopy = cloneDeep(filter); + filterCopy.itemsPerPage = 1; + filterCopy.currentPage = index + 1; + const singleResult = await queryFindPerformers(filterCopy); + if (singleResult.data.findPerformers.performers.length === 1) { + const { id } = singleResult.data.findPerformers.performers[0]; + // navigate to the image player page + history.push(`/performers/${id}`); + } + }, [history, filter, count]); + + return viewRandom; +} + +function useAddKeybinds(filter: ListFilterModel, count: number) { + const viewRandom = useViewRandom(filter, count); + + useEffect(() => { + Mousetrap.bind("p r", () => { + viewRandom(); + }); + + return () => { + Mousetrap.unbind("p r"); + }; + }, [viewRandom]); +} + +export const FilteredPerformerList = PatchComponent( + "FilteredPerformerList", + (props: IPerformerList) => { const intl = useIntl(); const history = useHistory(); - const [mergePerformers, setMergePerformers] = useState< - GQL.SelectPerformerDataFragment[] | undefined - >(undefined); - const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); - const [isExportAll, setIsExportAll] = useState(false); + const location = useLocation(); - const filterMode = GQL.FilterMode.Performers; + const searchFocus = useFocus(); - const otherOperations = [ - ...extraOperations, - { - text: intl.formatMessage({ id: "actions.open_random" }), - onClick: openRandom, - }, - { - text: `${intl.formatMessage({ id: "actions.merge" })}…`, - onClick: merge, - isDisplayed: showWhenSelected, - }, - { - text: intl.formatMessage({ id: "actions.export" }), - onClick: onExport, - isDisplayed: showWhenSelected, - }, - { - text: intl.formatMessage({ id: "actions.export_all" }), - onClick: onExportAll, - }, - ]; + const { + filterHook, + view, + alterQuery, + extraCriteria, + extraOperations = [], + } = props; - function addKeybinds( - result: GQL.FindPerformersQueryResult, - filter: ListFilterModel - ) { - Mousetrap.bind("p r", () => { - openRandom(result, filter); + // States + const { + showSidebar, + setShowSidebar, + sectionOpen, + setSectionOpen, + loading: sidebarStateLoading, + } = useSidebarState(view); + + const { filterState, queryResult, modalState, listSelect, showEditFilter } = + useFilteredItemList({ + filterStateProps: { + filterMode: GQL.FilterMode.Performers, + view, + useURL: alterQuery, + }, + queryResultProps: { + useResult: useFindPerformers, + getCount: (r) => r.data?.findPerformers.count ?? 0, + getItems: (r) => r.data?.findPerformers.performers ?? [], + filterHook, + }, + }); + + const { filter, setFilter } = filterState; + + const { effectiveFilter, result, cachedResult, items, totalCount } = + queryResult; + + const { + selectedIds, + selectedItems, + onSelectChange, + onSelectAll, + onSelectNone, + onInvertSelection, + hasSelection, + } = listSelect; + + const { modal, showModal, closeModal } = modalState; + + // Utility hooks + const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({ + filter, + setFilter, + }); + + useAddKeybinds(filter, totalCount); + useFilteredSidebarKeybinds({ + showSidebar, + setShowSidebar, + }); + + useEffect(() => { + Mousetrap.bind("e", () => { + if (hasSelection) { + onEdit?.(); + } + }); + + Mousetrap.bind("d d", () => { + if (hasSelection) { + onDelete?.(); + } }); return () => { - Mousetrap.unbind("p r"); + Mousetrap.unbind("e"); + Mousetrap.unbind("d d"); }; - } + }); - async function openRandom( - result: GQL.FindPerformersQueryResult, - filter: ListFilterModel - ) { - if (result.data?.findPerformers) { - const { count } = result.data.findPerformers; - const index = Math.floor(Math.random() * count); - const filterCopy = cloneDeep(filter); - filterCopy.itemsPerPage = 1; - filterCopy.currentPage = index + 1; - const singleResult = await queryFindPerformers(filterCopy); - if (singleResult.data.findPerformers.performers.length === 1) { - const { id } = singleResult.data.findPerformers.performers[0]!; - history.push(`/performers/${id}`); - } + const onCloseEditDelete = useCloseEditDelete({ + closeModal, + onSelectNone, + result, + }); + + function onCreateNew() { + let queryParam = new URLSearchParams(location.search).get("q"); + let newPath = "/performers/new"; + if (queryParam) { + newPath += "?q=" + encodeURIComponent(queryParam); } + history.push(newPath); } - async function merge( - result: GQL.FindPerformersQueryResult, - filter: ListFilterModel, - selectedIds: Set - ) { - const selected = - result.data?.findPerformers.performers.filter((p) => - selectedIds.has(p.id) - ) ?? []; - setMergePerformers(selected); - } + const viewRandom = useViewRandom(filter, totalCount); - async function onExport() { - setIsExportAll(false); - setIsExportDialogOpen(true); - } - - async function onExportAll() { - setIsExportAll(true); - setIsExportDialogOpen(true); - } - - function renderContent( - result: GQL.FindPerformersQueryResult, - filter: ListFilterModel, - selectedIds: Set, - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void - ) { - function renderMergeDialog() { - if (mergePerformers) { - return ( - { - setMergePerformers(undefined); - if (mergedId) { - history.push(`/performers/${mergedId}`); - } - }} - show - /> - ); - } - } - - function maybeRenderPerformerExportDialog() { - if (isExportDialogOpen) { - return ( - <> - setIsExportDialogOpen(false)} - /> - - ); - } - } - - function renderPerformers() { - if (!result.data?.findPerformers) return; - - if (filter.displayMode === DisplayMode.Grid) { - return ( - - ); - } - if (filter.displayMode === DisplayMode.List) { - return ( - - ); - } - if (filter.displayMode === DisplayMode.Tagger) { - return ( - - ); - } - } - - return ( - <> - {renderMergeDialog()} - {maybeRenderPerformerExportDialog()} - {renderPerformers()} - + function onExport(all: boolean) { + showModal( + closeModal()} + /> ); } - function renderEditDialog( - selectedPerformers: GQL.SlimPerformerDataFragment[], - onClose: (applied: boolean) => void - ) { - return ( - + function onEdit() { + showModal( + ); } - function renderDeleteDialog( - selectedPerformers: GQL.SlimPerformerDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ( + function onDelete() { + showModal( = PatchComponent( ); } - return ( - - { + closeModal(); + if (mergedId) { + history.push(`/performers/${mergedId}`); + } + }} + show /> - + ); + } + + const convertedExtraOperations: IListOperations[] = extraOperations.map( + (o) => ({ + ...o, + isDisplayed: o.isDisplayed + ? () => o.isDisplayed!(result, filter, selectedIds) + : undefined, + onClick: () => { + o.onClick(result, filter, selectedIds); + }, + }) + ); + + const otherOperations: IListOperations[] = [ + ...convertedExtraOperations, + { + text: intl.formatMessage({ id: "actions.select_all" }), + onClick: () => onSelectAll(), + isDisplayed: () => totalCount > 0, + }, + { + text: intl.formatMessage({ id: "actions.select_none" }), + onClick: () => onSelectNone(), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.invert_selection" }), + onClick: () => onInvertSelection(), + isDisplayed: () => totalCount > 0, + }, + { + text: intl.formatMessage({ id: "actions.open_random" }), + onClick: viewRandom, + }, + { + text: `${intl.formatMessage({ id: "actions.merge" })}…`, + onClick: onMerge, + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.export" }), + onClick: () => onExport(false), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.export_all" }), + onClick: () => onExport(true), + }, + ]; + + // render + if (sidebarStateLoading) return null; + + const operations = ( + + ); + + return ( +
+ {modal} + + + + setShowSidebar(false)}> + setShowSidebar(false)} + count={cachedResult.loading ? undefined : totalCount} + focus={searchFocus} + /> + + setShowSidebar(!showSidebar)} + > + + + showEditFilter(c.criterionOption.type)} + onRemoveCriterion={removeCriterion} + onRemoveAll={clearAllCriteria} + /> + +
+ setFilter(filter.changePage(page))} + /> + +
+ + + + + + {totalCount > filter.itemsPerPage && ( +
+
+ +
+
+ )} +
+
+
+
); } ); diff --git a/ui/v2.5/src/components/Performers/Performers.tsx b/ui/v2.5/src/components/Performers/Performers.tsx index d240ce988..7b6e32b8f 100644 --- a/ui/v2.5/src/components/Performers/Performers.tsx +++ b/ui/v2.5/src/components/Performers/Performers.tsx @@ -4,11 +4,11 @@ import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; import Performer from "./PerformerDetails/Performer"; import PerformerCreate from "./PerformerDetails/PerformerCreate"; -import { PerformerList } from "./PerformerList"; +import { FilteredPerformerList } from "./PerformerList"; import { View } from "../List/views"; const Performers: React.FC = () => { - return ; + return ; }; const PerformerRoutes: React.FC = () => { diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx index 329ac5bc8..551632266 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx @@ -1,7 +1,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useStudioFilterHook } from "src/core/studios"; -import { PerformerList } from "src/components/Performers/PerformerList"; +import { FilteredPerformerList } from "src/components/Performers/PerformerList"; import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; import { View } from "src/components/List/views"; @@ -33,7 +33,7 @@ export const StudioPerformersPanel: React.FC = ({ const filterHook = useStudioFilterHook(studio, showChildStudioContent); return ( - = ({ }) => { const filterHook = useTagFilterHook(tag, showSubTagContent); return ( - Date: Fri, 6 Feb 2026 12:37:38 +1100 Subject: [PATCH 054/177] Revamp studio list with sidebar (#6549) * Add studios_filter to TagFilterType * Convert studio list to use sidebar --- graphql/schema/types/filters.graphql | 2 + pkg/models/tag.go | 2 + pkg/sqlite/tag.go | 9 + pkg/sqlite/tag_filter.go | 9 + .../List/Filters/LabeledIdFilter.tsx | 19 +- .../StudioDetails/StudioChildrenPanel.tsx | 4 +- ui/v2.5/src/components/Studios/StudioList.tsx | 565 +++++++++++++----- ui/v2.5/src/components/Studios/Studios.tsx | 4 +- .../Tags/TagDetails/TagStudiosPanel.tsx | 4 +- 9 files changed, 461 insertions(+), 157 deletions(-) diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index f89cee3e2..04a28171c 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -644,6 +644,8 @@ input TagFilterType { galleries_filter: GalleryFilterType "Filter by related performers that meet this criteria" performers_filter: PerformerFilterType + "Filter by related studios that meet this criteria" + studios_filter: StudioFilterType "Filter by creation time" created_at: TimestampCriterionInput diff --git a/pkg/models/tag.go b/pkg/models/tag.go index 0f39d8861..bfb3f1ad3 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -52,6 +52,8 @@ type TagFilterType struct { GalleriesFilter *GalleryFilterType `json:"galleries_filter"` // Filter by related performers that meet this criteria PerformersFilter *PerformerFilterType `json:"performers_filter"` + // Filter by related studios that meet this criteria + StudiosFilter *StudioFilterType `json:"studios_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index ea18664d9..8a0561b0f 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -108,6 +108,7 @@ type tagRepositoryType struct { images joinRepository galleries joinRepository performers joinRepository + studios joinRepository } var ( @@ -161,6 +162,14 @@ var ( fkColumn: performerIDColumn, foreignTable: performerTable, }, + studios: joinRepository{ + repository: repository{ + tableName: studiosTagsTable, + idColumn: tagIDColumn, + }, + fkColumn: studioIDColumn, + foreignTable: studioTable, + }, } ) diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index 2f4e79149..92da1237c 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -143,6 +143,15 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler { tagRepository.performers.innerJoin(f, "", "tags.id") }, }, + + &relatedFilterHandler{ + relatedIDCol: "studios_tags.studio_id", + relatedRepo: studioRepository.repository, + relatedHandler: &studioFilterHandler{tagFilter.StudiosFilter}, + joinFn: func(f *filterBuilder) { + tagRepository.studios.innerJoin(f, "", "tags.id") + }, + }, } } diff --git a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx index d621d85bd..a5e81087e 100644 --- a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx @@ -23,6 +23,7 @@ import { IntCriterionInput, PerformerFilterType, SceneFilterType, + StudioFilterType, } from "src/core/generated-graphql"; import { useIntl } from "react-intl"; @@ -521,12 +522,18 @@ interface IFilterType { performer_count?: InputMaybe; galleries_filter?: InputMaybe; gallery_count?: InputMaybe; + studios_filter?: InputMaybe; + studio_count?: InputMaybe; } export function setObjectFilter( out: IFilterType, mode: FilterMode, - relatedFilterOutput: SceneFilterType | PerformerFilterType | GalleryFilterType + relatedFilterOutput: + | SceneFilterType + | PerformerFilterType + | GalleryFilterType + | StudioFilterType ) { const empty = Object.keys(relatedFilterOutput).length === 0; @@ -561,6 +568,16 @@ export function setObjectFilter( } out.galleries_filter = relatedFilterOutput as GalleryFilterType; break; + case FilterMode.Studios: + // if empty, only get objects with studios + if (empty) { + out.studio_count = { + modifier: CriterionModifier.GreaterThan, + value: 0, + }; + } + out.studios_filter = relatedFilterOutput as StudioFilterType; + break; default: throw new Error("Invalid filter mode"); } diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx index b6cd8b484..a69364a89 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx @@ -2,7 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { ParentStudiosCriterion } from "src/models/list-filter/criteria/studios"; import { ListFilterModel } from "src/models/list-filter/filter"; -import { StudioList } from "../StudioList"; +import { FilteredStudioList } from "../StudioList"; import { View } from "src/components/List/views"; function useFilterHook(studio: GQL.StudioDataFragment) { @@ -51,7 +51,7 @@ export const StudioChildrenPanel: React.FC = ({ const filterHook = useFilterHook(studio); return ( - ; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; + fromParent?: boolean; +}> = PatchComponent( + "StudioList", + ({ studios, filter, selectedIds, onSelectChange, fromParent }) => { + if (studios.length === 0) { + return null; + } -function getCount(result: GQL.FindStudiosQueryResult) { - return result?.data?.findStudios?.count ?? 0; -} + if (filter.displayMode === DisplayMode.Grid) { + return ( + + ); + } + if (filter.displayMode === DisplayMode.List) { + return

TODO

; + } + if (filter.displayMode === DisplayMode.Wall) { + return

TODO

; + } + if (filter.displayMode === DisplayMode.Tagger) { + return ; + } + + return null; + } +); + +const StudioFilterSidebarSections = PatchContainerComponent( + "FilteredStudioList.SidebarSections" +); + +const SidebarContent: React.FC<{ + filter: ListFilterModel; + setFilter: (filter: ListFilterModel) => void; + filterHook?: (filter: ListFilterModel) => ListFilterModel; + view?: View; + sidebarOpen: boolean; + onClose?: () => void; + showEditFilter: (editingCriterion?: string) => void; + count?: number; + focus?: ReturnType; +}> = ({ + filter, + setFilter, + filterHook, + view, + showEditFilter, + sidebarOpen, + onClose, + count, + focus, +}) => { + const showResultsId = + count !== undefined ? "actions.show_count_results" : "actions.show_results"; + + return ( + <> + + + + + + } + filter={filter} + setFilter={setFilter} + option={FavoriteStudioCriterionOption} + sectionID="favourite" + /> + + +
+ +
+ + ); +}; interface IStudioList { fromParent?: boolean; @@ -37,147 +157,172 @@ interface IStudioList { extraOperations?: IItemListOperation[]; } -export const StudioList: React.FC = PatchComponent( - "StudioList", - ({ fromParent, filterHook, view, alterQuery, extraOperations = [] }) => { +function useViewRandom(filter: ListFilterModel, count: number) { + const history = useHistory(); + + const viewRandom = useCallback(async () => { + // query for a random studio + if (count === 0) { + return; + } + + const index = Math.floor(Math.random() * count); + const filterCopy = cloneDeep(filter); + filterCopy.itemsPerPage = 1; + filterCopy.currentPage = index + 1; + const singleResult = await queryFindStudios(filterCopy); + if (singleResult.data.findStudios.studios.length === 1) { + const { id } = singleResult.data.findStudios.studios[0]; + // navigate to the studio page + history.push(`/studios/${id}`); + } + }, [history, filter, count]); + + return viewRandom; +} + +function useAddKeybinds(filter: ListFilterModel, count: number) { + const viewRandom = useViewRandom(filter, count); + + useEffect(() => { + Mousetrap.bind("p r", () => { + viewRandom(); + }); + + return () => { + Mousetrap.unbind("p r"); + }; + }, [viewRandom]); +} + +export const FilteredStudioList = PatchComponent( + "FilteredStudioList", + (props: IStudioList) => { const intl = useIntl(); const history = useHistory(); - const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); - const [isExportAll, setIsExportAll] = useState(false); + const location = useLocation(); - const filterMode = GQL.FilterMode.Studios; + const searchFocus = useFocus(); - const otherOperations = [ - ...extraOperations, - { - text: intl.formatMessage({ id: "actions.view_random" }), - onClick: viewRandom, - }, - { - text: intl.formatMessage({ id: "actions.export" }), - onClick: onExport, - isDisplayed: showWhenSelected, - }, - { - text: intl.formatMessage({ id: "actions.export_all" }), - onClick: onExportAll, - }, - ]; + const { filterHook, view, alterQuery, extraOperations = [] } = props; - function addKeybinds( - result: GQL.FindStudiosQueryResult, - filter: ListFilterModel - ) { - Mousetrap.bind("p r", () => { - viewRandom(result, filter); + // States + const { + showSidebar, + setShowSidebar, + sectionOpen, + setSectionOpen, + loading: sidebarStateLoading, + } = useSidebarState(view); + + const { filterState, queryResult, modalState, listSelect, showEditFilter } = + useFilteredItemList({ + filterStateProps: { + filterMode: GQL.FilterMode.Studios, + view, + useURL: alterQuery, + }, + queryResultProps: { + useResult: useFindStudios, + getCount: (r) => r.data?.findStudios.count ?? 0, + getItems: (r) => r.data?.findStudios.studios ?? [], + filterHook, + }, + }); + + const { filter, setFilter } = filterState; + + const { effectiveFilter, result, cachedResult, items, totalCount } = + queryResult; + + const { + selectedIds, + selectedItems, + onSelectChange, + onSelectAll, + onSelectNone, + onInvertSelection, + hasSelection, + } = listSelect; + + const { modal, showModal, closeModal } = modalState; + + // Utility hooks + const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({ + filter, + setFilter, + }); + + useAddKeybinds(filter, totalCount); + useFilteredSidebarKeybinds({ + showSidebar, + setShowSidebar, + }); + + useEffect(() => { + Mousetrap.bind("e", () => { + if (hasSelection) { + onEdit?.(); + } + }); + + Mousetrap.bind("d d", () => { + if (hasSelection) { + onDelete?.(); + } }); return () => { - Mousetrap.unbind("p r"); + Mousetrap.unbind("e"); + Mousetrap.unbind("d d"); }; - } + }); - async function viewRandom( - result: GQL.FindStudiosQueryResult, - filter: ListFilterModel - ) { - // query for a random studio - if (result.data?.findStudios) { - const { count } = result.data.findStudios; + const onCloseEditDelete = useCloseEditDelete({ + closeModal, + onSelectNone, + result, + }); - const index = Math.floor(Math.random() * count); - const filterCopy = cloneDeep(filter); - filterCopy.itemsPerPage = 1; - filterCopy.currentPage = index + 1; - const singleResult = await queryFindStudios(filterCopy); - if (singleResult.data.findStudios.studios.length === 1) { - const { id } = singleResult.data.findStudios.studios[0]; - // navigate to the studio page - history.push(`/studios/${id}`); - } + function onCreateNew() { + let queryParam = new URLSearchParams(location.search).get("q"); + let newPath = "/studios/new"; + if (queryParam) { + newPath += "?q=" + encodeURIComponent(queryParam); } + history.push(newPath); } - async function onExport() { - setIsExportAll(false); - setIsExportDialogOpen(true); - } + const viewRandom = useViewRandom(filter, totalCount); - async function onExportAll() { - setIsExportAll(true); - setIsExportDialogOpen(true); - } - - function renderContent( - result: GQL.FindStudiosQueryResult, - filter: ListFilterModel, - selectedIds: Set, - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void - ) { - function maybeRenderExportDialog() { - if (isExportDialogOpen) { - return ( - setIsExportDialogOpen(false)} - /> - ); - } - } - - function renderStudios() { - if (!result.data?.findStudios) return; - - if (filter.displayMode === DisplayMode.Grid) { - return ( - - ); - } - if (filter.displayMode === DisplayMode.List) { - return

TODO

; - } - if (filter.displayMode === DisplayMode.Wall) { - return

TODO

; - } - if (filter.displayMode === DisplayMode.Tagger) { - return ; - } - } - - return ( - <> - {maybeRenderExportDialog()} - {renderStudios()} - + function onExport(all: boolean) { + showModal( + closeModal()} + /> ); } - function renderEditDialog( - selectedStudios: GQL.SlimStudioDataFragment[], - onClose: (applied: boolean) => void - ) { - return ; + function onEdit() { + showModal( + + ); } - function renderDeleteDialog( - selectedStudios: GQL.SlimStudioDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ( + function onDelete() { + showModal( = PatchComponent( ); } + const convertedExtraOperations = extraOperations.map((op) => ({ + text: op.text, + onClick: () => op.onClick(result, filter, selectedIds), + isDisplayed: () => op.isDisplayed?.(result, filter, selectedIds) ?? true, + })); + + const otherOperations = [ + ...convertedExtraOperations, + { + text: intl.formatMessage({ id: "actions.select_all" }), + onClick: () => onSelectAll(), + isDisplayed: () => totalCount > 0, + }, + { + text: intl.formatMessage({ id: "actions.select_none" }), + onClick: () => onSelectNone(), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.invert_selection" }), + onClick: () => onInvertSelection(), + isDisplayed: () => totalCount > 0, + }, + { + text: intl.formatMessage({ id: "actions.view_random" }), + onClick: viewRandom, + }, + { + text: intl.formatMessage({ id: "actions.export" }), + onClick: () => onExport(false), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.export_all" }), + onClick: () => onExport(true), + }, + ]; + + // render + if (sidebarStateLoading) return null; + + const operations = ( + + ); + return ( - - - + {modal} + + + + setShowSidebar(false)}> + setShowSidebar(false)} + count={cachedResult.loading ? undefined : totalCount} + focus={searchFocus} + /> + + setShowSidebar(!showSidebar)} + > + + + showEditFilter(c.criterionOption.type)} + onRemoveCriterion={removeCriterion} + onRemoveAll={clearAllCriteria} + /> + +
+ setFilter(filter.changePage(page))} + /> + +
+ + + + + + {totalCount > filter.itemsPerPage && ( +
+
+ +
+
+ )} +
+
+
+
); } ); diff --git a/ui/v2.5/src/components/Studios/Studios.tsx b/ui/v2.5/src/components/Studios/Studios.tsx index 545de936f..956531fe0 100644 --- a/ui/v2.5/src/components/Studios/Studios.tsx +++ b/ui/v2.5/src/components/Studios/Studios.tsx @@ -4,11 +4,11 @@ import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; import Studio from "./StudioDetails/Studio"; import StudioCreate from "./StudioDetails/StudioCreate"; -import { StudioList } from "./StudioList"; +import { FilteredStudioList } from "./StudioList"; import { View } from "../List/views"; const Studios: React.FC = () => { - return ; + return ; }; const StudioRoutes: React.FC = () => { diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx index 72d150b42..045d55481 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx @@ -1,7 +1,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useTagFilterHook } from "src/core/tags"; -import { StudioList } from "src/components/Studios/StudioList"; +import { FilteredStudioList } from "src/components/Studios/StudioList"; interface ITagStudiosPanel { active: boolean; @@ -15,5 +15,5 @@ export const TagStudiosPanel: React.FC = ({ showSubTagContent, }) => { const filterHook = useTagFilterHook(tag, showSubTagContent); - return ; + return ; }; From 8dec195c2d5f909fad98d2b59f8498185d4bb21d Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:53:04 +1100 Subject: [PATCH 055/177] Quick fix for front page card styling (#6553) --- ui/v2.5/src/components/FrontPage/styles.scss | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ui/v2.5/src/components/FrontPage/styles.scss b/ui/v2.5/src/components/FrontPage/styles.scss index 88d7f0c0a..f643e4eb6 100644 --- a/ui/v2.5/src/components/FrontPage/styles.scss +++ b/ui/v2.5/src/components/FrontPage/styles.scss @@ -492,3 +492,10 @@ color: white; opacity: 0.75; } + +// HACK: compatibility with existing behaviour after removed width from zoom-1 class +// this should really be changed to use the specific card types instead of a generic zoom-1 class, +// but this is a quick fix to prevent breaking existing styles +.recommendation-row .zoom-1 { + width: 320px; +} From 07b483038ae499c8c4f9b93609342940573e5bd4 Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Mon, 9 Feb 2026 01:55:12 +0200 Subject: [PATCH 056/177] docs: standardize letter casing in settings page (#6548) * Standardize letter casing in settings page for headings, options and buttons * Add localized messages for changelog header and select directory --- internal/manager/task_generate.go | 4 +- .../src/components/Changelog/Changelog.tsx | 4 +- .../FolderSelect/FolderSelectDialog.tsx | 4 +- ui/v2.5/src/docs/en/Manual/AutoTagging.md | 18 +- ui/v2.5/src/docs/en/Manual/Captions.md | 2 +- ui/v2.5/src/docs/en/Manual/Images.md | 6 +- ui/v2.5/src/docs/en/Manual/Interactive.md | 4 +- ui/v2.5/src/docs/en/Manual/Interface.md | 18 +- ui/v2.5/src/docs/en/Manual/Tasks.md | 18 +- .../src/docs/en/Manual/TroubleshootingMode.md | 6 +- ui/v2.5/src/locales/en-GB.json | 306 +++++++++--------- 11 files changed, 199 insertions(+), 191 deletions(-) diff --git a/internal/manager/task_generate.go b/internal/manager/task_generate.go index 2b330bcf3..cc991d5d6 100644 --- a/internal/manager/task_generate.go +++ b/internal/manager/task_generate.go @@ -221,10 +221,10 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error logMsg += fmt.Sprintf(" %d heatmaps & speeds", totals.interactiveHeatmapSpeeds) } if j.input.ClipPreviews { - logMsg += fmt.Sprintf(" %d Image Clip Previews", totals.clipPreviews) + logMsg += fmt.Sprintf(" %d image clip previews", totals.clipPreviews) } if j.input.ImageThumbnails { - logMsg += fmt.Sprintf(" %d Image Thumbnails", totals.imageThumbnails) + logMsg += fmt.Sprintf(" %d image thumbnails", totals.imageThumbnails) } if logMsg == "Generating" { logMsg = "Nothing selected to generate" diff --git a/ui/v2.5/src/components/Changelog/Changelog.tsx b/ui/v2.5/src/components/Changelog/Changelog.tsx index 97175e1c2..7e4207dce 100644 --- a/ui/v2.5/src/components/Changelog/Changelog.tsx +++ b/ui/v2.5/src/components/Changelog/Changelog.tsx @@ -256,7 +256,9 @@ const Changelog: React.FC = () => { return (
-

Changelog:

+

+ +

{releases.map((r) => ( = ({ return ( onClose()} title=""> - Select Directory + + +
**⚠️ Important:** Auto Tag only works for names that already exist in your Stash database. It does not create new Performers, Studios, or Tags. +> **⚠️ Important:** Auto tag only works for names that already exist in your Stash database. It does not create new Performers, Studios, or Tags. - 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 tag does not match performer aliases. Aliases will not be considered during matching. ### Examples (performer "Jane Doe") @@ -35,14 +35,14 @@ This task is part of the advanced settings mode. ### 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. +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 +### Ignore Auto tag flag -Performers or Tags that have Ignore Auto Tag flag added to them will be skipped by the Auto Tag task. +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. +- **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/Captions.md b/ui/v2.5/src/docs/en/Manual/Captions.md index 4e3849fac..a575f915b 100644 --- a/ui/v2.5/src/docs/en/Manual/Captions.md +++ b/ui/v2.5/src/docs/en/Manual/Captions.md @@ -15,4 +15,4 @@ Where `{language_code}` is defined by the [ISO-6399-1](https://en.wikipedia.org/ Scenes with captions can be filtered with the `captions` criterion. -> **⚠️ Note:** If the caption file was added after the scene was initially added during scan, you will need to run a Selective Scan task for it to show up. +> **⚠️ Note:** If the caption file was added after the scene was initially added during scan, you will need to run a Selective scan task for it to show up. diff --git a/ui/v2.5/src/docs/en/Manual/Images.md b/ui/v2.5/src/docs/en/Manual/Images.md index f08f5241c..5be7beba5 100644 --- a/ui/v2.5/src/docs/en/Manual/Images.md +++ b/ui/v2.5/src/docs/en/Manual/Images.md @@ -21,11 +21,11 @@ You can also manually select any image from a gallery as its cover. On the galle Images can also be clips/gifs. These are meant to be short video loops. Right now they are not possible in zipfiles. To declare video files to be images, there are two ways: -1. Deactivate video scanning for all libraries that contain clips/gifs, but keep image scanning active. Set the **Scan Video Extensions as Image Clip** option in the library section of your settings. -2. Make sure none of the file endings used by your clips/gifs are present in the **Video Extensions** and add them to the **Image Extensions** in the library section of your settings. +1. Deactivate video scanning for all libraries that contain clips/gifs, but keep image scanning active. Set the **Scan video extensions as image clips** option in the library section of your settings. +2. Make sure none of the file endings used by your clips/gifs are present in the **Video extensions** and add them to the **Image extensions** in the library section of your settings. A clip/gif will be a stillframe in the wall and grid view by default. To view the loop, you can go into the Lightbox Carousel (e.g. by clicking on an image in the wall view) or the image detail page. If you want the loop to be used as a preview on the wall and grid view, you will have to generate them. -You can do this as you scan for the new clip file by activating **Generate previews for image clips** on the scan settings, or do it after by going to the **Generated Content** section in the task section of your settings, activating **Image Clip Previews** and clicking generate. This takes a while, as the files are transcoded. +You can do this as you scan for the new clip file by activating **Generate previews for image clips** on the scan settings, or do it after by going to the **Generated Content** section in the task section of your settings, activating **Image clip previews** and clicking generate. This takes a while, as the files are transcoded. diff --git a/ui/v2.5/src/docs/en/Manual/Interactive.md b/ui/v2.5/src/docs/en/Manual/Interactive.md index 831109aab..ab12381dc 100644 --- a/ui/v2.5/src/docs/en/Manual/Interactive.md +++ b/ui/v2.5/src/docs/en/Manual/Interactive.md @@ -1,8 +1,8 @@ # Interactivity -Stash currently supports syncing with Handy devices, using funscript files. +Stash currently supports syncing with The Handy devices, using funscript files. -In order for stash to connect to your Handy device, the Handy Connection Key must be entered in Settings -> Interface. +In order for stash to connect to your Handy device, the Handy connection key must be entered in Settings -> Interface. Funscript files must be in the same directory as the matching video file and must have the same base name. For example, a funscript file for `video.mp4` must be named `video.funscript`. A scan must be run to update scenes with matching funscript files. diff --git a/ui/v2.5/src/docs/en/Manual/Interface.md b/ui/v2.5/src/docs/en/Manual/Interface.md index cf5911405..8fd89d57e 100644 --- a/ui/v2.5/src/docs/en/Manual/Interface.md +++ b/ui/v2.5/src/docs/en/Manual/Interface.md @@ -4,20 +4,20 @@ Setting the language affects the formatting of numbers and dates. -## SFW Content Mode +## SFW content mode -SFW Content Mode is used to indicate that the content being managed is _not_ adult content. +SFW content mode is used to indicate that the content being managed is _not_ adult content. -When SFW Content Mode is enabled, the following changes are made to the UI: +When SFW content mode is enabled, the following changes are made to the UI: - default performer images are changed to less adult-oriented images - certain adult-specific metadata fields are hidden (e.g. performer genital fields) - `O`-Counter is replaced with `Like`-counter -## Scene/Marker Wall Preview Type +## Scene/Marker Wall Preview type The Scene Wall and Marker pages display scene preview videos (mp4) by default. This can be changed to animated image (webp) or static image. -> **⚠️ Note:** scene/marker preview videos must be generated to see them in the applicable wall page if Video preview type is selected. Likewise, if Animated Image is selected, then Image Previews must be generated. +> **⚠️ Note:** scene/marker preview videos must be generated to see them in the applicable wall page if Video preview type is selected. Likewise, if Animated image is selected, then Image Previews must be generated. ## Show Studios as text @@ -33,7 +33,7 @@ The maximum loop duration option allows looping of shorter videos. Set this valu The "Track Activity" option allows tracking of scene play count and duration, and sets the resume point when a scene video is not finished. -The "Minimum Play Percent" gives the minimum proportion of a video that must be played before the play count of the scene is incremented. +The "Minimum play percent" gives the minimum proportion of a video that must be played before the play count of the scene is incremented. By default, when a scene has a resume point, the scene player will automatically seek to this point when the scene is played. Setting "Always start video from beginning" to true disables this behaviour. @@ -43,15 +43,15 @@ The stash UI can be customised using custom CSS. See [here](https://docs.stashap There is also a [collection of community-created themes](https://docs.stashapp.cc/themes/list/#browse-themes) available. -## Custom Javascript +## Custom JavaScript -Stash supports the injection of custom javascript to assist with theming or adding additional functionality. Be aware that bad Javascript could break the UI or worse. +Stash supports the injection of custom JavaScript to assist with theming or adding additional functionality. Be aware that bad JavaScript could break the UI or worse. ## Custom Locales The localisation strings can be customised. The master list of default (en-GB) locale strings can be found [here](https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json). The custom locale format is the same as this json file. -For example, to override the `actions.add_directory` label (which is `Add Directory` by default), you would have the following in the custom locale: +For example, to override the `actions.add_directory` label (which is `Add directory` by default), you would have the following in the custom locale: ``` { diff --git a/ui/v2.5/src/docs/en/Manual/Tasks.md b/ui/v2.5/src/docs/en/Manual/Tasks.md index 063d02277..5dd887cfe 100644 --- a/ui/v2.5/src/docs/en/Manual/Tasks.md +++ b/ui/v2.5/src/docs/en/Manual/Tasks.md @@ -16,7 +16,7 @@ The scan task accepts the following options: |--------|-------------| | Generate scene covers | Generates scene covers for video files. | | Generate previews | Generates video previews (mp4) which play when hovering over a scene. | -| Generate animated image previews* | *Accessible in Advanced Mode* - Also generate animated (webp) previews, only required when Scene/Marker Wall Preview Type is set to Animated Image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files.| +| Generate animated image previews | *Accessible in Advanced mode* - Also generate animated (webp) previews, only required when Scene/Marker Wall Preview type is set to Animated image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files.| | Generate scrubber sprites | The set of images displayed below the video player for easy navigation. | | Generate video perceptual hashes | Generates perceptual hashes for scene deduplication and identification. | | Generate thumbnails for images | Generates thumbnails for image files. | @@ -49,16 +49,16 @@ The generate task accepts the following options: |--------|-------------| | Scene covers | Generates scene covers for video files. | | Previews | Generates video previews (mp4) which play when hovering over a scene. | -| Animated image previews | *Accessible in Advanced Mode* - Generates animated previews (webp). Only required if the Preview Type is set to Animated Image. Requires Generate previews to be enabled. | -| Scene Scrubber Sprites | The set of images displayed below the video player for easy navigation. | -| Markers Previews | Generates 20 second video previews (mp4) which begin at the marker timecode. | -| Marker Animated Image Previews | *Accessible in Advanced Mode* - Also generate animated (webp) previews, only required when Scene/Marker Wall Preview Type is set to Animated Image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files. | -| Marker Screenshots | Generates static JPG images for markers. Only required if Preview Type is set to Static Image. Requires Marker Previews to be enabled. | -| Transcodes | *Accessible in Advanced Mode* - MP4 conversions of unsupported video formats. Allows direct streaming instead of live transcoding. | +| Animated image previews | *Accessible in Advanced mode* - Generates animated previews (webp). Only required if the Preview type is set to Animated image. Requires Generate previews to be enabled. | +| Scene scrubber sprites | The set of images displayed below the video player for easy navigation. | +| Marker previews | Generates 20 second video previews (mp4) which begin at the marker timecode. | +| Marker animated image previews | *Accessible in Advanced mode* - Also generate animated (webp) previews, only required when Scene/Marker Wall Preview type is set to Animated image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files. | +| Marker screenshots | Generates static JPG images for markers. Only required if Preview type is set to Static image. Requires marker previews to be enabled. | +| Transcodes | *Accessible in Advanced mode* - MP4 conversions of unsupported video formats. Allows direct streaming instead of live transcoding. | | Video Perceptual hashes (for deduplication) | Generates perceptual hashes for scene deduplication and identification. | | Generate heatmaps and speeds for interactive scenes | Generates heatmaps and speeds for interactive scenes. | -| Image Clip Previews | Generates a gif/looping video as thumbnail for image clips/gifs. | -| Image Thumbnails | Generates thumbnails for image files. | +| Image clip previews | Generates a gif/looping video as thumbnail for image clips/gifs. | +| Image thumbnails | Generates thumbnails for image files. | | Image Perceptual hashes (for deduplication) | Generates perceptual hashes for image deduplication and identification. | | Overwrite existing generated files | By default, where a generated file exists, it is not regenerated. When this flag is enabled, then the generated files are regenerated. | diff --git a/ui/v2.5/src/docs/en/Manual/TroubleshootingMode.md b/ui/v2.5/src/docs/en/Manual/TroubleshootingMode.md index d7a2c1cee..9a5ffd215 100644 --- a/ui/v2.5/src/docs/en/Manual/TroubleshootingMode.md +++ b/ui/v2.5/src/docs/en/Manual/TroubleshootingMode.md @@ -1,7 +1,7 @@ # Troubleshooting Mode -Troubleshooting Mode disables all plugins and all custom CSS, JavaScript, and locales. It also temporarily sets the log level to `DEBUG`. This is useful when you are experiencing issues with your Stash instance to eliminate the possibility that a plugin or custom code is causing the issue. +Troubleshooting mode disables all plugins and all custom CSS, JavaScript, and locales. It also temporarily sets the log level to `DEBUG`. This is useful when you are experiencing issues with your Stash instance to eliminate the possibility that a plugin or custom code is causing the issue. -Troubleshooting Mode is enabled from the Settings page, by clicking the `Troubleshooting Mode` button at the bottom left of the page. +Troubleshooting mode is enabled from the Settings page, by clicking the `Troubleshooting mode` button at the bottom left of the page. -When Troubleshooting Mode is enabled, a red border and a banner will be displayed to remind you that you are in Troubleshooting Mode. To exit Troubleshooting Mode, click the `Exit` button in the banner. \ No newline at end of file +When Troubleshooting mode is enabled, a red border and a banner will be displayed to remind you that you are in Troubleshooting mode. To exit Troubleshooting mode, click the `Exit` button in the banner. \ No newline at end of file diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 76df6cf33..a2fee0ec1 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1,7 +1,7 @@ { "actions": { "add": "Add", - "add_directory": "Add Directory", + "add_directory": "Add directory", "add_entity": "Add {entityType}", "add_manual_date": "Add manual date", "add_sub_groups": "Add Sub-Groups", @@ -14,7 +14,7 @@ "anonymise": "Anonymise", "apply": "Apply", "assign_stashid_to_parent_studio": "Assign Stash ID to existing parent studio and update metadata", - "auto_tag": "Auto Tag", + "auto_tag": "Auto tag", "backup": "Backup", "browse_for_image": "Browse for image…", "cancel": "Cancel", @@ -47,7 +47,7 @@ "disallow": "Disallow", "download": "Download", "download_anonymised": "Download anonymised", - "download_backup": "Download Backup", + "download_backup": "Download backup", "edit": "Edit", "edit_entity": "Edit {entityType}", "enable": "Enable", @@ -58,8 +58,8 @@ "finish": "Finish", "from_file": "From file…", "from_url": "From URL…", - "full_export": "Full Export", - "full_import": "Full Import", + "full_export": "Full export", + "full_import": "Full import", "generate": "Generate", "generate_thumb_default": "Generate default thumbnail", "generate_thumb_from_current": "Generate thumbnail from current", @@ -75,13 +75,13 @@ "logout": "Log out", "make_primary": "Make Primary", "merge": "Merge", - "migrate_blobs": "Migrate Blobs", - "migrate_scene_screenshots": "Migrate Scene Screenshots", + "migrate_blobs": "Migrate blobs", + "migrate_scene_screenshots": "Migrate scene screenshots", "next_action": "Next", "not_running": "not running", "open_in_external_player": "Open in external player", "open_random": "Open Random", - "optimise_database": "Optimise Database", + "optimise_database": "Optimise database", "overwrite": "Overwrite", "play": "Play", "play_random": "Play Random", @@ -115,13 +115,14 @@ "scrape_with": "Scrape with…", "search": "Search", "select_all": "Select All", + "select_directory": "Select directory", "select_entity": "Select {entityType}", "select_folders": "Select folders", "select_none": "Select None", "invert_selection": "Invert Selection", - "selective_auto_tag": "Selective Auto Tag", - "selective_clean": "Selective Clean", - "selective_scan": "Selective Scan", + "selective_auto_tag": "Selective auto tag", + "selective_clean": "Selective clean", + "selective_scan": "Selective scan", "set_as_default": "Set as default", "set_back_image": "Back image…", "set_cover": "Set as Cover", @@ -252,7 +253,7 @@ "stash_wiki": "Stash {url} page", "version": "Version" }, - "advanced_mode": "Advanced Mode", + "advanced_mode": "Advanced mode", "application_paths": { "heading": "Application Paths" }, @@ -270,11 +271,14 @@ "tasks": "Tasks", "tools": "Tools" }, + "changelog": { + "header": "Changelog" + }, "dlna": { "allow_temp_ip": "Allow {tempIP}", "allowed_ip_addresses": "Allowed IP addresses", "allowed_ip_temporarily": "Allowed IP temporarily", - "default_ip_whitelist": "Default IP Whitelist", + "default_ip_whitelist": "Default IP whitelist", "default_ip_whitelist_desc": "Default IP addresses allow to access DLNA. Use {wildcard} to allow all IP addresses.", "disabled_dlna_temporarily": "Disabled DLNA temporarily", "disallowed_ip": "Disallowed IP", @@ -283,35 +287,35 @@ "network_interfaces": "Interfaces", "network_interfaces_desc": "Interfaces to expose DLNA server on. An empty list results in running on all interfaces. Requires DLNA restart after changing.", "recent_ip_addresses": "Recent IP addresses", - "server_display_name": "Server Display Name", + "server_display_name": "Server display name", "server_display_name_desc": "Display name for the DLNA server. Defaults to {server_name} if empty.", - "server_port": "Server Port", + "server_port": "Server port", "server_port_desc": "Port to run the DLNA server on. Requires DLNA restart after changing.", "successfully_cancelled_temporary_behaviour": "Successfully cancelled temporary behaviour", "until_restart": "until restart", - "video_sort_order": "Default Video Sort Order", + "video_sort_order": "Default video sort order", "video_sort_order_desc": "Order to sort videos by default." }, "general": { "auth": { - "api_key": "API Key", + "api_key": "API key", "api_key_desc": "API key for external systems. Only required when username/password is configured. Username must be saved before generating API key.", "authentication": "Authentication", "clear_api_key": "Clear API key", "credentials": { - "description": "Credentials to restrict access to stash.", + "description": "Credentials to restrict access to Stash.", "heading": "Credentials" }, "generate_api_key": "Generate API key", "log_file": "Log file", "log_file_desc": "Path to the file to output logging to. Blank to disable file logging. Requires restart.", - "log_http": "Log http access", - "log_http_desc": "Logs http access to the terminal. Requires restart.", + "log_http": "Log HTTP access", + "log_http_desc": "Logs HTTP access to the terminal. Requires restart.", "log_to_terminal": "Log to terminal", "log_to_terminal_desc": "Logs to the terminal in addition to a file. Always true if file logging is disabled. Requires restart.", "log_file_max_size": "Maximum log size", "log_file_max_size_desc": "Maximum size in megabytes of the log file before it is compressed. 0MB is disabled. Requires restart.", - "maximum_session_age": "Maximum Session Age", + "maximum_session_age": "Maximum session age", "maximum_session_age_desc": "Maximum idle time before a login session is expired, in seconds. Requires restart.", "password": "Password", "password_desc": "Password to access Stash. Leave blank to disable user authentication", @@ -320,50 +324,50 @@ "username_desc": "Username to access Stash. Leave blank to disable user authentication" }, "backup_directory_path": { - "description": "Directory location for SQLite database file backups", - "heading": "Backup Directory Path" + "description": "Directory location for SQLite database file backups.", + "heading": "Backup directory path" }, "delete_trash_path": { "description": "Path where deleted files will be moved to instead of being permanently deleted. Leave empty to permanently delete files.", - "heading": "Trash Path" + "heading": "Trash path" }, "blobs_path": { "description": "Where in the filesystem to store binary data. Applicable only when using the Filesystem blob storage type. WARNING: changing this requires manually moving existing data.", "heading": "Binary data filesystem path" }, "blobs_storage": { - "description": "Where to store binary data such as scene covers, performer, studio and tag images. After changing this value, the existing data must be migrated using the Migrate Blobs tasks. See Tasks page for migration.", + "description": "Where to store binary data such as scene covers, performer, studio and tag images. After changing this value, the existing data must be migrated using the Migrate blobs tasks. See Tasks page for migration.", "heading": "Binary data storage type" }, "cache_location": "Directory location of the cache. Required if streaming using HLS (such as on Apple devices) or DASH.", - "cache_path_head": "Cache Path", + "cache_path_head": "Cache path", "calculate_md5_and_ohash_desc": "Calculate MD5 checksum in addition to oshash. Enabling will cause initial scans to be slower. File naming hash must be set to oshash to disable MD5 calculation.", "calculate_md5_and_ohash_label": "Calculate MD5 for videos", "check_for_insecure_certificates": "Check for insecure certificates", - "check_for_insecure_certificates_desc": "Some sites use insecure ssl certificates. When unticked the scraper skips the insecure certificates check and allows scraping of those sites. If you get a certificate error when scraping untick this.", + "check_for_insecure_certificates_desc": "Some sites use insecure SSL certificates. When unticked the scraper skips the insecure certificates check and allows scraping of those sites. If you get a certificate error when scraping untick this.", "chrome_cdp_path": "Chrome CDP path", "chrome_cdp_path_desc": "File path to the Chrome executable, or a remote address (starting with http:// or https://, for example http://localhost:9222/json/version) to a Chrome instance.", - "create_galleries_from_folders_desc": "If true, creates galleries from folders containing images by default. Create a File called .forcegallery or .nogallery in a folder to enforce/prevent this.", + "create_galleries_from_folders_desc": "If true, creates galleries from folders containing images by default. Create a file called .forcegallery or .nogallery in a folder to override this setting.", "create_galleries_from_folders_label": "Create galleries from folders containing images", "database": "Database", - "db_path_head": "Database Path", + "db_path_head": "Database path", "directory_locations_to_your_content": "Directory locations to your content", - "excluded_image_gallery_patterns_desc": "Regexps of image and gallery files/paths to exclude from Scan and add to Clean", - "excluded_image_gallery_patterns_head": "Excluded Image/Gallery Patterns", - "excluded_video_patterns_desc": "Regexps of video files/paths to exclude from Scan and add to Clean", - "excluded_video_patterns_head": "Excluded Video Patterns", + "excluded_image_gallery_patterns_desc": "Regexps of image and gallery files/paths to exclude from Scan and add to Clean tasks.", + "excluded_image_gallery_patterns_head": "Excluded image/gallery patterns", + "excluded_video_patterns_desc": "Regexps of video files/paths to exclude from Scan and add to Clean tasks.", + "excluded_video_patterns_head": "Excluded video patterns", "ffmpeg": { "download_ffmpeg": { "description": "Downloads FFmpeg into the configuration directory and clears the ffmpeg and ffprobe paths to resolve from the configuration directory.", "heading": "Download FFmpeg" }, "ffmpeg_path": { - "description": "Path to the ffmpeg executable (not just the folder). If empty, ffmpeg will be resolved from the environment via $PATH, the configuration directory, or from $HOME/.stash", - "heading": "FFmpeg Executable Path" + "description": "Path to the ffmpeg executable (not just the folder). If empty, ffmpeg will be resolved from the environment via $PATH, the configuration directory, or from $HOME/.stash.", + "heading": "FFmpeg executable path" }, "ffprobe_path": { - "description": "Path to the ffprobe executable (not just the folder). If empty, ffprobe will be resolved from the environment via $PATH, the configuration directory, or from $HOME/.stash", - "heading": "FFprobe Executable Path" + "description": "Path to the ffprobe executable (not just the folder). If empty, ffprobe will be resolved from the environment via $PATH, the configuration directory, or from $HOME/.stash.", + "heading": "FFprobe executable path" }, "hardware_acceleration": { "desc": "Uses available hardware to encode video for live transcoding.", @@ -372,80 +376,80 @@ "live_transcode": { "input_args": { "desc": "Advanced: Additional arguments to pass to ffmpeg before the input field when live transcoding video.", - "heading": "FFmpeg Live Transcode Input Args" + "heading": "FFmpeg live transcode input arguments" }, "output_args": { "desc": "Advanced: Additional arguments to pass to ffmpeg before the output field when live transcoding video.", - "heading": "FFmpeg Live Transcode Output Args" + "heading": "FFmpeg live transcode output arguments" } }, "transcode": { "input_args": { "desc": "Advanced: Additional arguments to pass to ffmpeg before the input field when generating video.", - "heading": "FFmpeg Transcode Input Args" + "heading": "FFmpeg transcode input arguments" }, "output_args": { "desc": "Advanced: Additional arguments to pass to ffmpeg before the output field when generating video.", - "heading": "FFmpeg Transcode Output Args" + "heading": "FFmpeg transcode output arguments" } } }, "funscript_heatmap_draw_range": "Include range in generated heatmaps", "funscript_heatmap_draw_range_desc": "Draw range of motion on the y-axis of the generated heatmap. Existing heatmaps will need to be regenerated after changing.", - "gallery_cover_regex_desc": "Regexp used to identify an image as gallery cover", + "gallery_cover_regex_desc": "Regexps used to identify an image as gallery cover.", "gallery_cover_regex_label": "Gallery cover pattern", - "gallery_ext_desc": "Comma-delimited list of file extensions that will be identified as gallery zip files.", - "gallery_ext_head": "Gallery zip Extensions", + "gallery_ext_desc": "Comma-delimited list of file extensions that will be identified as gallery ZIP files.", + "gallery_ext_head": "Gallery ZIP extensions", "generated_file_naming_hash_desc": "Use MD5 or oshash for generated file naming. Changing this requires that all scenes have the applicable MD5/oshash value populated. After changing this value, existing generated files will need to be migrated or regenerated. See Tasks page for migration.", "generated_file_naming_hash_head": "Generated file naming hash", - "generated_files_location": "Directory location for the generated files (scene markers, scene previews, sprites, etc)", - "generated_path_head": "Generated Path", + "generated_files_location": "Directory location for the generated files (scene markers, scene previews, sprites, etc).", + "generated_path_head": "Generated path", "hashing": "Hashing", "heatmap_generation": "Funscript Heatmap Generation", "image_ext_desc": "Comma-delimited list of file extensions that will be identified as images.", - "image_ext_head": "Image Extensions", + "image_ext_head": "Image extensions", "include_audio_desc": "Includes audio stream when generating previews.", "include_audio_head": "Include audio", "logging": "Logging", - "maximum_streaming_transcode_size_desc": "Maximum size for transcoded streams", + "maximum_streaming_transcode_size_desc": "Maximum size for transcoded streams.", "maximum_streaming_transcode_size_head": "Maximum streaming transcode size", - "maximum_transcode_size_desc": "Maximum size for generated transcodes", + "maximum_transcode_size_desc": "Maximum size for generated transcodes.", "maximum_transcode_size_head": "Maximum transcode size", "metadata_path": { - "description": "Directory location used when performing a full export or import", - "heading": "Metadata Path" + "description": "Directory location used when performing a full export or import.", + "heading": "Metadata path" }, - "number_of_parallel_task_for_scan_generation_desc": "Set to 0 for auto-detection. Warning running more tasks than is required to achieve 100% cpu utilisation will decrease performance and potentially cause other issues.", + "number_of_parallel_task_for_scan_generation_desc": "Set to 0 for auto-detection. Warning running more tasks than is required to achieve 100% CPU utilisation will decrease performance and potentially cause other issues.", "number_of_parallel_task_for_scan_generation_head": "Number of parallel task for scan/generation", "parallel_scan_head": "Parallel Scan/Generation", "plugins_path": { - "description": "Directory location of plugin configuration files", - "heading": "Plugins Path" + "description": "Directory location of plugin configuration files.", + "heading": "Plugins path" }, "preview_generation": "Preview Generation", "python_path": { - "description": "Path to the python executable (not just the folder). Used for script scrapers and plugins. If blank, python will be resolved from the environment", - "heading": "Python Executable Path" + "description": "Path to the python executable (not just the folder). Used for script scrapers and plugins. If blank, Python will be resolved from the environment.", + "heading": "Python executable path" }, - "scraper_user_agent": "Scraper User Agent", - "scraper_user_agent_desc": "User-Agent string used during scrape http requests", + "scraper_user_agent": "Scraper User-Agent", + "scraper_user_agent_desc": "User-Agent string used during scrape HTTP requests.", "scrapers_path": { - "description": "Directory location of scraper configuration files", - "heading": "Scrapers Path" + "description": "Directory location of scraper configuration files.", + "heading": "Scrapers path" }, "scraping": "Scraping", "sqlite_location": "File location for the SQLite database (requires restart). WARNING: storing the database on a different system to where the Stash server is run from (i.e. over the network) is unsupported!", "video_ext_desc": "Comma-delimited list of file extensions that will be identified as videos.", - "video_ext_head": "Video Extensions", + "video_ext_head": "Video extensions", "video_head": "Video" }, "library": { "exclusions": "Exclusions", - "gallery_and_image_options": "Gallery and Image options", - "media_content_extensions": "Media content extensions" + "gallery_and_image_options": "Gallery and Image Options", + "media_content_extensions": "Media Content Extensions" }, "logs": { - "log_level": "Log Level" + "log_level": "Log level" }, "plugins": { "available_plugins": "Available Plugins", @@ -457,8 +461,8 @@ "available_scrapers": "Available Scrapers", "entity_metadata": "{entityType} Metadata", "entity_scrapers": "{entityType} scrapers", - "excluded_tag_patterns_desc": "Regexps of tag names to exclude from scraping results", - "excluded_tag_patterns_head": "Excluded Tag Patterns", + "excluded_tag_patterns_desc": "Regexps of tag names to exclude from scraping results.", + "excluded_tag_patterns_head": "Excluded tag patterns", "installed_scrapers": "Installed Scrapers", "scraper": "Scraper", "scrapers": "Scrapers", @@ -486,25 +490,25 @@ "anonymise_database": "Makes a copy of the database to the backups directory, anonymising all sensitive data. This can then be provided to others for troubleshooting and debugging purposes. The original database is not modified. Anonymised database uses the filename format {filename_format}.", "anonymising_database": "Anonymising database", "auto_tag": { - "auto_tagging_all_paths": "Auto Tagging all paths", - "auto_tagging_paths": "Auto Tagging the following paths" + "auto_tagging_all_paths": "Auto tagging all paths", + "auto_tagging_paths": "Auto tagging the following paths" }, - "auto_tag_based_on_filenames": "Auto-tag content based on file paths.", - "auto_tagging": "Auto Tagging", + "auto_tag_based_on_filenames": "Auto tag content based on file paths.", + "auto_tagging": "Auto tagging", "backing_up_database": "Backing up database", "backup_and_download": "Performs a backup of the database and downloads the resulting file.", - "backup_database": "Performs a backup of the database to the backups directory, with the filename format {filename_format}", + "backup_database": "Performs a backup of the database to the backups directory, with the filename format {filename_format}.", "cleanup_desc": "Check for missing files and remove them from the database. This is a destructive action.", "clean_generated": { "blob_files": "Blob files", "description": "Removes generated files without a corresponding database entry.", - "image_thumbnails": "Image Thumbnails", + "image_thumbnails": "Image thumbnails", "image_thumbnails_desc": "Image thumbnails and clips", - "markers": "Marker Previews", - "previews": "Scene Previews", + "markers": "Marker previews", + "previews": "Scene previews", "previews_desc": "Scene previews and thumbnails", - "sprites": "Scene Sprites", - "transcodes": "Scene Transcodes" + "sprites": "Scene sprites", + "transcodes": "Scene transcodes" }, "data_management": "Data management", "defaults_set": "Defaults have been set and will be used when clicking the {action} button on the Tasks page.", @@ -522,7 +526,7 @@ "generate_phashes_during_scan": "Generate video perceptual hashes", "generate_phashes_during_scan_tooltip": "For deduplication and scene identification.", "generate_previews_during_scan": "Generate animated image previews", - "generate_previews_during_scan_tooltip": "Also generate animated (webp) previews, only required when Scene/Marker Wall Preview Type is set to Animated Image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files.", + "generate_previews_during_scan_tooltip": "Also generate animated (webp) previews, only required when Scene/Marker Wall Preview type is set to Animated image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files.", "generate_sprites_during_scan": "Generate scrubber sprites", "generate_sprites_during_scan_tooltip": "The set of images displayed below the video player for easy navigation.", "generate_thumbnails_during_scan": "Generate thumbnails for images", @@ -589,7 +593,7 @@ "tools": { "graphql_playground": "GraphQL playground", "heading": "Tools", - "scene_duplicate_checker": "Scene Duplicate Checker", + "scene_duplicate_checker": "Scene duplicate checker", "scene_filename_parser": { "add_field": "Add Field", "capitalize_title": "Capitalize title", @@ -601,7 +605,7 @@ "ignored_words": "Ignored words", "matches_with": "Matches with {i}", "select_parser_recipe": "Select Parser Recipe", - "title": "Scene Filename Parser", + "title": "Scene filename parser", "whitespace_chars": "Whitespace characters", "whitespace_chars_desc": "These characters will be replaced with whitespace in the title" }, @@ -619,8 +623,8 @@ "option_label": "Custom CSS enabled" }, "troubleshooting_mode": { - "button": "Troubleshooting Mode", - "dialog_title": "Enable Troubleshooting Mode", + "button": "Troubleshooting mode", + "dialog_title": "Enable troubleshooting mode", "dialog_description": "This will temporarily disable all customizations to help diagnose issues:", "dialog_item_plugins": "All plugins", "dialog_item_css": "Custom CSS", @@ -629,22 +633,22 @@ "dialog_log_level": "Log level will be set to Debug for detailed diagnostics.", "dialog_reload_note": "The page will reload automatically.", "enable": "Enable & Reload", - "overlay_message": "Troubleshooting Mode is active - all customizations are disabled", + "overlay_message": "Troubleshooting mode is active - all customizations are disabled", "exit": "Exit" }, "custom_javascript": { - "description": "Page must be reloaded for changes to take effect. There is no guarantee of compatibility between custom Javascript and future releases of Stash.", - "heading": "Custom Javascript", - "option_label": "Custom Javascript enabled" + "description": "Page must be reloaded for changes to take effect. There is no guarantee of compatibility between custom JavaScript and future releases of Stash.", + "heading": "Custom JavaScript", + "option_label": "Custom JavaScript enabled" }, "custom_locales": { "description": "Override individual locale strings. See https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json for the master list. Page must be reloaded for changes to take effect.", - "heading": "Custom localisation", + "heading": "Custom Localisation", "option_label": "Custom localisation enabled" }, "custom_title": { "description": "Custom text to append to the page title. If empty, defaults to 'Stash'.", - "heading": "Custom Title" + "heading": "Custom title" }, "delete_options": { "description": "Default settings when deleting images, galleries, and scenes.", @@ -656,14 +660,14 @@ }, "desktop_integration": { "desktop_integration": "Desktop Integration", - "notifications_enabled": "Enable Notifications", - "send_desktop_notifications_for_events": "Send desktop notifications for events", - "skip_opening_browser": "Skip Opening Browser", - "skip_opening_browser_on_startup": "Skip auto-opening browser during startup" + "notifications_enabled": "Enable notifications", + "send_desktop_notifications_for_events": "Send desktop notifications for events.", + "skip_opening_browser": "Skip opening browser", + "skip_opening_browser_on_startup": "Skip auto-opening browser during startup." }, "detail": { "compact_expanded_details": { - "description": "When enabled, this option will present expanded details while maintaining a compact presentation", + "description": "When enabled, this option will present expanded details while maintaining a compact presentation.", "heading": "Compact expanded details" }, "enable_background_image": { @@ -672,13 +676,13 @@ }, "heading": "Detail Page", "show_all_details": { - "description": "When enabled, all content details will be shown by default and each detail item will fit under a single column", + "description": "When enabled, all content details will be shown by default and each detail item will fit under a single column.", "heading": "Show all details" } }, "editing": { "disable_dropdown_create": { - "description": "Remove the ability to create new objects from the dropdown selectors", + "description": "Remove the ability to create new objects from the dropdown selectors.", "heading": "Disable dropdown create" }, "heading": "Editing", @@ -696,7 +700,7 @@ } }, "type": { - "label": "Rating System Type", + "label": "Rating system type", "options": { "decimal": "Decimal", "stars": "Stars" @@ -706,7 +710,7 @@ }, "funscript_offset": { "description": "Time offset in milliseconds for interactive scripts playback.", - "heading": "Funscript Offset (ms)" + "heading": "Funscript offset (ms)" }, "handy_connection": { "connect": "Connect", @@ -719,8 +723,8 @@ "sync": "Sync" }, "handy_connection_key": { - "description": "Handy connection key to use for interactive scenes. Setting this key will allow Stash to share your current scene information with handyfeeling.com", - "heading": "Handy Connection Key" + "description": "Handy connection key to use for interactive scenes. Setting this key will allow Stash to share your current scene information with handyfeeling.com.", + "heading": "Handy connection key" }, "image_lightbox": { "heading": "Image Lightbox" @@ -734,11 +738,11 @@ "heading": "Images", "options": { "create_image_clips_from_videos": { - "description": "When a library has Videos disabled, Video Files (files ending with Video Extension) will be scanned as Image Clip.", - "heading": "Scan Video Extensions as Image Clip" + "description": "When a library has Videos disabled, video files (see Video extensions) will be scanned as image clips.", + "heading": "Scan video extensions as image clips" }, "write_image_thumbnails": { - "description": "Write image thumbnails to disk when generated on-the-fly", + "description": "Write image thumbnails to disk when generated on-the-fly.", "heading": "Write image thumbnails" } } @@ -748,31 +752,31 @@ "heading": "Language" }, "max_loop_duration": { - "description": "Maximum scene duration where scene player will loop the video - 0 to disable", + "description": "Maximum scene duration where scene player will loop the video. Set 0 to disable.", "heading": "Maximum loop duration" }, "menu_items": { - "description": "Show or hide different types of content on the navigation bar", - "heading": "Menu Items" + "description": "Show or hide different types of content on the navigation bar.", + "heading": "Menu items" }, "minimum_play_percent": { "description": "The percentage of time in which a scene must be played before its play count is incremented.", - "heading": "Minimum Play Percent" + "heading": "Minimum play percent" }, "performers": { "options": { "image_location": { - "description": "Custom path for default performer images. Leave empty to use in-built defaults", - "heading": "Custom Performer Image Path" + "description": "Custom path for default performer images. Leave empty to use built-in defaults.", + "heading": "Custom performer image path" } } }, "preview_type": { "description": "The default option is video (mp4) previews. For less CPU usage when browsing, you can use the animated image (webp) previews. However they must be generated in addition to the video previews and are larger files.", - "heading": "Preview Type", + "heading": "Preview type", "options": { - "animated": "Animated Image", - "static": "Static Image", + "animated": "Animated image", + "static": "Static image", "video": "Video" } }, @@ -788,22 +792,22 @@ "always_start_from_beginning": "Always start video from beginning", "auto_start_video": "Auto-start video", "auto_start_video_on_play_selected": { - "description": "Auto-start scene videos when playing from queue, or playing selected or random from Scenes page", + "description": "Auto-start scene videos when playing from queue, or playing selected or random from Scenes page.", "heading": "Auto-start video when playing selected" }, "continue_playlist_default": { - "description": "Play next scene in queue when video finishes", + "description": "Play next scene in queue when video finishes.", "heading": "Continue playlist by default" }, - "disable_mobile_media_auto_rotate": "Disable auto-rotate of fullscreen media on Mobile", + "disable_mobile_media_auto_rotate": "Disable auto-rotate of fullscreen media on mobile", "enable_chromecast": "Enable Chromecast", - "show_ab_loop_controls": "Show AB Loop plugin controls", - "show_scrubber": "Show Scrubber", - "show_range_markers": "Show Range Markers", - "track_activity": "Enable Scene Play history", + "show_ab_loop_controls": "Show AB loop controls", + "show_scrubber": "Show scrubber", + "show_range_markers": "Show range markers", + "track_activity": "Enable scene play history", "vr_tag": { "description": "The VR button will only be displayed for scenes with this tag.", - "heading": "VR Tag" + "heading": "VR tag" } } }, @@ -820,27 +824,27 @@ }, "sfw_mode": { "description": "Enable if using stash to store SFW content. Hides or changes some adult-content-related aspects of the UI.", - "heading": "SFW Content Mode" + "heading": "SFW content mode" }, "show_tag_card_on_hover": { - "description": "Show tag card when hovering tag badges", + "description": "Show tag card when hovering tag badges.", "heading": "Tag card tooltips" }, "slideshow_delay": { - "description": "Slideshow is available in galleries when in wall view mode", - "heading": "Slideshow Delay (seconds)" + "description": "Slideshow is available in galleries when in wall view mode.", + "heading": "Slideshow delay (seconds)" }, "studio_panel": { - "heading": "Studio view", + "heading": "Studio View", "options": { "show_child_studio_content": { - "description": "In the studio view, display content from the sub-studios as well", + "description": "In the studio view, display content from the sub-studios as well.", "heading": "Display sub-studios content" } } }, "performer_list": { - "heading": "Performer list", + "heading": "Performer List", "options": { "show_links_on_grid_card": { "heading": "Display links on performer grid cards" @@ -848,17 +852,17 @@ } }, "tag_panel": { - "heading": "Tag view", + "heading": "Tag View", "options": { "show_child_tagged_content": { - "description": "In the tag view, display content from the subtags as well", - "heading": "Display subtag content" + "description": "In the tag view, display content from the sub-tags as well.", + "heading": "Display sub-tag content" } } }, "title": "User Interface", "use_stash_hosted_funscript": { - "description": "When enabled, funscripts will be served directly from Stash to your Handy device without using the third party Handy server. Requires that Stash be accessible from your Handy device, and that an API key is generated if stash has credentials configured.", + "description": "When enabled, funscripts will be served directly from Stash to your Handy device without using the third party Handy server. Requires that Stash be accessible from your Handy device, and that an API key is generated if Stash has credentials configured.", "heading": "Serve funscripts directly" } } @@ -967,19 +971,19 @@ "display_mode": { "fit_horizontally": "Fit horizontally", "fit_to_screen": "Fit to screen", - "label": "Display Mode", + "label": "Display mode", "original": "Original" }, "options": "Options", "page_header": "Page {page} / {total}", "reset_zoom_on_nav": "Reset zoom level when changing image", "scale_up": { - "description": "Scale smaller images up to fill screen", + "description": "Scale smaller images up to fill screen.", "label": "Scale up to fit" }, "scroll_mode": { "description": "Hold shift to temporarily use other mode.", - "label": "Scroll Mode", + "label": "Scroll mode", "pan_y": "Pan Y", "zoom": "Zoom" } @@ -997,24 +1001,24 @@ "destination": "Reassign to" }, "scene_gen": { - "clip_previews": "Image Clip Previews", + "clip_previews": "Image clip previews", "covers": "Scene covers", "force_transcodes": "Force Transcode generation", "force_transcodes_tooltip": "By default, transcodes are only generated when the video file is not supported in the browser. When enabled, transcodes will be generated even when the video file appears to be supported in the browser.", "image_phash": "Image perceptual hashes", "image_phash_tooltip": "For deduplication and identification", - "image_previews": "Animated Image Previews", - "image_previews_tooltip": "Also generate animated (webp) previews, only required when Scene/Marker Wall Preview Type is set to Animated Image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files.", - "image_thumbnails": "Image Thumbnails", + "image_previews": "Animated image previews", + "image_previews_tooltip": "Also generate animated (webp) previews, only required when Scene/Marker Wall Preview type is set to Animated image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files.", + "image_thumbnails": "Image thumbnails", "interactive_heatmap_speed": "Generate heatmaps and speeds for interactive scenes", - "marker_image_previews": "Marker Animated Image Previews", - "marker_image_previews_tooltip": "Also generate animated (webp) previews, only required when Scene/Marker Wall Preview Type is set to Animated Image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files.", - "marker_screenshots": "Marker Screenshots", + "marker_image_previews": "Marker animated image previews", + "marker_image_previews_tooltip": "Also generate animated (webp) previews, only required when Scene/Marker Wall Preview type is set to Animated image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files.", + "marker_screenshots": "Marker screenshots", "marker_screenshots_tooltip": "Marker static JPG images", - "markers": "Marker Previews", + "markers": "Marker previews", "markers_tooltip": "20 second videos which begin at the given timecode.", - "override_preview_generation_options": "Override Preview Generation Options", - "override_preview_generation_options_desc": "Override Preview Generation Options for this operation. Defaults are set in System -> Preview Generation.", + "override_preview_generation_options": "Override preview generation options", + "override_preview_generation_options_desc": "Override preview generation options for this operation. Defaults are set in System -> Preview Generation.", "overwrite": "Overwrite existing files", "phash": "Video perceptual hashes", "phash_tooltip": "For deduplication and scene identification", @@ -1022,7 +1026,7 @@ "preview_exclude_end_time_head": "Exclude end time", "preview_exclude_start_time_desc": "Exclude the first x seconds from scene previews. This can be a value in seconds, or a percentage (eg 2%) of the total scene duration.", "preview_exclude_start_time_head": "Exclude start time", - "preview_generation_options": "Preview Generation Options", + "preview_generation_options": "Preview generation options", "preview_options": "Preview Options", "preview_preset_desc": "The preset regulates size, quality and encoding time of preview generation. Presets beyond “slow” have diminishing returns and are not recommended.", "preview_preset_head": "Preview encoding preset", @@ -1030,7 +1034,7 @@ "preview_seg_count_head": "Number of segments in preview", "preview_seg_duration_desc": "Duration of each preview segment, in seconds.", "preview_seg_duration_head": "Preview segment duration", - "sprites": "Scene Scrubber Sprites", + "sprites": "Scene scrubber sprites", "sprites_tooltip": "The set of images displayed below the video player for easy navigation.", "transcodes": "Transcodes", "transcodes_tooltip": "MP4 transcodes will be pre-generated for all content; useful for slow CPUs but requires much more disk space", @@ -1183,7 +1187,7 @@ "height_cm": "Height (cm)", "help": "Help", "history": "History", - "ignore_auto_tag": "Ignore Auto Tag", + "ignore_auto_tag": "Ignore auto tag", "image": "Image", "image_count": "Image Count", "image_index": "Image #", @@ -1252,16 +1256,16 @@ "organized": "Organised", "orientation": "Orientation", "package_manager": { - "add_source": "Add Source", - "check_for_updates": "Check for Updates", + "add_source": "Add source", + "check_for_updates": "Check for updates", "confirm_delete_source": "Are you sure you want to delete source {name} ({url})?", "confirm_uninstall": "Are you sure you want to uninstall {number} packages?", "description": "Description", - "edit_source": "Edit Source", + "edit_source": "Edit source", "hide_unselected": "Hide unselected", "install": "Install", - "installed_version": "Installed Version", - "latest_version": "Latest Version", + "installed_version": "Installed version", + "latest_version": "Latest version", "no_packages": "No packages found", "no_sources": "No sources configured", "no_upgradable": "No upgradable packages found", @@ -1272,7 +1276,7 @@ "source": { "local_path": { "description": "Relative path to store packages for this source. Note that changing this requires the packages to be moved manually.", - "heading": "Local Path" + "heading": "Local path" }, "name": "Name", "url": "Source URL" From 5cf41c8c8edf22b7c864689a01a84eeb7633eccd Mon Sep 17 00:00:00 2001 From: InfiniteStash <117855276+InfiniteStash@users.noreply.github.com> Date: Wed, 11 Feb 2026 01:26:05 +0100 Subject: [PATCH 057/177] Remove unused stash-box fingerprint queries (#6561) * Remove unused stash-box fingerprint query * Remove findSceneByFingerprint --- graphql/stash-box/query.graphql | 12 - pkg/stashbox/graphql/generated_client.go | 334 ----------------------- 2 files changed, 346 deletions(-) diff --git a/graphql/stash-box/query.graphql b/graphql/stash-box/query.graphql index 2367e85cf..e2686ac4d 100644 --- a/graphql/stash-box/query.graphql +++ b/graphql/stash-box/query.graphql @@ -120,18 +120,6 @@ fragment SceneFragment on Scene { } } -query FindSceneByFingerprint($fingerprint: FingerprintQueryInput!) { - findSceneByFingerprint(fingerprint: $fingerprint) { - ...SceneFragment - } -} - -query FindScenesByFullFingerprints($fingerprints: [FingerprintQueryInput!]!) { - findScenesByFullFingerprints(fingerprints: $fingerprints) { - ...SceneFragment - } -} - query FindScenesBySceneFingerprints( $fingerprints: [[FingerprintQueryInput!]!]! ) { diff --git a/pkg/stashbox/graphql/generated_client.go b/pkg/stashbox/graphql/generated_client.go index 640a1c893..29b702a7f 100644 --- a/pkg/stashbox/graphql/generated_client.go +++ b/pkg/stashbox/graphql/generated_client.go @@ -9,8 +9,6 @@ import ( ) type StashBoxGraphQLClient interface { - FindSceneByFingerprint(ctx context.Context, fingerprint FingerprintQueryInput, interceptors ...clientv2.RequestInterceptor) (*FindSceneByFingerprint, error) - FindScenesByFullFingerprints(ctx context.Context, fingerprints []*FingerprintQueryInput, interceptors ...clientv2.RequestInterceptor) (*FindScenesByFullFingerprints, error) FindScenesBySceneFingerprints(ctx context.Context, fingerprints [][]*FingerprintQueryInput, interceptors ...clientv2.RequestInterceptor) (*FindScenesBySceneFingerprints, error) SearchScene(ctx context.Context, term string, interceptors ...clientv2.RequestInterceptor) (*SearchScene, error) SearchPerformer(ctx context.Context, term string, interceptors ...clientv2.RequestInterceptor) (*SearchPerformer, error) @@ -536,42 +534,6 @@ func (t *SceneFragment_Studio_StudioFragment_Parent) GetName() string { return t.Name } -type FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent struct { - ID string "json:\"id\" graphql:\"id\"" - Name string "json:\"name\" graphql:\"name\"" -} - -func (t *FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent) GetID() string { - if t == nil { - t = &FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent{} - } - return t.ID -} -func (t *FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent) GetName() string { - if t == nil { - t = &FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent{} - } - return t.Name -} - -type FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent struct { - ID string "json:\"id\" graphql:\"id\"" - Name string "json:\"name\" graphql:\"name\"" -} - -func (t *FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetID() string { - if t == nil { - t = &FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent{} - } - return t.ID -} -func (t *FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetName() string { - if t == nil { - t = &FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent{} - } - return t.Name -} - type FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent struct { ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" @@ -695,28 +657,6 @@ func (t *SubmitPerformerDraft_SubmitPerformerDraft) GetID() *string { return t.ID } -type FindSceneByFingerprint struct { - FindSceneByFingerprint []*SceneFragment "json:\"findSceneByFingerprint\" graphql:\"findSceneByFingerprint\"" -} - -func (t *FindSceneByFingerprint) GetFindSceneByFingerprint() []*SceneFragment { - if t == nil { - t = &FindSceneByFingerprint{} - } - return t.FindSceneByFingerprint -} - -type FindScenesByFullFingerprints struct { - FindScenesByFullFingerprints []*SceneFragment "json:\"findScenesByFullFingerprints\" graphql:\"findScenesByFullFingerprints\"" -} - -func (t *FindScenesByFullFingerprints) GetFindScenesByFullFingerprints() []*SceneFragment { - if t == nil { - t = &FindScenesByFullFingerprints{} - } - return t.FindScenesByFullFingerprints -} - type FindScenesBySceneFingerprints struct { FindScenesBySceneFingerprints [][]*SceneFragment "json:\"findScenesBySceneFingerprints\" graphql:\"findScenesBySceneFingerprints\"" } @@ -849,278 +789,6 @@ func (t *SubmitPerformerDraft) GetSubmitPerformerDraft() *SubmitPerformerDraft_S return &t.SubmitPerformerDraft } -const FindSceneByFingerprintDocument = `query FindSceneByFingerprint ($fingerprint: FingerprintQueryInput!) { - findSceneByFingerprint(fingerprint: $fingerprint) { - ... SceneFragment - } -} -fragment SceneFragment on Scene { - id - title - code - details - director - duration - date - urls { - ... URLFragment - } - images { - ... ImageFragment - } - studio { - ... StudioFragment - } - tags { - ... TagFragment - } - performers { - ... PerformerAppearanceFragment - } - fingerprints { - ... FingerprintFragment - } -} -fragment URLFragment on URL { - url - type -} -fragment ImageFragment on Image { - id - url - width - height -} -fragment StudioFragment on Studio { - name - id - aliases - urls { - ... URLFragment - } - parent { - name - id - } - images { - ... ImageFragment - } -} -fragment TagFragment on Tag { - name - id -} -fragment PerformerAppearanceFragment on PerformerAppearance { - as - performer { - ... PerformerFragment - } -} -fragment PerformerFragment on Performer { - id - name - disambiguation - aliases - gender - merged_ids - deleted - merged_into_id - urls { - ... URLFragment - } - images { - ... ImageFragment - } - birth_date - death_date - ethnicity - country - eye_color - hair_color - height - measurements { - ... MeasurementsFragment - } - breast_type - career_start_year - career_end_year - tattoos { - ... BodyModificationFragment - } - piercings { - ... BodyModificationFragment - } -} -fragment MeasurementsFragment on Measurements { - band_size - cup_size - waist - hip -} -fragment BodyModificationFragment on BodyModification { - location - description -} -fragment FingerprintFragment on Fingerprint { - algorithm - hash - duration -} -` - -func (c *Client) FindSceneByFingerprint(ctx context.Context, fingerprint FingerprintQueryInput, interceptors ...clientv2.RequestInterceptor) (*FindSceneByFingerprint, error) { - vars := map[string]any{ - "fingerprint": fingerprint, - } - - var res FindSceneByFingerprint - if err := c.Client.Post(ctx, "FindSceneByFingerprint", FindSceneByFingerprintDocument, &res, vars, interceptors...); err != nil { - if c.Client.ParseDataWhenErrors { - return &res, err - } - - return nil, err - } - - return &res, nil -} - -const FindScenesByFullFingerprintsDocument = `query FindScenesByFullFingerprints ($fingerprints: [FingerprintQueryInput!]!) { - findScenesByFullFingerprints(fingerprints: $fingerprints) { - ... SceneFragment - } -} -fragment SceneFragment on Scene { - id - title - code - details - director - duration - date - urls { - ... URLFragment - } - images { - ... ImageFragment - } - studio { - ... StudioFragment - } - tags { - ... TagFragment - } - performers { - ... PerformerAppearanceFragment - } - fingerprints { - ... FingerprintFragment - } -} -fragment URLFragment on URL { - url - type -} -fragment ImageFragment on Image { - id - url - width - height -} -fragment StudioFragment on Studio { - name - id - aliases - urls { - ... URLFragment - } - parent { - name - id - } - images { - ... ImageFragment - } -} -fragment TagFragment on Tag { - name - id -} -fragment PerformerAppearanceFragment on PerformerAppearance { - as - performer { - ... PerformerFragment - } -} -fragment PerformerFragment on Performer { - id - name - disambiguation - aliases - gender - merged_ids - deleted - merged_into_id - urls { - ... URLFragment - } - images { - ... ImageFragment - } - birth_date - death_date - ethnicity - country - eye_color - hair_color - height - measurements { - ... MeasurementsFragment - } - breast_type - career_start_year - career_end_year - tattoos { - ... BodyModificationFragment - } - piercings { - ... BodyModificationFragment - } -} -fragment MeasurementsFragment on Measurements { - band_size - cup_size - waist - hip -} -fragment BodyModificationFragment on BodyModification { - location - description -} -fragment FingerprintFragment on Fingerprint { - algorithm - hash - duration -} -` - -func (c *Client) FindScenesByFullFingerprints(ctx context.Context, fingerprints []*FingerprintQueryInput, interceptors ...clientv2.RequestInterceptor) (*FindScenesByFullFingerprints, error) { - vars := map[string]any{ - "fingerprints": fingerprints, - } - - var res FindScenesByFullFingerprints - if err := c.Client.Post(ctx, "FindScenesByFullFingerprints", FindScenesByFullFingerprintsDocument, &res, vars, interceptors...); err != nil { - if c.Client.ParseDataWhenErrors { - return &res, err - } - - return nil, err - } - - return &res, nil -} - const FindScenesBySceneFingerprintsDocument = `query FindScenesBySceneFingerprints ($fingerprints: [[FingerprintQueryInput!]!]!) { findScenesBySceneFingerprints(fingerprints: $fingerprints) { ... SceneFragment @@ -1890,8 +1558,6 @@ func (c *Client) SubmitPerformerDraft(ctx context.Context, input PerformerDraftI } var DocumentOperationNames = map[string]string{ - FindSceneByFingerprintDocument: "FindSceneByFingerprint", - FindScenesByFullFingerprintsDocument: "FindScenesByFullFingerprints", FindScenesBySceneFingerprintsDocument: "FindScenesBySceneFingerprints", SearchSceneDocument: "SearchScene", SearchPerformerDocument: "SearchPerformer", From 5628fbc5d3cdd6867e6069b9b4889701b8892359 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:27:57 +1100 Subject: [PATCH 058/177] Merge tag values dialog (#6552) * Change tag merge to accept values. MergeHierarchy is removed as it is no longer needed * Add tag merge value dialog to choose values when merging --- graphql/schema/types/tag.graphql | 2 + internal/api/resolver_mutation_tag.go | 86 ++-- pkg/tag/update.go | 46 -- ui/v2.5/graphql/data/tag.graphql | 2 + ui/v2.5/graphql/mutations/tag.graphql | 10 +- ui/v2.5/graphql/queries/tag.graphql | 8 +- .../Performers/PerformerMergeDialog.tsx | 46 +- .../Shared/ScrapeDialog/ScrapeDialogRow.tsx | 29 +- .../Shared/ScrapeDialog/scrapeResult.ts | 3 + .../src/components/Tags/TagMergeDialog.tsx | 448 +++++++++++++++++- ui/v2.5/src/core/StashService.ts | 8 + 11 files changed, 565 insertions(+), 123 deletions(-) diff --git a/graphql/schema/types/tag.graphql b/graphql/schema/types/tag.graphql index 2210c900e..0acbc927f 100644 --- a/graphql/schema/types/tag.graphql +++ b/graphql/schema/types/tag.graphql @@ -78,6 +78,8 @@ type FindTagsResultType { input TagsMergeInput { source: [ID!]! destination: ID! + # values defined here will override values in the destination + values: TagUpdateInput } input BulkTagUpdateInput { diff --git a/internal/api/resolver_mutation_tag.go b/internal/api/resolver_mutation_tag.go index 31c7980f6..ac0183b74 100644 --- a/internal/api/resolver_mutation_tag.go +++ b/internal/api/resolver_mutation_tag.go @@ -6,7 +6,6 @@ import ( "strconv" "strings" - "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/plugin/hook" "github.com/stashapp/stash/pkg/sliceutil/stringslice" @@ -103,17 +102,7 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput) return r.getTag(ctx, newTag.ID) } -func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) (*models.Tag, error) { - tagID, err := strconv.Atoi(input.ID) - if err != nil { - return nil, fmt.Errorf("converting id: %w", err) - } - - translator := changesetTranslator{ - inputMap: getUpdateInputMap(ctx), - } - - // Populate tag from the input +func tagPartialFromInput(input TagUpdateInput, translator changesetTranslator) (*models.TagPartial, error) { updatedTag := models.NewTagPartial() updatedTag.Name = translator.optionalString(input.Name, "name") @@ -132,6 +121,7 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) } updatedTag.StashIDs = translator.updateStashIDs(updateStashIDInputs, "stash_ids") + var err error updatedTag.ParentIDs, err = translator.updateIds(input.ParentIds, "parent_ids") if err != nil { return nil, fmt.Errorf("converting parent tag ids: %w", err) @@ -149,6 +139,25 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) updatedTag.CustomFields.Partial = convertMapJSONNumbers(updatedTag.CustomFields.Partial) } + return &updatedTag, nil +} + +func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) (*models.Tag, error) { + tagID, err := strconv.Atoi(input.ID) + if err != nil { + return nil, fmt.Errorf("converting id: %w", err) + } + + translator := changesetTranslator{ + inputMap: getUpdateInputMap(ctx), + } + + // Populate tag from the input + updatedTag, err := tagPartialFromInput(input, translator) + if err != nil { + return nil, err + } + var imageData []byte imageIncluded := translator.hasField("image") if input.Image != nil { @@ -185,11 +194,11 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) } } - if err := tag.ValidateUpdate(ctx, tagID, updatedTag, qb); err != nil { + if err := tag.ValidateUpdate(ctx, tagID, *updatedTag, qb); err != nil { return err } - t, err = qb.UpdatePartial(ctx, tagID, updatedTag) + t, err = qb.UpdatePartial(ctx, tagID, *updatedTag) if err != nil { return err } @@ -337,6 +346,31 @@ func (r *mutationResolver) TagsMerge(ctx context.Context, input TagsMergeInput) return nil, nil } + var values *models.TagPartial + var imageData []byte + + if input.Values != nil { + translator := changesetTranslator{ + inputMap: getNamedUpdateInputMap(ctx, "input.values"), + } + + values, err = tagPartialFromInput(*input.Values, translator) + if err != nil { + return nil, err + } + + if input.Values.Image != nil { + var err error + imageData, err = utils.ProcessImageInput(ctx, *input.Values.Image) + if err != nil { + return nil, fmt.Errorf("processing cover image: %w", err) + } + } + } else { + v := models.NewTagPartial() + values = &v + } + var t *models.Tag if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Tag @@ -351,28 +385,22 @@ func (r *mutationResolver) TagsMerge(ctx context.Context, input TagsMergeInput) return fmt.Errorf("tag with id %d not found", destination) } - parents, children, err := tag.MergeHierarchy(ctx, destination, source, qb) - if err != nil { - return err - } - if err = qb.Merge(ctx, source, destination); err != nil { return err } - err = qb.UpdateParentTags(ctx, destination, parents) - if err != nil { - return err - } - err = qb.UpdateChildTags(ctx, destination, children) - if err != nil { + if err := tag.ValidateUpdate(ctx, destination, *values, qb); err != nil { return err } - err = tag.ValidateHierarchyExisting(ctx, t, parents, children, qb) - if err != nil { - logger.Errorf("Error merging tag: %s", err) - return err + if _, err := qb.UpdatePartial(ctx, destination, *values); err != nil { + return fmt.Errorf("updating tag: %w", err) + } + + if len(imageData) > 0 { + if err := qb.UpdateImage(ctx, destination, imageData); err != nil { + return err + } } return nil diff --git a/pkg/tag/update.go b/pkg/tag/update.go index 99e9b9165..4a3a2901a 100644 --- a/pkg/tag/update.go +++ b/pkg/tag/update.go @@ -220,49 +220,3 @@ func ValidateHierarchyExisting(ctx context.Context, tag *models.Tag, parentIDs, return nil } - -func MergeHierarchy(ctx context.Context, destination int, sources []int, qb RelationshipFinder) ([]int, []int, error) { - var mergedParents, mergedChildren []int - allIds := append([]int{destination}, sources...) - - addTo := func(mergedItems []int, tagIDs []int) []int { - Tags: - for _, tagID := range tagIDs { - // Ignore tags which are already set - for _, existingItem := range mergedItems { - if tagID == existingItem { - continue Tags - } - } - - // Ignore tags which are being merged, as these are rolled up anyway (if A is merged into B any direct link between them can be ignored) - for _, id := range allIds { - if tagID == id { - continue Tags - } - } - - mergedItems = append(mergedItems, tagID) - } - - return mergedItems - } - - for _, id := range allIds { - parents, err := qb.GetParentIDs(ctx, id) - if err != nil { - return nil, nil, err - } - - mergedParents = addTo(mergedParents, parents) - - children, err := qb.GetChildIDs(ctx, id) - if err != nil { - return nil, nil, err - } - - mergedChildren = addTo(mergedChildren, children) - } - - return mergedParents, mergedChildren, nil -} diff --git a/ui/v2.5/graphql/data/tag.graphql b/ui/v2.5/graphql/data/tag.graphql index e640af0c9..19438e2a4 100644 --- a/ui/v2.5/graphql/data/tag.graphql +++ b/ui/v2.5/graphql/data/tag.graphql @@ -34,6 +34,8 @@ fragment TagData on Tag { children { ...SlimTagData } + + custom_fields } fragment SelectTagData on Tag { diff --git a/ui/v2.5/graphql/mutations/tag.graphql b/ui/v2.5/graphql/mutations/tag.graphql index f2138e057..33c50833a 100644 --- a/ui/v2.5/graphql/mutations/tag.graphql +++ b/ui/v2.5/graphql/mutations/tag.graphql @@ -24,8 +24,14 @@ mutation BulkTagUpdate($input: BulkTagUpdateInput!) { } } -mutation TagsMerge($source: [ID!]!, $destination: ID!) { - tagsMerge(input: { source: $source, destination: $destination }) { +mutation TagsMerge( + $source: [ID!]! + $destination: ID! + $values: TagUpdateInput +) { + tagsMerge( + input: { source: $source, destination: $destination, values: $values } + ) { ...TagData } } diff --git a/ui/v2.5/graphql/queries/tag.graphql b/ui/v2.5/graphql/queries/tag.graphql index e0b20ee02..c91315f99 100644 --- a/ui/v2.5/graphql/queries/tag.graphql +++ b/ui/v2.5/graphql/queries/tag.graphql @@ -1,5 +1,9 @@ -query FindTags($filter: FindFilterType, $tag_filter: TagFilterType) { - findTags(filter: $filter, tag_filter: $tag_filter) { +query FindTags( + $filter: FindFilterType + $tag_filter: TagFilterType + $ids: [ID!] +) { + findTags(filter: $filter, tag_filter: $tag_filter, ids: $ids) { count tags { ...TagData diff --git a/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx index 834d2ac76..ab4a6fed5 100644 --- a/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx @@ -19,6 +19,7 @@ import { useToast } from "src/hooks/Toast"; import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons"; import { ScrapeDialog } from "../Shared/ScrapeDialog/ScrapeDialog"; import { + ScrapedCustomFieldRows, ScrapedImageRow, ScrapedInputGroupRow, ScrapedStringListRow, @@ -27,9 +28,9 @@ import { import { ModalComponent } from "../Shared/Modal"; import { sortStoredIdObjects } from "src/utils/data"; import { + CustomFieldScrapeResults, ObjectListScrapeResult, ScrapeResult, - ZeroableScrapeResult, hasScrapedValues, } from "../Shared/ScrapeDialog/scrapeResult"; import { ScrapedTagsRow } from "../Shared/ScrapeDialog/ScrapedObjectsRow"; @@ -40,39 +41,6 @@ import { import { PerformerSelect } from "./PerformerSelect"; import { uniq } from "lodash-es"; -/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ -type CustomFieldScrapeResults = Map>; - -// There are a bunch of similar functions in PerformerScrapeDialog, but since we don't support -// scraping custom fields, this one is only needed here. The `renderScraped` naming is kept the same -// for consistency. -function renderScrapedCustomFieldRows( - results: CustomFieldScrapeResults, - onChange: (newCustomFields: CustomFieldScrapeResults) => void -) { - return ( - <> - {Array.from(results.entries()).map(([field, result]) => { - const fieldName = `custom_${field}`; - return ( - { - const newResults = new Map(results); - newResults.set(field, newResult); - onChange(newResults); - }} - /> - ); - })} - - ); -} - type MergeOptions = { values: GQL.PerformerUpdateInput; }; @@ -604,10 +572,12 @@ const PerformerMergeDetails: React.FC = ({ result={image} onChange={(value) => setImage(value)} /> - {hasCustomFieldValues && - renderScrapedCustomFieldRows(customFields, (newCustomFields) => - setCustomFields(newCustomFields) - )} + {hasCustomFieldValues && ( + setCustomFields(newCustomFields)} + /> + )} ); } diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx index 88b79d87d..677ecb87f 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx @@ -14,7 +14,7 @@ import { getCountryByISO } from "src/utils/country"; import { CountrySelect } from "../CountrySelect"; import { StringListInput } from "../StringListInput"; import { ImageSelector } from "../ImageSelector"; -import { ScrapeResult } from "./scrapeResult"; +import { CustomFieldScrapeResults, ScrapeResult } from "./scrapeResult"; import { ScrapeDialogContext } from "./ScrapeDialog"; function renderButtonIcon(selected: boolean) { @@ -431,3 +431,30 @@ export const ScrapedCountryRow: React.FC = ({ onChange={onChange} /> ); + +export const ScrapedCustomFieldRows: React.FC<{ + results: CustomFieldScrapeResults; + onChange: (newCustomFields: CustomFieldScrapeResults) => void; +}> = ({ results, onChange }) => { + return ( + <> + {Array.from(results.entries()).map(([field, result]) => { + const fieldName = `custom_${field}`; + return ( + { + const newResults = new Map(results); + newResults.set(field, newResult); + onChange(newResults); + }} + /> + ); + })} + + ); +}; diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/scrapeResult.ts b/ui/v2.5/src/components/Shared/ScrapeDialog/scrapeResult.ts index b9b88cef0..63d1c76c1 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/scrapeResult.ts +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/scrapeResult.ts @@ -2,6 +2,9 @@ import lodashIsEqual from "lodash-es/isEqual"; import clone from "lodash-es/clone"; import { IHasStoredID } from "src/utils/data"; +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +export type CustomFieldScrapeResults = Map>; + export class ScrapeResult { public newValue?: T; public originalValue?: T; diff --git a/ui/v2.5/src/components/Tags/TagMergeDialog.tsx b/ui/v2.5/src/components/Tags/TagMergeDialog.tsx index 15b648af5..a66ce5789 100644 --- a/ui/v2.5/src/components/Tags/TagMergeDialog.tsx +++ b/ui/v2.5/src/components/Tags/TagMergeDialog.tsx @@ -1,13 +1,412 @@ import { Button, Form, Col, Row } from "react-bootstrap"; -import React, { useEffect, useState } from "react"; +import * as GQL from "src/core/generated-graphql"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Icon } from "../Shared/Icon"; import { ModalComponent } from "src/components/Shared/Modal"; import * as FormUtils from "src/utils/form"; -import { useTagsMerge } from "src/core/StashService"; -import { useIntl } from "react-intl"; +import { queryFindTagsByID, useTagsMerge } from "src/core/StashService"; +import { FormattedMessage, useIntl } from "react-intl"; import { useToast } from "src/hooks/Toast"; import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons"; import { Tag, TagSelect } from "./TagSelect"; +import { + CustomFieldScrapeResults, + hasScrapedValues, + ObjectListScrapeResult, + ScrapeResult, +} from "../Shared/ScrapeDialog/scrapeResult"; +import { sortStoredIdObjects } from "src/utils/data"; +import ImageUtils from "src/utils/image"; +import { uniq } from "lodash-es"; +import { LoadingIndicator } from "../Shared/LoadingIndicator"; +import { + ScrapedCustomFieldRows, + ScrapeDialogRow, + ScrapedImageRow, + ScrapedInputGroupRow, + ScrapedStringListRow, + ScrapedTextAreaRow, +} from "../Shared/ScrapeDialog/ScrapeDialogRow"; +import { ScrapedTagsRow } from "../Shared/ScrapeDialog/ScrapedObjectsRow"; +import { StringListSelect } from "../Shared/Select"; +import { ScrapeDialog } from "../Shared/ScrapeDialog/ScrapeDialog"; + +interface IStashIDsField { + values: GQL.StashId[]; +} + +const StashIDsField: React.FC = ({ values }) => { + return v.stash_id)} />; +}; + +interface ITagMergeDetailsProps { + sources: GQL.TagDataFragment[]; + dest: GQL.TagDataFragment; + onClose: (values?: GQL.TagUpdateInput) => void; +} + +const TagMergeDetails: React.FC = ({ + sources, + dest, + onClose, +}) => { + const intl = useIntl(); + + const [loading, setLoading] = useState(true); + + const filterCandidates = useCallback( + (t: { stored_id: string }) => + t.stored_id !== dest.id && sources.every((s) => s.id !== t.stored_id), + [dest.id, sources] + ); + + const [name, setName] = useState>( + new ScrapeResult(dest.name) + ); + const [sortName, setSortName] = useState>( + new ScrapeResult(dest.sort_name) + ); + const [aliases, setAliases] = useState>( + new ScrapeResult(dest.aliases) + ); + const [description, setDescription] = useState>( + new ScrapeResult(dest.description) + ); + const [parentTags, setParentTags] = useState< + ObjectListScrapeResult + >( + new ObjectListScrapeResult( + sortStoredIdObjects( + dest.parents.map(idToStoredID).filter(filterCandidates) + ) + ) + ); + const [childTags, setChildTags] = useState< + ObjectListScrapeResult + >( + new ObjectListScrapeResult( + sortStoredIdObjects( + dest.children.map(idToStoredID).filter(filterCandidates) + ) + ) + ); + + const [stashIDs, setStashIDs] = useState(new ScrapeResult([])); + + const [image, setImage] = useState>( + new ScrapeResult(dest.image_path) + ); + + const [customFields, setCustomFields] = useState( + new Map() + ); + + function idToStoredID(o: { id: string; name: string }) { + return { + stored_id: o.id, + name: o.name, + }; + } + + // calculate the values for everything + // uses the first set value for single value fields, and combines all + useEffect(() => { + async function loadImages() { + const src = sources.find((s) => s.image_path); + if (!dest.image_path || !src) return; + + setLoading(true); + + const destData = await ImageUtils.imageToDataURL(dest.image_path); + const srcData = await ImageUtils.imageToDataURL(src.image_path!); + + // keep destination image by default + const useNewValue = false; + setImage(new ScrapeResult(destData, srcData, useNewValue)); + + setLoading(false); + } + + // append dest to all so that if dest has stash_ids with the same + // endpoint, then it will be excluded first + const all = sources.concat(dest); + + setName( + new ScrapeResult(dest.name, sources.find((s) => s.name)?.name, !dest.name) + ); + setSortName( + new ScrapeResult( + dest.sort_name, + sources.find((s) => s.sort_name)?.sort_name, + !dest.sort_name + ) + ); + + setDescription( + new ScrapeResult( + dest.description, + sources.find((s) => s.description)?.description, + !dest.description + ) + ); + + // default alias list should be the existing aliases, plus the names of all sources, + // plus all source aliases, deduplicated + const allAliases = uniq( + dest.aliases.concat( + sources.map((s) => s.name), + sources.flatMap((s) => s.aliases) + ) + ); + setAliases(new ScrapeResult(dest.aliases, allAliases, !!allAliases.length)); + + // default parent/child tags should be the existing tags, plus all source parent/child tags, deduplicated + const allParentTags = uniq(all.flatMap((s) => s.parents)) + .map(idToStoredID) + .filter(filterCandidates); // exclude self and sources + + setParentTags( + new ObjectListScrapeResult( + sortStoredIdObjects(dest.parents.map(idToStoredID)), + sortStoredIdObjects(allParentTags), + !!allParentTags.length + ) + ); + + const allChildTags = uniq(all.flatMap((s) => s.children)) + .map(idToStoredID) + .filter(filterCandidates); // exclude self and sources + + setChildTags( + new ObjectListScrapeResult( + sortStoredIdObjects( + dest.children.map(idToStoredID).filter(filterCandidates) + ), + sortStoredIdObjects(allChildTags), + !!allChildTags.length + ) + ); + + setStashIDs( + new ScrapeResult( + dest.stash_ids, + all + .map((s) => s.stash_ids) + .flat() + .filter((s, index, a) => { + // remove entries with duplicate endpoints + return index === a.findIndex((ss) => ss.endpoint === s.endpoint); + }) + ) + ); + + setImage( + new ScrapeResult( + dest.image_path, + sources.find((s) => s.image_path)?.image_path, + !dest.image_path + ) + ); + + const customFieldNames = new Set(Object.keys(dest.custom_fields)); + + for (const s of sources) { + for (const n of Object.keys(s.custom_fields)) { + customFieldNames.add(n); + } + } + + setCustomFields( + new Map( + Array.from(customFieldNames) + .sort() + .map((field) => { + return [ + field, + new ScrapeResult( + dest.custom_fields?.[field], + sources.find((s) => s.custom_fields?.[field])?.custom_fields?.[ + field + ], + dest.custom_fields?.[field] === undefined + ), + ]; + }) + ) + ); + + loadImages(); + }, [sources, dest, filterCandidates]); + + const hasCustomFieldValues = useMemo(() => { + return hasScrapedValues(Array.from(customFields.values())); + }, [customFields]); + + // ensure this is updated if fields are changed + const hasValues = useMemo(() => { + return ( + hasCustomFieldValues || + hasScrapedValues([ + name, + sortName, + aliases, + description, + parentTags, + childTags, + stashIDs, + image, + ]) + ); + }, [ + name, + sortName, + aliases, + description, + parentTags, + childTags, + stashIDs, + image, + hasCustomFieldValues, + ]); + + function renderScrapeRows() { + if (loading) { + return ( +
+ +
+ ); + } + + if (!hasValues) { + return ( +
+ +
+ ); + } + + return ( + <> + setName(value)} + /> + setSortName(value)} + /> + setAliases(value)} + /> + setParentTags(value)} + /> + setChildTags(value)} + /> + setDescription(value)} + /> + + } + newField={} + onChange={(value) => setStashIDs(value)} + /> + setImage(value)} + /> + {hasCustomFieldValues && ( + setCustomFields(newCustomFields)} + /> + )} + + ); + } + + function createValues(): GQL.TagUpdateInput { + // only set the cover image if it's different from the existing cover image + const coverImage = image.useNewValue ? image.getNewValue() : undefined; + + return { + id: dest.id, + name: name.getNewValue(), + sort_name: sortName.getNewValue(), + aliases: aliases + .getNewValue() + ?.map((s) => s.trim()) + .filter((s) => s.length > 0), + parent_ids: parentTags.getNewValue()?.map((t) => t.stored_id!), + child_ids: childTags.getNewValue()?.map((t) => t.stored_id!), + description: description.getNewValue(), + stash_ids: stashIDs.getNewValue(), + image: coverImage, + custom_fields: { + partial: Object.fromEntries( + Array.from(customFields.entries()).flatMap(([field, v]) => + v.useNewValue ? [[field, v.getNewValue()]] : [] + ) + ), + }, + }; + } + + const dialogTitle = intl.formatMessage({ + id: "actions.merge", + }); + + const destinationLabel = !hasValues + ? "" + : intl.formatMessage({ id: "dialogs.merge.destination" }); + const sourceLabel = !hasValues + ? "" + : intl.formatMessage({ id: "dialogs.merge.source" }); + + return ( + { + if (!apply) { + onClose(); + } else { + onClose(createValues()); + } + }} + > + {renderScrapeRows()} + + ); +}; interface ITagMergeModalProps { show: boolean; @@ -23,6 +422,11 @@ export const TagMergeModal: React.FC = ({ const [src, setSrc] = useState([]); const [dest, setDest] = useState(null); + const [loadedSources, setLoadedSources] = useState([]); + const [loadedDest, setLoadedDest] = useState(); + + const [secondStep, setSecondStep] = useState(false); + const [running, setRunning] = useState(false); const [mergeTags] = useTagsMerge(); @@ -41,7 +445,23 @@ export const TagMergeModal: React.FC = ({ } }, [tags]); - async function onMerge() { + async function loadTags() { + try { + const tagIDs = src.map((s) => s.id); + tagIDs.push(dest!.id); + const query = await queryFindTagsByID(tagIDs); + const { tags: loadedTags } = query.data.findTags; + + setLoadedDest(loadedTags.find((s) => s.id === dest!.id)); + setLoadedSources(loadedTags.filter((s) => s.id !== dest!.id)); + setSecondStep(true); + } catch (e) { + Toast.error(e); + return; + } + } + + async function onMerge(values: GQL.TagUpdateInput) { if (!dest) return; const source = src.map((s) => s.id); @@ -53,6 +473,7 @@ export const TagMergeModal: React.FC = ({ variables: { source, destination, + values, }, }); if (result.data?.tagsMerge) { @@ -78,6 +499,23 @@ export const TagMergeModal: React.FC = ({ } } + if (secondStep && dest) { + return ( + { + setSecondStep(false); + if (values) { + onMerge(values); + } else { + onClose(); + } + }} + /> + ); + } + return ( = ({ icon={faSignInAlt} accept={{ text: intl.formatMessage({ id: "actions.merge" }), - onClick: () => onMerge(), + onClick: () => loadTags(), }} disabled={!canMerge()} cancel={{ diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 6aaf17125..58b1aae42 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -472,6 +472,14 @@ export const queryFindTagsForList = (filter: ListFilterModel) => }, }); +export const queryFindTagsByID = (tagIDs: string[]) => + client.query({ + query: GQL.FindTagsDocument, + variables: { + ids: tagIDs, + }, + }); + export const queryFindTagsByIDForSelect = (tagIDs: string[]) => client.query({ query: GQL.FindTagsForSelectDocument, From 7aa7276fa3f60c12b6e84a5e38d4912b4c6dfe37 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Tue, 10 Feb 2026 18:38:57 -0600 Subject: [PATCH 059/177] Bugfix: AVIF Image PHash Support (#6556) * AVIF phash support * add avif check for zips --- cmd/phasher/main.go | 8 ++-- internal/manager/task_generate_image_phash.go | 2 +- pkg/hash/imagephash/phash.go | 42 +++++++++++++++++-- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/cmd/phasher/main.go b/cmd/phasher/main.go index e0801d5d7..be2053784 100644 --- a/cmd/phasher/main.go +++ b/cmd/phasher/main.go @@ -27,11 +27,11 @@ func printPhash(ff *ffmpeg.FFMpeg, ffp *ffmpeg.FFProbe, inputfile string, quiet // Common image extensions imageExts := map[string]bool{ - "jpg": true, "jpeg": true, "png": true, "gif": true, "webp": true, "bmp": true, + "jpg": true, "jpeg": true, "png": true, "gif": true, "webp": true, "bmp": true, "avif": true, } if imageExts[ext] { - return printImagePhash(inputfile, quiet) + return printImagePhash(ff, inputfile, quiet) } return printVideoPhash(ff, ffp, inputfile, quiet) @@ -65,12 +65,12 @@ func printVideoPhash(ff *ffmpeg.FFMpeg, ffp *ffmpeg.FFProbe, inputfile string, q return nil } -func printImagePhash(inputfile string, quiet *bool) error { +func printImagePhash(ff *ffmpeg.FFMpeg, inputfile string, quiet *bool) error { imgFile := &models.ImageFile{ BaseFile: &models.BaseFile{Path: inputfile}, } - phash, err := imagephash.Generate(imgFile) + phash, err := imagephash.Generate(ff, imgFile) if err != nil { return err } diff --git a/internal/manager/task_generate_image_phash.go b/internal/manager/task_generate_image_phash.go index 4c07ffadf..a5c764df0 100644 --- a/internal/manager/task_generate_image_phash.go +++ b/internal/manager/task_generate_image_phash.go @@ -41,7 +41,7 @@ func (t *GenerateImagePhashTask) Start(ctx context.Context) { } if !set { - generated, err := imagephash.Generate(t.File) + generated, err := imagephash.Generate(instance.FFMpeg, t.File) if err != nil { logger.Errorf("Error generating phash for %q: %v", t.File.Path, err) logErrorOutput(err) diff --git a/pkg/hash/imagephash/phash.go b/pkg/hash/imagephash/phash.go index 4cf6e9209..73e8e3667 100644 --- a/pkg/hash/imagephash/phash.go +++ b/pkg/hash/imagephash/phash.go @@ -2,17 +2,22 @@ package imagephash import ( "bytes" + "context" "fmt" "image" + "path/filepath" + "strings" "github.com/corona10/goimagehash" + "github.com/stashapp/stash/pkg/ffmpeg" + "github.com/stashapp/stash/pkg/ffmpeg/transcoder" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/models" ) // Generate computes a perceptual hash for an image file. -func Generate(imageFile *models.ImageFile) (*uint64, error) { - img, err := loadImage(imageFile) +func Generate(encoder *ffmpeg.FFMpeg, imageFile *models.ImageFile) (*uint64, error) { + img, err := loadImage(encoder, imageFile) if err != nil { return nil, fmt.Errorf("loading image: %w", err) } @@ -27,7 +32,17 @@ func Generate(imageFile *models.ImageFile) (*uint64, error) { } // loadImage loads an image from disk and decodes it. -func loadImage(imageFile *models.ImageFile) (image.Image, error) { +// For AVIF files, ffmpeg is used to convert to BMP first since Go has no built-in AVIF decoder. +func loadImage(encoder *ffmpeg.FFMpeg, imageFile *models.ImageFile) (image.Image, error) { + ext := strings.ToLower(filepath.Ext(imageFile.Path)) + if ext == ".avif" { + // AVIF in zip files is not supported - ffmpeg cannot read files inside zips + if imageFile.Base().ZipFileID != nil { + return nil, fmt.Errorf("AVIF images in zip files are not supported for phash generation") + } + return loadImageFFmpeg(encoder, imageFile.Path) + } + reader, err := imageFile.Open(&file.OsFS{}) if err != nil { return nil, err @@ -46,3 +61,24 @@ func loadImage(imageFile *models.ImageFile) (image.Image, error) { return img, nil } + +// loadImageFFmpeg uses ffmpeg to convert an image to BMP and then decodes it. +func loadImageFFmpeg(encoder *ffmpeg.FFMpeg, path string) (image.Image, error) { + options := transcoder.ScreenshotOptions{ + OutputPath: "-", + OutputType: transcoder.ScreenshotOutputTypeBMP, + } + + args := transcoder.ScreenshotTime(path, 0, options) + data, err := encoder.GenerateOutput(context.Background(), args, nil) + if err != nil { + return nil, fmt.Errorf("converting image with ffmpeg: %w", err) + } + + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("decoding ffmpeg output: %w", err) + } + + return img, nil +} From 26db935fadf797a6d48b5702b3f0ceb2bdb569f3 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Tue, 10 Feb 2026 18:43:18 -0600 Subject: [PATCH 060/177] FR: Change Identify Settings to Use Gender Checkboxes (#6557) --- graphql/schema/types/metadata.graphql | 8 ++- internal/identify/identify.go | 21 ++++-- internal/identify/identify_test.go | 24 +++++-- internal/identify/options.go | 4 ++ internal/identify/scene.go | 10 ++- internal/identify/scene_test.go | 32 ++++----- ui/v2.5/graphql/data/config.graphql | 2 +- .../Dialogs/IdentifyDialog/IdentifyDialog.tsx | 2 +- .../Dialogs/IdentifyDialog/Options.tsx | 65 ++++++++++++++----- ui/v2.5/src/docs/en/Manual/Identify.md | 2 +- ui/v2.5/src/locales/en-GB.json | 2 + 11 files changed, 120 insertions(+), 52 deletions(-) diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index 3d004ccb3..0c0d59579 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -215,7 +215,9 @@ input IdentifyMetadataOptionsInput { setCoverImage: Boolean setOrganized: Boolean "defaults to true if not provided" - includeMalePerformers: Boolean + includeMalePerformers: Boolean @deprecated(reason: "Use performerGenders") + "Filter to only include performers with these genders. If not provided, all genders are included." + performerGenders: [GenderEnum!] "defaults to true if not provided" skipMultipleMatches: Boolean "tag to tag skipped multiple matches with" @@ -260,7 +262,9 @@ type IdentifyMetadataOptions { setCoverImage: Boolean setOrganized: Boolean "defaults to true if not provided" - includeMalePerformers: Boolean + includeMalePerformers: Boolean @deprecated(reason: "Use performerGenders") + "Filter to only include performers with these genders. If not provided, all genders are included." + performerGenders: [GenderEnum!] "defaults to true if not provided" skipMultipleMatches: Boolean "tag to tag skipped multiple matches with" diff --git a/internal/identify/identify.go b/internal/identify/identify.go index 3d4c94467..6dc67dac3 100644 --- a/internal/identify/identify.go +++ b/internal/identify/identify.go @@ -147,6 +147,9 @@ func (t *SceneIdentifier) getOptions(source ScraperSource) MetadataOptions { if source.Options.IncludeMalePerformers != nil { options.IncludeMalePerformers = source.Options.IncludeMalePerformers } + if source.Options.PerformerGenders != nil { + options.PerformerGenders = source.Options.PerformerGenders + } if source.Options.SkipMultipleMatches != nil { options.SkipMultipleMatches = source.Options.SkipMultipleMatches } @@ -204,13 +207,23 @@ func (t *SceneIdentifier) getSceneUpdater(ctx context.Context, s *models.Scene, ret.Partial.StudioID = models.NewOptionalInt(*studioID) } - includeMalePerformers := true - if options.IncludeMalePerformers != nil { - includeMalePerformers = *options.IncludeMalePerformers + // Determine allowed genders for performer filtering + var allowedGenders []models.GenderEnum + if options.PerformerGenders != nil { + // New field takes precedence + allowedGenders = options.PerformerGenders + } else if options.IncludeMalePerformers != nil && !*options.IncludeMalePerformers { + // Legacy: if includeMalePerformers is false, include all genders except male + for _, g := range models.AllGenderEnum { + if g != models.GenderEnumMale { + allowedGenders = append(allowedGenders, g) + } + } } + // nil allowedGenders means include all performers addSkipSingleNamePerformerTag := false - performerIDs, err := rel.performers(ctx, !includeMalePerformers) + performerIDs, err := rel.performers(ctx, allowedGenders) if err != nil { if errors.Is(err, ErrSkipSingleNamePerformer) { addSkipSingleNamePerformerTag = true diff --git a/internal/identify/identify_test.go b/internal/identify/identify_test.go index eb646c305..35ad2006d 100644 --- a/internal/identify/identify_test.go +++ b/internal/identify/identify_test.go @@ -60,9 +60,15 @@ func TestSceneIdentifier_Identify(t *testing.T) { ) defaultOptions := &MetadataOptions{ - SetOrganized: &boolFalse, - SetCoverImage: &boolFalse, - IncludeMalePerformers: &boolFalse, + SetOrganized: &boolFalse, + SetCoverImage: &boolFalse, + PerformerGenders: []models.GenderEnum{ + models.GenderEnumFemale, + models.GenderEnumTransgenderFemale, + models.GenderEnumTransgenderMale, + models.GenderEnumIntersex, + models.GenderEnumNonBinary, + }, SkipSingleNamePerformers: &boolFalse, } sources := []ScraperSource{ @@ -216,9 +222,15 @@ func TestSceneIdentifier_modifyScene(t *testing.T) { boolFalse := false defaultOptions := &MetadataOptions{ - SetOrganized: &boolFalse, - SetCoverImage: &boolFalse, - IncludeMalePerformers: &boolFalse, + SetOrganized: &boolFalse, + SetCoverImage: &boolFalse, + PerformerGenders: []models.GenderEnum{ + models.GenderEnumFemale, + models.GenderEnumTransgenderFemale, + models.GenderEnumTransgenderMale, + models.GenderEnumIntersex, + models.GenderEnumNonBinary, + }, SkipSingleNamePerformers: &boolFalse, } tr := &SceneIdentifier{ diff --git a/internal/identify/options.go b/internal/identify/options.go index b4954a1f1..9e27a3e39 100644 --- a/internal/identify/options.go +++ b/internal/identify/options.go @@ -5,6 +5,7 @@ import ( "io" "strconv" + "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scraper" ) @@ -32,7 +33,10 @@ type MetadataOptions struct { SetCoverImage *bool `json:"setCoverImage"` SetOrganized *bool `json:"setOrganized"` // defaults to true if not provided + // Deprecated: use PerformerGenders instead IncludeMalePerformers *bool `json:"includeMalePerformers"` + // Filter to only include performers with these genders. If not provided, all genders are included. + PerformerGenders []models.GenderEnum `json:"performerGenders"` // defaults to true if not provided SkipMultipleMatches *bool `json:"skipMultipleMatches"` // ID of tag to tag skipped multiple matches with diff --git a/internal/identify/scene.go b/internal/identify/scene.go index b82a04301..00d387c41 100644 --- a/internal/identify/scene.go +++ b/internal/identify/scene.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "slices" "strconv" "strings" "time" @@ -69,7 +70,7 @@ func (g sceneRelationships) studio(ctx context.Context) (*int, error) { return nil, nil } -func (g sceneRelationships) performers(ctx context.Context, ignoreMale bool) ([]int, error) { +func (g sceneRelationships) performers(ctx context.Context, allowedGenders []models.GenderEnum) ([]int, error) { fieldStrategy := g.fieldOptions["performers"] scraped := g.result.result.Performers @@ -97,8 +98,11 @@ func (g sceneRelationships) performers(ctx context.Context, ignoreMale bool) ([] singleNamePerformerSkipped := false for _, p := range scraped { - if ignoreMale && p.Gender != nil && strings.EqualFold(*p.Gender, models.GenderEnumMale.String()) { - continue + if allowedGenders != nil && p.Gender != nil { + gender := models.GenderEnum(strings.ToUpper(*p.Gender)) + if !slices.Contains(allowedGenders, gender) { + continue + } } performerID, err := getPerformerID(ctx, endpoint, g.performerCreator, p, createMissing, g.skipSingleNamePerformers) diff --git a/internal/identify/scene_test.go b/internal/identify/scene_test.go index 862bbbff8..9a3fcf025 100644 --- a/internal/identify/scene_test.go +++ b/internal/identify/scene_test.go @@ -183,13 +183,13 @@ func Test_sceneRelationships_performers(t *testing.T) { } tests := []struct { - name string - scene *models.Scene - fieldOptions *FieldOptions - scraped []*models.ScrapedPerformer - ignoreMale bool - want []int - wantErr bool + name string + scene *models.Scene + fieldOptions *FieldOptions + scraped []*models.ScrapedPerformer + allowedGenders []models.GenderEnum + want []int + wantErr bool }{ { "ignore", @@ -202,7 +202,7 @@ func Test_sceneRelationships_performers(t *testing.T) { StoredID: &validStoredID, }, }, - false, + nil, nil, false, }, @@ -211,7 +211,7 @@ func Test_sceneRelationships_performers(t *testing.T) { emptyScene, defaultOptions, []*models.ScrapedPerformer{}, - false, + nil, nil, false, }, @@ -225,7 +225,7 @@ func Test_sceneRelationships_performers(t *testing.T) { StoredID: &existingPerformerStr, }, }, - false, + nil, nil, false, }, @@ -239,7 +239,7 @@ func Test_sceneRelationships_performers(t *testing.T) { StoredID: &validStoredID, }, }, - false, + nil, []int{existingPerformerID, validStoredIDInt}, false, }, @@ -254,7 +254,7 @@ func Test_sceneRelationships_performers(t *testing.T) { Gender: &male, }, }, - true, + []models.GenderEnum{models.GenderEnumFemale, models.GenderEnumTransgenderMale, models.GenderEnumTransgenderFemale, models.GenderEnumIntersex, models.GenderEnumNonBinary}, nil, false, }, @@ -270,7 +270,7 @@ func Test_sceneRelationships_performers(t *testing.T) { StoredID: &validStoredID, }, }, - false, + nil, []int{validStoredIDInt}, false, }, @@ -287,7 +287,7 @@ func Test_sceneRelationships_performers(t *testing.T) { Gender: &female, }, }, - true, + []models.GenderEnum{models.GenderEnumFemale, models.GenderEnumTransgenderMale, models.GenderEnumTransgenderFemale, models.GenderEnumIntersex, models.GenderEnumNonBinary}, []int{validStoredIDInt}, false, }, @@ -304,7 +304,7 @@ func Test_sceneRelationships_performers(t *testing.T) { StoredID: &invalidStoredID, }, }, - false, + nil, nil, true, }, @@ -319,7 +319,7 @@ func Test_sceneRelationships_performers(t *testing.T) { }, } - got, err := tr.performers(testCtx, tt.ignoreMale) + got, err := tr.performers(testCtx, tt.allowedGenders) if (err != nil) != tt.wantErr { t.Errorf("sceneRelationships.performers() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/ui/v2.5/graphql/data/config.graphql b/ui/v2.5/graphql/data/config.graphql index 08dcf5d3b..ca1f6a47c 100644 --- a/ui/v2.5/graphql/data/config.graphql +++ b/ui/v2.5/graphql/data/config.graphql @@ -143,7 +143,7 @@ fragment IdentifyMetadataOptionsData on IdentifyMetadataOptions { } setCoverImage setOrganized - includeMalePerformers + performerGenders skipMultipleMatches skipMultipleMatchTag skipSingleNamePerformers diff --git a/ui/v2.5/src/components/Dialogs/IdentifyDialog/IdentifyDialog.tsx b/ui/v2.5/src/components/Dialogs/IdentifyDialog/IdentifyDialog.tsx index 3073a7952..8262de4ec 100644 --- a/ui/v2.5/src/components/Dialogs/IdentifyDialog/IdentifyDialog.tsx +++ b/ui/v2.5/src/components/Dialogs/IdentifyDialog/IdentifyDialog.tsx @@ -62,7 +62,7 @@ export const IdentifyDialog: React.FC = ({ createMissing: true, }, ], - includeMalePerformers: true, + performerGenders: undefined, setCoverImage: true, setOrganized: false, skipMultipleMatches: true, diff --git a/ui/v2.5/src/components/Dialogs/IdentifyDialog/Options.tsx b/ui/v2.5/src/components/Dialogs/IdentifyDialog/Options.tsx index 1362df02a..4987db5f9 100644 --- a/ui/v2.5/src/components/Dialogs/IdentifyDialog/Options.tsx +++ b/ui/v2.5/src/components/Dialogs/IdentifyDialog/Options.tsx @@ -6,6 +6,7 @@ import { IScraperSource } from "./constants"; import { FieldOptionsList } from "./FieldOptions"; import { ThreeStateBoolean } from "./ThreeStateBoolean"; import { TagSelect } from "src/components/Shared/Select"; +import { genderList } from "src/utils/gender"; interface IOptionsEditor { options: GQL.IdentifyMetadataOptionsInput; @@ -124,24 +125,52 @@ export const OptionsEditor: React.FC = ({ )} - - setOptions({ - includeMalePerformers: v, - }) - } - label={intl.formatMessage({ - id: "config.tasks.identify.include_male_performers", - })} - defaultValue={defaultOptions?.includeMalePerformers ?? undefined} - {...checkboxProps} - /> + + + + + {source && ( + ) => { + if (e.currentTarget.checked) { + setOptions({ performerGenders: undefined }); + } else { + setOptions({ + performerGenders: + defaultOptions?.performerGenders ?? genderList.slice(), + }); + } + }} + /> + )} + {(options.performerGenders != null || !source) && + genderList.map((gender) => { + const performerGenders = + options.performerGenders ?? genderList.slice(); + return ( + } + checked={performerGenders.includes(gender)} + onChange={(e: React.ChangeEvent) => { + const isChecked = e.currentTarget.checked; + setOptions({ + performerGenders: isChecked + ? [...performerGenders, gender] + : performerGenders.filter((g) => g !== gender), + }); + }} + /> + ); + })} + + + + Date: Tue, 10 Feb 2026 18:52:44 -0600 Subject: [PATCH 061/177] Feature: Scene Duplicate Filter (#6344) --- graphql/schema/types/filters.graphql | 30 ++- pkg/models/file.go | 2 +- pkg/models/scene.go | 26 +- pkg/sqlite/file_filter.go | 28 ++- pkg/sqlite/scene_filter.go | 73 ++++-- pkg/sqlite/scene_test.go | 2 +- .../src/components/List/CriterionEditor.tsx | 12 +- .../List/Filters/DuplicateFilter.tsx | 227 ++++++++++++++++++ ui/v2.5/src/components/List/styles.scss | 18 ++ ui/v2.5/src/components/Scenes/SceneList.tsx | 7 + ui/v2.5/src/locales/en-GB.json | 3 + .../models/list-filter/criteria/criterion.ts | 4 +- .../src/models/list-filter/criteria/phash.ts | 124 ++++++++-- ui/v2.5/src/models/list-filter/types.ts | 12 +- 14 files changed, 510 insertions(+), 58 deletions(-) create mode 100644 ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 04a28171c..a7fecca20 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -75,10 +75,26 @@ input OrientationCriterionInput { value: [OrientationEnum!]! } -input PHashDuplicationCriterionInput { - duplicated: Boolean - "Currently unimplemented" +input DuplicationCriterionInput { + duplicated: Boolean @deprecated(reason: "Use phash field instead") + "Currently unimplemented. Intended for phash distance matching." distance: Int + "Filter by phash duplication" + phash: Boolean + "Filter by URL duplication" + url: Boolean + "Filter by Stash ID duplication" + stash_id: Boolean + "Filter by title duplication" + title: Boolean +} + +input FileDuplicationCriterionInput { + duplicated: Boolean @deprecated(reason: "Use phash field instead") + "Currently unimplemented. Intended for phash distance matching." + distance: Int + "Filter by phash duplication" + phash: Boolean } input StashIDCriterionInput { @@ -261,8 +277,8 @@ input SceneFilterType { organized: Boolean "Filter by o-counter" o_counter: IntCriterionInput - "Filter Scenes that have an exact phash match available" - duplicated: PHashDuplicationCriterionInput + "Filter Scenes by duplication criteria" + duplicated: DuplicationCriterionInput "Filter by resolution" resolution: ResolutionCriterionInput "Filter by orientation" @@ -744,8 +760,8 @@ input FileFilterType { "Filter by modification time" mod_time: TimestampCriterionInput - "Filter files that have an exact match available" - duplicated: PHashDuplicationCriterionInput + "Filter files by duplication criteria (only phash applies to files)" + duplicated: FileDuplicationCriterionInput "find files based on hash" hashes: [FingerprintFilterInput!] diff --git a/pkg/models/file.go b/pkg/models/file.go index 63c30ba4d..32263319c 100644 --- a/pkg/models/file.go +++ b/pkg/models/file.go @@ -26,7 +26,7 @@ type FileFilterType struct { ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder"` ZipFile *MultiCriterionInput `json:"zip_file"` ModTime *TimestampCriterionInput `json:"mod_time"` - Duplicated *PHashDuplicationCriterionInput `json:"duplicated"` + Duplicated *FileDuplicationCriterionInput `json:"duplicated"` Hashes []*FingerprintFilterInput `json:"hashes"` VideoFileFilter *VideoFileFilterInput `json:"video_file_filter"` ImageFileFilter *ImageFileFilterInput `json:"image_file_filter"` diff --git a/pkg/models/scene.go b/pkg/models/scene.go index 434659cbe..22863c4d9 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -2,10 +2,28 @@ package models import "context" -type PHashDuplicationCriterionInput struct { +type DuplicationCriterionInput struct { + // Deprecated: Use Phash field instead. Kept for backwards compatibility. Duplicated *bool `json:"duplicated"` - // Currently unimplemented + // Currently unimplemented. Intended for phash distance matching. Distance *int `json:"distance"` + // Filter by phash duplication + Phash *bool `json:"phash"` + // Filter by URL duplication + URL *bool `json:"url"` + // Filter by Stash ID duplication + StashID *bool `json:"stash_id"` + // Filter by title duplication + Title *bool `json:"title"` +} + +type FileDuplicationCriterionInput struct { + // Deprecated: Use Phash field instead. Kept for backwards compatibility. + Duplicated *bool `json:"duplicated"` + // Currently unimplemented. Intended for phash distance matching. + Distance *int `json:"distance"` + // Filter by phash duplication + Phash *bool `json:"phash"` } type SceneFilterType struct { @@ -33,8 +51,8 @@ type SceneFilterType struct { Organized *bool `json:"organized"` // Filter by o-counter OCounter *IntCriterionInput `json:"o_counter"` - // Filter Scenes that have an exact phash match available - Duplicated *PHashDuplicationCriterionInput `json:"duplicated"` + // Filter Scenes by duplication criteria + Duplicated *DuplicationCriterionInput `json:"duplicated"` // Filter by resolution Resolution *ResolutionCriterionInput `json:"resolution"` // Filter by orientation diff --git a/pkg/sqlite/file_filter.go b/pkg/sqlite/file_filter.go index 12c7ba3d5..157efb1d8 100644 --- a/pkg/sqlite/file_filter.go +++ b/pkg/sqlite/file_filter.go @@ -82,7 +82,7 @@ func (qb *fileFilterHandler) criterionHandler() criterionHandler { qb.hashesCriterionHandler(fileFilter.Hashes), - qb.phashDuplicatedCriterionHandler(fileFilter.Duplicated), + qb.duplicatedCriterionHandler(fileFilter.Duplicated), ×tampCriterionHandler{fileFilter.CreatedAt, "files.created_at", nil}, ×tampCriterionHandler{fileFilter.UpdatedAt, "files.updated_at", nil}, @@ -205,17 +205,27 @@ func (qb *fileFilterHandler) galleryCountCriterionHandler(c *models.IntCriterion return h.handler(c) } -func (qb *fileFilterHandler) phashDuplicatedCriterionHandler(duplicatedFilter *models.PHashDuplicationCriterionInput) criterionHandlerFunc { +func (qb *fileFilterHandler) duplicatedCriterionHandler(duplicatedFilter *models.FileDuplicationCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { // TODO: Wishlist item: Implement Distance matching - if duplicatedFilter != nil { - var v string - if *duplicatedFilter.Duplicated { - v = ">" - } else { - v = "=" - } + // For files, only phash duplication applies + if duplicatedFilter == nil { + return + } + var phashValue *bool + + // Handle legacy 'duplicated' field for backwards compatibility + //nolint:staticcheck + if duplicatedFilter.Duplicated != nil && duplicatedFilter.Phash == nil { + //nolint:staticcheck + phashValue = duplicatedFilter.Duplicated + } else if duplicatedFilter.Phash != nil { + phashValue = duplicatedFilter.Phash + } + + if phashValue != nil { + v := getCountOperator(*phashValue) f.addInnerJoin("(SELECT file_id FROM files_fingerprints INNER JOIN (SELECT fingerprint FROM files_fingerprints WHERE type = 'phash' GROUP BY fingerprint HAVING COUNT (fingerprint) "+v+" 1) dupes on files_fingerprints.fingerprint = dupes.fingerprint)", "scph", "files.id = scph.file_id") } } diff --git a/pkg/sqlite/scene_filter.go b/pkg/sqlite/scene_filter.go index aa0d349df..e42376950 100644 --- a/pkg/sqlite/scene_filter.go +++ b/pkg/sqlite/scene_filter.go @@ -174,7 +174,7 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler { qb.performerTagsCriterionHandler(sceneFilter.PerformerTags), qb.performerFavoriteCriterionHandler(sceneFilter.PerformerFavorite), qb.performerAgeCriterionHandler(sceneFilter.PerformerAge), - qb.phashDuplicatedCriterionHandler(sceneFilter.Duplicated, qb.addSceneFilesTable), + qb.duplicatedCriterionHandler(sceneFilter.Duplicated), &dateCriterionHandler{sceneFilter.Date, "scenes.date", nil}, ×tampCriterionHandler{sceneFilter.CreatedAt, "scenes.created_at", nil}, ×tampCriterionHandler{sceneFilter.UpdatedAt, "scenes.updated_at", nil}, @@ -296,26 +296,71 @@ func (qb *sceneFilterHandler) fileCountCriterionHandler(fileCount *models.IntCri return h.handler(fileCount) } -func (qb *sceneFilterHandler) phashDuplicatedCriterionHandler(duplicatedFilter *models.PHashDuplicationCriterionInput, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { +func (qb *sceneFilterHandler) duplicatedCriterionHandler(duplicatedFilter *models.DuplicationCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { - // TODO: Wishlist item: Implement Distance matching - if duplicatedFilter != nil { - if addJoinFn != nil { - addJoinFn(f) - } + if duplicatedFilter == nil { + return + } - var v string - if *duplicatedFilter.Duplicated { - v = ">" - } else { - v = "=" - } + // Handle legacy 'duplicated' field - treat as phash if phash not explicitly set + //nolint:staticcheck + if duplicatedFilter.Duplicated != nil && duplicatedFilter.Phash == nil { + //nolint:staticcheck + duplicatedFilter.Phash = duplicatedFilter.Duplicated + } - f.addInnerJoin("(SELECT file_id FROM files_fingerprints INNER JOIN (SELECT fingerprint FROM files_fingerprints WHERE type = 'phash' GROUP BY fingerprint HAVING COUNT (fingerprint) "+v+" 1) dupes on files_fingerprints.fingerprint = dupes.fingerprint)", "scph", "scenes_files.file_id = scph.file_id") + // Handle explicit fields + if duplicatedFilter.Phash != nil { + qb.addSceneFilesTable(f) + qb.applyPhashDuplication(f, *duplicatedFilter.Phash) + } + + if duplicatedFilter.StashID != nil { + qb.applyStashIDDuplication(f, *duplicatedFilter.StashID) + } + + if duplicatedFilter.Title != nil { + qb.applyTitleDuplication(f, *duplicatedFilter.Title) + } + + if duplicatedFilter.URL != nil { + qb.applyURLDuplication(f, *duplicatedFilter.URL) } } } +// getCountOperator returns ">" for duplicated items (count > 1) or "=" for unique items (count = 1) +func getCountOperator(duplicated bool) string { + if duplicated { + return ">" + } + return "=" +} + +func (qb *sceneFilterHandler) applyPhashDuplication(f *filterBuilder, duplicated bool) { + // TODO: Wishlist item: Implement Distance matching + v := getCountOperator(duplicated) + f.addInnerJoin("(SELECT file_id FROM files_fingerprints INNER JOIN (SELECT fingerprint FROM files_fingerprints WHERE type = 'phash' GROUP BY fingerprint HAVING COUNT (fingerprint) "+v+" 1) dupes on files_fingerprints.fingerprint = dupes.fingerprint)", "scph", "scenes_files.file_id = scph.file_id") +} + +func (qb *sceneFilterHandler) applyStashIDDuplication(f *filterBuilder, duplicated bool) { + v := getCountOperator(duplicated) + // Find stash_ids that appear on more than one scene + f.addInnerJoin("(SELECT scene_id FROM scene_stash_ids INNER JOIN (SELECT stash_id FROM scene_stash_ids GROUP BY stash_id HAVING COUNT(DISTINCT scene_id) "+v+" 1) dupes ON scene_stash_ids.stash_id = dupes.stash_id)", "scsi", "scenes.id = scsi.scene_id") +} + +func (qb *sceneFilterHandler) applyTitleDuplication(f *filterBuilder, duplicated bool) { + v := getCountOperator(duplicated) + // Find titles that appear on more than one scene (excluding empty titles) + f.addInnerJoin("(SELECT id FROM scenes WHERE title != '' AND title IS NOT NULL AND title IN (SELECT title FROM scenes WHERE title != '' AND title IS NOT NULL GROUP BY title HAVING COUNT(*) "+v+" 1))", "sctitle", "scenes.id = sctitle.id") +} + +func (qb *sceneFilterHandler) applyURLDuplication(f *filterBuilder, duplicated bool) { + v := getCountOperator(duplicated) + // Find URLs that appear on more than one scene + f.addInnerJoin("(SELECT scene_id FROM scene_urls INNER JOIN (SELECT url FROM scene_urls GROUP BY url HAVING COUNT(DISTINCT scene_id) "+v+" 1) dupes ON scene_urls.url = dupes.url)", "scurl", "scenes.id = scurl.scene_id") +} + func (qb *sceneFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if codec != nil { diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index ae9ba56cf..6cdb62a5e 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -4121,7 +4121,7 @@ func TestSceneQueryPhashDuplicated(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Scene duplicated := true - phashCriterion := models.PHashDuplicationCriterionInput{ + phashCriterion := models.DuplicationCriterionInput{ Duplicated: &duplicated, } diff --git a/ui/v2.5/src/components/List/CriterionEditor.tsx b/ui/v2.5/src/components/List/CriterionEditor.tsx index eba212223..8795296fa 100644 --- a/ui/v2.5/src/components/List/CriterionEditor.tsx +++ b/ui/v2.5/src/components/List/CriterionEditor.tsx @@ -42,8 +42,12 @@ import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; import StudiosFilter from "./Filters/StudiosFilter"; import { TagsCriterion } from "src/models/list-filter/criteria/tags"; import TagsFilter from "./Filters/TagsFilter"; -import { PhashCriterion } from "src/models/list-filter/criteria/phash"; +import { + PhashCriterion, + DuplicatedCriterion, +} from "src/models/list-filter/criteria/phash"; import { PhashFilter } from "./Filters/PhashFilter"; +import { DuplicatedFilter } from "./Filters/DuplicateFilter"; import { PathCriterion } from "src/models/list-filter/criteria/path"; import { ModifierSelectorButtons } from "./ModifierSelect"; import { CustomFieldsCriterion } from "src/models/list-filter/criteria/custom-fields"; @@ -273,6 +277,12 @@ export const CriterionEditor: React.FC = ({ ); } + if (criterion instanceof DuplicatedCriterion) { + return ( + + ); + } + if (criterion instanceof CustomFieldsCriterion) { return ( diff --git a/ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx b/ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx new file mode 100644 index 000000000..819d5b885 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx @@ -0,0 +1,227 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { useIntl } from "react-intl"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { Option, SelectedList } from "./SidebarListFilter"; +import { + DuplicatedCriterion, + DuplicatedCriterionOption, + DuplicationFieldId, + DUPLICATION_FIELD_IDS, + DUPLICATION_FIELD_MESSAGE_IDS, +} from "src/models/list-filter/criteria/phash"; +import { IndeterminateCheckbox } from "src/components/Shared/IndeterminateCheckbox"; +import { SidebarSection } from "src/components/Shared/Sidebar"; +import { Icon } from "src/components/Shared/Icon"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { keyboardClickHandler } from "src/utils/keyboard"; + +interface IDuplicatedFilter { + criterion: DuplicatedCriterion; + setCriterion: (c: DuplicatedCriterion) => void; +} + +export const DuplicatedFilter: React.FC = ({ + criterion, + setCriterion, +}) => { + const intl = useIntl(); + + function onFieldChange( + fieldId: DuplicationFieldId, + value: boolean | undefined + ) { + const c = criterion.clone(); + if (value === undefined) { + delete c.value[fieldId]; + } else { + c.value[fieldId] = value; + } + setCriterion(c); + } + + return ( +
+ {DUPLICATION_FIELD_IDS.map((fieldId) => ( + onFieldChange(fieldId, v)} + /> + ))} +
+ ); +}; + +interface ISidebarDuplicateFilterProps { + title?: React.ReactNode; + filter: ListFilterModel; + setFilter: (f: ListFilterModel) => void; + sectionID?: string; +} + +export const SidebarDuplicateFilter: React.FC = ({ + title, + filter, + setFilter, + sectionID, +}) => { + const intl = useIntl(); + const [expandedType, setExpandedType] = useState(null); + + const trueLabel = intl.formatMessage({ id: "true" }); + const falseLabel = intl.formatMessage({ id: "false" }); + + // Get label for a duplicate type + const getLabel = useCallback( + (typeId: DuplicationFieldId) => + intl.formatMessage({ id: DUPLICATION_FIELD_MESSAGE_IDS[typeId] }), + [intl] + ); + + // Get the single duplicated criterion from the filter + const getCriterion = useCallback((): DuplicatedCriterion | null => { + const criteria = filter.criteriaFor( + DuplicatedCriterionOption.type + ) as DuplicatedCriterion[]; + return criteria.length > 0 ? criteria[0] : null; + }, [filter]); + + // Get value for a specific type from the criterion + const getTypeValue = useCallback( + (typeId: DuplicationFieldId): boolean | undefined => { + const criterion = getCriterion(); + if (!criterion) return undefined; + return criterion.value[typeId]; + }, + [getCriterion] + ); + + // Build selected items list + const selected: Option[] = useMemo(() => { + const result: Option[] = []; + const criterion = getCriterion(); + if (!criterion) return result; + + for (const typeId of DUPLICATION_FIELD_IDS) { + const value = criterion.value[typeId]; + if (value !== undefined) { + const valueLabel = value ? trueLabel : falseLabel; + result.push({ + id: typeId, + label: `${getLabel(typeId)}: ${valueLabel}`, + }); + } + } + + return result; + }, [getCriterion, trueLabel, falseLabel, getLabel]); + + // Available options - show options that aren't already selected + const options = useMemo(() => { + const result: { id: DuplicationFieldId; label: string }[] = []; + + for (const typeId of DUPLICATION_FIELD_IDS) { + if (getTypeValue(typeId) === undefined) { + result.push({ id: typeId, label: getLabel(typeId) }); + } + } + + return result; + }, [getTypeValue, getLabel]); + + function onToggleExpand(id: string) { + setExpandedType(expandedType === id ? null : id); + } + + function onUnselect(item: Option) { + const typeId = item.id as DuplicationFieldId; + const criterion = getCriterion(); + + if (!criterion) return; + + const newCriterion = criterion.clone(); + delete newCriterion.value[typeId]; + + // If no fields are set, remove the criterion entirely + const hasAnyValue = DUPLICATION_FIELD_IDS.some( + (id) => newCriterion.value[id] !== undefined + ); + + if (!hasAnyValue) { + setFilter(filter.removeCriterion(DuplicatedCriterionOption.type)); + } else { + setFilter( + filter.replaceCriteria(DuplicatedCriterionOption.type, [newCriterion]) + ); + } + setExpandedType(null); + } + + function onSelectValue(typeId: string, value: boolean) { + const criterion = getCriterion(); + const newCriterion = criterion + ? criterion.clone() + : (DuplicatedCriterionOption.makeCriterion() as DuplicatedCriterion); + + newCriterion.value[typeId as DuplicationFieldId] = value; + setFilter( + filter.replaceCriteria(DuplicatedCriterionOption.type, [newCriterion]) + ); + setExpandedType(null); + } + + return ( + onUnselect(i)} /> + } + > +
+ +
+
+ ); +}; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 8a7fdf8cf..e7a4caf02 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -726,6 +726,24 @@ input[type="range"].zoom-slider { min-height: 2em; } +.duplicate-sub-options { + margin-left: 2rem; + padding-left: 0.5rem; + + .duplicate-sub-option { + align-items: center; + cursor: pointer; + display: flex; + height: 2em; + opacity: 0.8; + padding-left: 0.5rem; + + &:hover { + background-color: rgba(138, 155, 168, 0.15); + } + } +} + .tilted { transform: rotate(45deg); } diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 2f74fc7e4..79f470de8 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -44,6 +44,7 @@ import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organi import { HasMarkersCriterionOption } from "src/models/list-filter/criteria/has-markers"; import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter"; import { PerformerAgeCriterionOption } from "src/models/list-filter/scenes"; +import { SidebarDuplicateFilter } from "../List/Filters/DuplicateFilter"; import { SidebarAgeFilter } from "../List/Filters/SidebarAgeFilter"; import { SidebarDurationFilter } from "../List/Filters/SidebarDurationFilter"; import { @@ -320,6 +321,12 @@ const SidebarContent: React.FC<{ setFilter={setFilter} sectionID="organized" /> + } + filter={filter} + setFilter={setFilter} + sectionID="duplicated" + /> } option={PerformerAgeCriterionOption} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 6f7403686..b8800216c 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1092,7 +1092,10 @@ "select_youngest": "Select the youngest file in the duplicate group", "title": "Duplicate Scenes" }, + "duplicated": "Duplicated", "duplicated_phash": "Duplicated (pHash)", + "duplicated_stash_id": "Duplicated (Stash ID)", + "duplicated_title": "Duplicated (Title)", "duration": "Duration", "effect_filters": { "aspect": "Aspect", diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index 8f30e5d17..ae23a48d4 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -12,6 +12,7 @@ import { import TextUtils from "src/utils/text"; import { CriterionType, + IDuplicationValue, IHierarchicalLabelValue, ILabeledId, INumberValue, @@ -36,7 +37,8 @@ export type CriterionValue = | IStashIDValue | IDateValue | ITimestampValue - | IPhashDistanceValue; + | IPhashDistanceValue + | IDuplicationValue; export interface ISavedCriterion { modifier: CriterionModifier; diff --git a/ui/v2.5/src/models/list-filter/criteria/phash.ts b/ui/v2.5/src/models/list-filter/criteria/phash.ts index 0cbfa155e..e79b0a447 100644 --- a/ui/v2.5/src/models/list-filter/criteria/phash.ts +++ b/ui/v2.5/src/models/list-filter/criteria/phash.ts @@ -1,15 +1,28 @@ import { CriterionModifier, PhashDistanceCriterionInput, - PHashDuplicationCriterionInput, + DuplicationCriterionInput, } from "src/core/generated-graphql"; -import { IPhashDistanceValue } from "../types"; -import { - BooleanCriterionOption, - ModifierCriterion, - ModifierCriterionOption, - StringCriterion, -} from "./criterion"; +import { IDuplicationValue, IPhashDistanceValue } from "../types"; +import { ModifierCriterion, ModifierCriterionOption } from "./criterion"; +import { IntlShape } from "react-intl"; + +// Shared mapping of duplication field IDs to their i18n message IDs +export const DUPLICATION_FIELD_MESSAGE_IDS = { + phash: "media_info.phash", + stash_id: "stash_id", + title: "title", + url: "url", +} as const; + +export type DuplicationFieldId = keyof typeof DUPLICATION_FIELD_MESSAGE_IDS; + +export const DUPLICATION_FIELD_IDS: DuplicationFieldId[] = [ + "phash", + "stash_id", + "title", + "url", +]; export const PhashCriterionOption = new ModifierCriterionOption({ messageID: "media_info.phash", @@ -55,20 +68,97 @@ export class PhashCriterion extends ModifierCriterion { } } -export const DuplicatedCriterionOption = new BooleanCriterionOption( - "duplicated_phash", - "duplicated", - () => new DuplicatedCriterion() -); +export const DuplicatedCriterionOption = new ModifierCriterionOption({ + messageID: "duplicated", + type: "duplicated", + modifierOptions: [], // No modifiers for this filter + defaultModifier: CriterionModifier.Equals, + makeCriterion: () => new DuplicatedCriterion(), +}); -export class DuplicatedCriterion extends StringCriterion { +export class DuplicatedCriterion extends ModifierCriterion { constructor() { - super(DuplicatedCriterionOption); + super(DuplicatedCriterionOption, {}); } - public toCriterionInput(): PHashDuplicationCriterionInput { + public cloneValues() { + this.value = { ...this.value }; + } + + // Override getLabel to provide custom formatting for duplication fields + public getLabel(intl: IntlShape): string { + const parts: string[] = []; + const trueLabel = intl.formatMessage({ id: "true" }); + const falseLabel = intl.formatMessage({ id: "false" }); + + for (const fieldId of DUPLICATION_FIELD_IDS) { + const fieldValue = this.value[fieldId]; + if (fieldValue !== undefined) { + const label = intl.formatMessage({ + id: DUPLICATION_FIELD_MESSAGE_IDS[fieldId], + }); + parts.push(`${label}: ${fieldValue ? trueLabel : falseLabel}`); + } + } + + // Handle legacy duplicated field + if (parts.length === 0 && this.value.duplicated !== undefined) { + const label = intl.formatMessage({ id: "duplicated_phash" }); + return `${label}: ${this.value.duplicated ? trueLabel : falseLabel}`; + } + + if (parts.length === 0) { + return intl.formatMessage({ id: "duplicated" }); + } + + return parts.join(", "); + } + + protected getLabelValue(intl: IntlShape): string { + // Required by abstract class - returns basic label when getLabel isn't overridden + return intl.formatMessage({ id: "duplicated" }); + } + + protected toCriterionInput(): DuplicationCriterionInput { return { - duplicated: this.value === "true", + duplicated: this.value.duplicated, + distance: this.value.distance, + phash: this.value.phash, + url: this.value.url, + stash_id: this.value.stash_id, + title: this.value.title, }; } + + // Override to handle legacy saved formats + public setFromSavedCriterion(criterion: unknown): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const c = criterion as any; + + // Handle various saved formats + if (c.value !== undefined) { + // New format: { value: { phash: true, ... } } + if (typeof c.value === "object") { + this.value = c.value as IDuplicationValue; + } else if (typeof c.value === "string") { + // Legacy format: { value: "true" } - convert to phash + this.value = { phash: c.value === "true" }; + } + } else if (typeof c === "object") { + // Direct value format + this.value = c as IDuplicationValue; + } + + if (c.modifier) { + this.modifier = c.modifier; + } + } + + public isValid(): boolean { + // Check if any duplication field is set + const hasFieldSet = DUPLICATION_FIELD_IDS.some( + (fieldId) => this.value[fieldId] !== undefined + ); + return hasFieldSet || this.value.duplicated !== undefined; + } } diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index bf5fff4d9..442099a53 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -47,9 +47,15 @@ export interface IRangeValue { export type INumberValue = IRangeValue; export type IDateValue = IRangeValue; export type ITimestampValue = IRangeValue; -export interface IPHashDuplicationValue { - duplicated: boolean; - distance?: number; // currently not implemented +export interface IDuplicationValue { + // Deprecated: Use phash field instead. Kept for backwards compatibility. + duplicated?: boolean; + // Currently not implemented. Intended for phash distance matching. + distance?: number; + phash?: boolean; + url?: boolean; + stash_id?: boolean; + title?: boolean; } export interface IStashIDValue { From 6ef599e894dd03a005ac7f6226455a343a2c795b Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:05:20 +1100 Subject: [PATCH 062/177] Make recommendation row width selector more specific. Fixes issue where the media overrides would set the card width to the wrong value on small viewports. --- ui/v2.5/src/components/FrontPage/styles.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/FrontPage/styles.scss b/ui/v2.5/src/components/FrontPage/styles.scss index f643e4eb6..2de0c6a44 100644 --- a/ui/v2.5/src/components/FrontPage/styles.scss +++ b/ui/v2.5/src/components/FrontPage/styles.scss @@ -496,6 +496,6 @@ // HACK: compatibility with existing behaviour after removed width from zoom-1 class // this should really be changed to use the specific card types instead of a generic zoom-1 class, // but this is a quick fix to prevent breaking existing styles -.recommendation-row .zoom-1 { +.recommendation-row .card.zoom-1 { width: 320px; } From 3ae3ea61025330defe0974147430275d16072e38 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:06:55 +1100 Subject: [PATCH 063/177] Default card width before container width is calculated (#6574) --- ui/v2.5/src/components/Shared/GridCard/GridCard.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx b/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx index 7bdac36c6..817527101 100644 --- a/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx +++ b/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx @@ -89,15 +89,22 @@ export function useCardWidth( zoomWidths: number[] ) { return useMemo(() => { + if (ScreenUtils.isMobile()) { + return; + } + if ( - !containerWidth || zoomIndex === undefined || zoomIndex < 0 || - zoomIndex >= zoomWidths.length || - ScreenUtils.isMobile() + zoomIndex >= zoomWidths.length ) return; + // use a default card width if we don't have the container width yet + if (!containerWidth) { + return zoomWidths[zoomIndex]; + } + let zoomValue = zoomIndex; const preferredCardWidth = zoomWidths[zoomValue]; let fittedCardWidth = calculateCardWidth( From c8a8154e83895f14d29abb7b148c955555845357 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:54:58 +1100 Subject: [PATCH 064/177] Only use infinite scrolling where there are more items than can be displayed (#6575) Also show dots on small viewports, up to a limit of 5 --- ui/v2.5/src/core/recommendations.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/core/recommendations.ts b/ui/v2.5/src/core/recommendations.ts index b0a1232e4..7c55fed9d 100644 --- a/ui/v2.5/src/core/recommendations.ts +++ b/ui/v2.5/src/core/recommendations.ts @@ -16,7 +16,7 @@ export function getSlickSliderSettings(cardCount: number, isTouch: boolean) { return { dots: !isTouch, arrows: !isTouch, - infinite: !isTouch, + infinite: !isTouch && cardCount > 5, speed: 300, variableWidth: true, swipeToSlide: true, @@ -26,6 +26,7 @@ export function getSlickSliderSettings(cardCount: number, isTouch: boolean) { { breakpoint: 1909, settings: { + infinite: !isTouch && cardCount > 4, slidesToShow: cardCount! > 4 ? 4 : cardCount, slidesToScroll: determineSlidesToScroll(cardCount!, 4, isTouch), }, @@ -33,6 +34,7 @@ export function getSlickSliderSettings(cardCount: number, isTouch: boolean) { { breakpoint: 1542, settings: { + infinite: !isTouch && cardCount > 3, slidesToShow: cardCount! > 3 ? 3 : cardCount, slidesToScroll: determineSlidesToScroll(cardCount!, 3, isTouch), }, @@ -40,6 +42,7 @@ export function getSlickSliderSettings(cardCount: number, isTouch: boolean) { { breakpoint: 1170, settings: { + infinite: !isTouch && cardCount > 2, slidesToShow: cardCount! > 2 ? 2 : cardCount, slidesToScroll: determineSlidesToScroll(cardCount!, 2, isTouch), }, @@ -47,9 +50,10 @@ export function getSlickSliderSettings(cardCount: number, isTouch: boolean) { { breakpoint: 801, settings: { + infinite: !isTouch && cardCount > 1, slidesToShow: 1, slidesToScroll: 1, - dots: false, + dots: cardCount < 6, }, }, ], From b1f3bbe5b0cad835e0a4c42a9cae5f414fbf3bfd Mon Sep 17 00:00:00 2001 From: feederbox826 Date: Sun, 15 Feb 2026 23:06:10 -0500 Subject: [PATCH 065/177] Performer image rewrite (#6566) * SVGs and attribute male performers * SVG, cleanup and attribute female performer images --- internal/static/performer/NoName01.png | Bin 13753 -> 0 bytes internal/static/performer/NoName02.png | Bin 11353 -> 0 bytes internal/static/performer/NoName02.svg | 1 + internal/static/performer/NoName03.png | Bin 11049 -> 0 bytes internal/static/performer/NoName04.png | Bin 10578 -> 0 bytes internal/static/performer/NoName05.png | Bin 11026 -> 0 bytes internal/static/performer/NoName05.svg | 1 + internal/static/performer/NoName06.png | Bin 10757 -> 3026 bytes internal/static/performer/NoName07.png | Bin 8661 -> 0 bytes internal/static/performer/NoName07.svg | 1 + internal/static/performer/NoName08.png | Bin 12597 -> 0 bytes internal/static/performer/NoName09.png | Bin 9907 -> 0 bytes internal/static/performer/NoName09.svg | 1 + internal/static/performer/NoName10.png | Bin 10761 -> 0 bytes internal/static/performer/NoName11.png | Bin 11893 -> 3587 bytes internal/static/performer/NoName12.png | Bin 40410 -> 0 bytes internal/static/performer/NoName12.svg | 1 + internal/static/performer/NoName13.png | Bin 8925 -> 0 bytes internal/static/performer/NoName13.svg | 1 + internal/static/performer/NoName14.png | Bin 9840 -> 0 bytes internal/static/performer/NoName14.svg | 1 + internal/static/performer/NoName15.png | Bin 13849 -> 0 bytes internal/static/performer/NoName16.png | Bin 14569 -> 0 bytes internal/static/performer/NoName17.png | Bin 10878 -> 0 bytes internal/static/performer/NoName17.svg | 1 + internal/static/performer/NoName18.png | Bin 12196 -> 0 bytes internal/static/performer/NoName19.png | Bin 12482 -> 0 bytes internal/static/performer/NoName19.svg | 1 + internal/static/performer/NoName20.png | Bin 12116 -> 0 bytes internal/static/performer/NoName21.png | Bin 10897 -> 0 bytes internal/static/performer/NoName21.svg | 1 + internal/static/performer/NoName22.png | Bin 10689 -> 0 bytes internal/static/performer/NoName22.svg | 1 + internal/static/performer/NoName23.png | Bin 12121 -> 0 bytes internal/static/performer/NoName23.svg | 1 + internal/static/performer/NoName24.png | Bin 11273 -> 0 bytes internal/static/performer/NoName24.svg | 1 + internal/static/performer/NoName25.png | Bin 11157 -> 0 bytes internal/static/performer/NoName25.svg | 1 + internal/static/performer/NoName26.png | Bin 10647 -> 0 bytes internal/static/performer/NoName26.svg | 1 + internal/static/performer/NoName27.png | Bin 11884 -> 0 bytes internal/static/performer/NoName27.svg | 1 + internal/static/performer/NoName28.png | Bin 12687 -> 0 bytes internal/static/performer/NoName28.svg | 1 + internal/static/performer/NoName29.png | Bin 11191 -> 2990 bytes internal/static/performer/NoName30.png | Bin 12278 -> 0 bytes internal/static/performer/NoName30.svg | 1 + internal/static/performer/NoName31.png | Bin 11289 -> 0 bytes internal/static/performer/NoName31.svg | 1 + internal/static/performer/NoName32.png | Bin 13180 -> 0 bytes internal/static/performer/NoName32.svg | 1 + internal/static/performer/NoName33.png | Bin 11984 -> 3506 bytes internal/static/performer/NoName34.png | Bin 12270 -> 0 bytes internal/static/performer/NoName34.svg | 1 + internal/static/performer/NoName35.png | Bin 8573 -> 2503 bytes internal/static/performer/NoName36.png | Bin 9733 -> 0 bytes internal/static/performer/NoName36.svg | 1 + internal/static/performer/NoName37.png | Bin 9818 -> 0 bytes internal/static/performer/NoName37.svg | 1 + internal/static/performer/NoName38.png | Bin 10489 -> 0 bytes internal/static/performer/NoName38.svg | 1 + internal/static/performer/NoName39.png | Bin 10928 -> 0 bytes internal/static/performer/NoName39.svg | 1 + internal/static/performer/NoName40.png | Bin 13857 -> 0 bytes internal/static/performer/attribution.md | 34 ++++++++++++++++++ internal/static/performer_male/Male01.png | Bin 29574 -> 0 bytes internal/static/performer_male/Male01.svg | 1 + internal/static/performer_male/Male02.png | Bin 27367 -> 0 bytes internal/static/performer_male/Male02.svg | 1 + internal/static/performer_male/Male03.png | Bin 26475 -> 0 bytes internal/static/performer_male/Male03.svg | 1 + internal/static/performer_male/Male04.png | Bin 26600 -> 0 bytes internal/static/performer_male/Male04.svg | 1 + internal/static/performer_male/Male05.png | Bin 25812 -> 0 bytes internal/static/performer_male/Male05.svg | 1 + internal/static/performer_male/Male06.png | Bin 31704 -> 0 bytes internal/static/performer_male/Male06.svg | 1 + internal/static/performer_male/attribution.md | 8 +++++ 79 files changed, 73 insertions(+) delete mode 100644 internal/static/performer/NoName01.png delete mode 100644 internal/static/performer/NoName02.png create mode 100644 internal/static/performer/NoName02.svg delete mode 100644 internal/static/performer/NoName03.png delete mode 100644 internal/static/performer/NoName04.png delete mode 100644 internal/static/performer/NoName05.png create mode 100644 internal/static/performer/NoName05.svg delete mode 100644 internal/static/performer/NoName07.png create mode 100644 internal/static/performer/NoName07.svg delete mode 100644 internal/static/performer/NoName08.png delete mode 100644 internal/static/performer/NoName09.png create mode 100644 internal/static/performer/NoName09.svg delete mode 100644 internal/static/performer/NoName10.png delete mode 100644 internal/static/performer/NoName12.png create mode 100644 internal/static/performer/NoName12.svg delete mode 100644 internal/static/performer/NoName13.png create mode 100644 internal/static/performer/NoName13.svg delete mode 100644 internal/static/performer/NoName14.png create mode 100644 internal/static/performer/NoName14.svg delete mode 100644 internal/static/performer/NoName15.png delete mode 100644 internal/static/performer/NoName16.png delete mode 100644 internal/static/performer/NoName17.png create mode 100644 internal/static/performer/NoName17.svg delete mode 100644 internal/static/performer/NoName18.png delete mode 100644 internal/static/performer/NoName19.png create mode 100644 internal/static/performer/NoName19.svg delete mode 100644 internal/static/performer/NoName20.png delete mode 100644 internal/static/performer/NoName21.png create mode 100644 internal/static/performer/NoName21.svg delete mode 100644 internal/static/performer/NoName22.png create mode 100644 internal/static/performer/NoName22.svg delete mode 100644 internal/static/performer/NoName23.png create mode 100644 internal/static/performer/NoName23.svg delete mode 100644 internal/static/performer/NoName24.png create mode 100644 internal/static/performer/NoName24.svg delete mode 100644 internal/static/performer/NoName25.png create mode 100644 internal/static/performer/NoName25.svg delete mode 100644 internal/static/performer/NoName26.png create mode 100644 internal/static/performer/NoName26.svg delete mode 100644 internal/static/performer/NoName27.png create mode 100644 internal/static/performer/NoName27.svg delete mode 100644 internal/static/performer/NoName28.png create mode 100644 internal/static/performer/NoName28.svg delete mode 100644 internal/static/performer/NoName30.png create mode 100644 internal/static/performer/NoName30.svg delete mode 100644 internal/static/performer/NoName31.png create mode 100644 internal/static/performer/NoName31.svg delete mode 100644 internal/static/performer/NoName32.png create mode 100644 internal/static/performer/NoName32.svg delete mode 100644 internal/static/performer/NoName34.png create mode 100644 internal/static/performer/NoName34.svg delete mode 100644 internal/static/performer/NoName36.png create mode 100644 internal/static/performer/NoName36.svg delete mode 100644 internal/static/performer/NoName37.png create mode 100644 internal/static/performer/NoName37.svg delete mode 100644 internal/static/performer/NoName38.png create mode 100644 internal/static/performer/NoName38.svg delete mode 100644 internal/static/performer/NoName39.png create mode 100644 internal/static/performer/NoName39.svg delete mode 100644 internal/static/performer/NoName40.png create mode 100644 internal/static/performer/attribution.md delete mode 100644 internal/static/performer_male/Male01.png create mode 100644 internal/static/performer_male/Male01.svg delete mode 100644 internal/static/performer_male/Male02.png create mode 100644 internal/static/performer_male/Male02.svg delete mode 100644 internal/static/performer_male/Male03.png create mode 100644 internal/static/performer_male/Male03.svg delete mode 100644 internal/static/performer_male/Male04.png create mode 100644 internal/static/performer_male/Male04.svg delete mode 100644 internal/static/performer_male/Male05.png create mode 100644 internal/static/performer_male/Male05.svg delete mode 100644 internal/static/performer_male/Male06.png create mode 100644 internal/static/performer_male/Male06.svg create mode 100644 internal/static/performer_male/attribution.md diff --git a/internal/static/performer/NoName01.png b/internal/static/performer/NoName01.png deleted file mode 100644 index cdcba1db9009b2d51379ab8578079ecd23fd1c2f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13753 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 diff --git a/internal/static/performer/NoName02.svg b/internal/static/performer/NoName02.svg new file mode 100644 index 000000000..b5dbaf2b9 --- /dev/null +++ b/internal/static/performer/NoName02.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/static/performer/NoName03.png b/internal/static/performer/NoName03.png deleted file mode 100644 index 8ac0d13b73ae19c5aabe55a6cf6cec8aafcea9ca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11049 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 R;DqKfBH^TQ4*p`m{{diN-=ZFoeiS8)uJD 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 diff --git a/internal/static/performer/NoName05.png b/internal/static/performer/NoName05.png deleted file mode 100644 index 8a49ba6d3e9bc1985bbe75d1b2bc3ef25285d09e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11026 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

Ajy&zIM~N2W{& zaD<)OQ&c1Q5zCV^O>|NVaB|8QU9 d*Cn|>_IU8=xBLcgyz=o`n%kM(H2p35e*q1-B&q-a diff --git a/internal/static/performer/NoName05.svg b/internal/static/performer/NoName05.svg new file mode 100644 index 000000000..5a26d98d8 --- /dev/null +++ b/internal/static/performer/NoName05.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/static/performer/NoName06.png b/internal/static/performer/NoName06.png index 4359911aefc00700d4d1f927a2c30b823850a080..f2a8016e20b0d391f982500b3cdb9d01741cd1fc 100644 GIT binary patch literal 3026 zcmY*bc~nzZ6HmYZ#!zKT1s4R@lB85Lid%s|5Q`!Nc;>yh0fr^d5DcQIfFMO7psm)V z5`{*P%JQBNl9v=jKoms65^H#DvI@9Rw53ImTK&@ReE)p++?jjM@66nDXXearZdPEx zPD`=_nLr>|?(*~D5D2D5nKwc-H6oG>&pP8VKh6(~ClD6c{3}F){KjGxmu%MCK03BD_*O)0s;FTx4qDn95xJhh!~%{o08z3h`@Ho@_WQ>Wg!uix>13*EWhcMoTsVA17Y zLPL8hov+Ne6m!<*s`i1MTU~+f($Z)4@xQ){e61N}G4fuHJC}NG`m8K;X5p7EDL_YM zetICXRg0A+E0eOXZ+U3sti2u*DXA+0*_ERMh#vi_ogiig#XxRQdR7P2>*5Kf3Mo{?8Y3 z&wPJqdf++0MudN}==V_yR1!qnBp~Uh6nyuxa{$qZ%vU?limnr&FQJ!#?iT5~A40TIbmwdVj6 zbo(*DuKh|RY5{pYuH3BI9u=AndZ=Vf@uPzqQlmDl{QQZE9-WsZ8}v?+&T#b(X;1FH zpg^Y4d6}qqVN8`T`YHhF&g6@tcdTwd-F{;_&~t*RO-oK`1RXsmRz{Qb5tv)k}3)U;h&+Z3)mIt z0!ra(tl;Dq7)d$$yGSwZs+iuUpObcx8|I2Mc8Qy>#Mo8mTF^o?v$i;ExoFQMy1&nD z2C<4Px^8DBvx#ZL(BXW($YZ0nfD0GHJwJdY_n2M`Q&r~fe&W3|?q!QE`@l{G1g&fw zxD$KvwsN~isGPT}m|5P@qH?yXDwTaoZEQn#Rq)H8v>E3L<`7z<480@lA8gn(h14iV zLQ#*I6PxY`>}#f%)Wr;NH#{USq5)d7gMXW;pXHCeFEMn>=W+R64o*}dlC(uv8&NeU zYXo(?vYNz?-{9|1vs{s9@Rf(Dn?%rsE5>%S>rqjXQv+=qS z#C;0=wO4LB2tm9o9`~sLtE~?Ia7~(uQ`F9Sc`CKD`E0XG0;9IeL~X5?mqF*>GKUeC zJ6r-=Cf%l}$K_EHeKK4Mg9GJ@+FLwnM3mbju5lbjiZslr*!j_I7^CCv9`~|9Ax(W2 zUQ`Dcy{5jUrcpaJl%YZP@DAEOt=Rz0gi#s`4wMkk{r&P2D0fg?V`ID_jroQ9j-rWDeuuv51ff33Vm?WS5IzndX`xvQr@3Be*vhHz=8ASV!gl$yGrw zDqr@Hw*l6IvDW|*^g^3(F{mq-?JLU-HOzSy9)^kiSU-vv1KZ&0QhsMCKWxnzK}o`3 z!*2S0zzW`Sj9L=fIFd{|&?`4=J<7gmOo(+e`7kZwQcxLYfsys$CHUr>xFZXF`~4}h z66#XTQt(?cNWfJ2F|d?_LH?+@ii&;B3G_!*yxh|9P7ZuKh=J9H7fnC`3@qRr#{_RvD3L4+2T0uLC{F1!5bL(BE&@n(zGX1IeN46mO zz6;72P)e4WH!R3uycIDVjnVhS;eC~JlSFW! zMK0$B)UXSXKcr)Zt=O0-<(b(spR<{pwZ+-eUf#_M(hSJ|jBZBjMy#b5 zaqnDcoMg}p4zu_wgMH0bg$VIygzV8vFYCL`<6? zdDGv$5<#-&=56NVidHyV&ZGCc7{00n0rVT(xskksZmxB!m)`nPbCL>}WmDN@TZQRt1JFi_KKNbVBHl$jj)yye#=RoA;-R=}kA)7A%E- zD9gqE$M-Wt;b1x@!+=U#c%q94qSqoeL7 zx~^l+CVKtDsbpjq13N+I_%krWf0ZC8aH14n^*H=JV0*+K`x0(5XpG$jVqfyB0s!kH z7g>yQoNr8vtDRx%Qjx^S9cvV9;RY5O=543IEGmC%YO$E8^PKRg4}0s8=KW;wl#qJl z{o2ueLtT}mkLzd|(a+7$k&+-+zP4BY&sNuP5ox|xiS@&C(pM(Of@jdRJ+ z2Ceze@BhyHOZLB;{)_lOH2+qjnSZZ;hx$*=zs3J#{)_D2;=h@H$ z|H=H~?ovht`kS+%9_~QgApsCA9K8|EcgnBc`LHdu+4n1GdX!xcbn5 zAX4A*ld!NXZsWSGZ1s}5h@2dB*#Gua75W30m_6H7LCb4H%Y5!iZ~&{-?j zU|y^F?4!4L(sw(3s&`3Ny+NjCpiU$7N_J^jt5KCKn7FAp`n=<>cTPpNv3W^qM&&-Q z7JcfeLgR|uhXPWG9z-E7Bak*RRDR=fzANkPEw5k0JQ27+s_=!*fpZNzDo3DooqL7y z2MWBEb42B4v(J-N9aL}L<2};tjLfYL<#oTj`g8Jmw?~+#JXF(o{JyS^vQ#Q3s~FWK zLGBYvM)8bE8(c&uJyj6U4-q8hOD~BVM;p_emJf*>`u0`VU^4bTA+-09$kEuDr(Ty+ z!-1IYrT`fQ5#8;3n{Y*Po*qgOOOVbIhf~tO>QQeC-U8pBbEhRB-%_-YfFckC7Laql z!`gHn&m{$JtiP;&abW4*+p}!(u{5C~Q??xQ@1_qDUb+ISb6uN5@&&W%x>7xZZ`?*E z1_p`xv~cBEUgEFk%q-wLaSE7}xg%S=9T6_yhxk3inyv48jZG-`q>71@9a;k3e`ze@ zA-@9i<`eJHVnGoIhE)=#`$bD6@s`TTDw-F?3cote%jg>q-a<`l| z*1sD0Ea?Ld`L$%f7gXnIbmGB~$#lyWTsgywz11NJ@jt{EE&VX`Li?ekorT{_<0gbd z8b5N?pq+fJ`_Jk6e7OhGWy^L|vlS%(QoRmx_WgAM94kikNRdsMkUuqm} zt~(5-MJYI-@CnvHswd>UqUE{>^xA(V2IQrjTKMQ$;a=qzWx( zt^#5JIb(V|lQfnUD8~0J6(E(rnz`dHCeT*RXtLD|?7oMH7WNiOtO`OQ5aOCiqaAGv z=4(nt8{dY@`^16t9HXY^o7b4(y28=3a$J%eWK)`|G2wRr>K<?99KNX_;6M+dy-y4ppG%kei%mDFLSx+v&7r-X8s9!$47yF z(MGA<0%G1+GY&5)GY;ad_{x{kfSW55EN5|&HIC*OjV_~IljnIHwel8@%J0~!0eukX zjl2C^tHBd{*je??Wp!`K!=k&2aq?%!`BG>D!X^rwZ&TLz-^Cnu_B{9FyVtC}_u(^x zig8tiAFjqh4$-%|6^@x_dUIsI`*6MR@tGD~^a;ldg(28-&k`~G#uZ1P%@!=TCOYDz ze5so=oqOvJzl%V|ZB0RT_fS$*;r6mgJOYTWssxABwDAJmHR-cU!!qn||Y#hlz6cVzHx@M$SBuq>E2yzf}FC&hM%zXM^ z6w`vtCnlcCAIkd<9D<90Lm8_@)xX);#9{7K5?Lz~QgyDk8jba)SZ4m&(D1A)Ez_x@ zps?ohBo7z4v3-Yw{5QBeBB$^2Alht9RXK87vp!}q!2iaqD)cOp*yOkHWWYB1m~`lZ zVN!E;On6QG?<(e6u*%McqQYUIkM^~BY+M?OqfJ%aTdSzpZgHRJh}e|DM8wh9G{?@X zD@X=yFQ@-iDr6JqaQ7m4XRtu%RGa7Tl^iN<3!POyH4=rQP+nrL)bktFd%OTC^3vCI z!^J7R;bb`1$2!00rAEXGsqke@n*6Jy!x7_aVfnp{u|r>nGgwsTE-L|3GZtH6>4I>b zi_PRR`#E(RRyuWIauK{_kYn^dCtf{70qQ@MKVNFKlfCfCmWkw47|rI_v?izZ=o;lO zlZb$ItS5tWkmzB&@WaH_A!r35p8NS({g(-^T-RuK{@y%LzgseE_iK4hv=nL8E3Fr8 z4=SkW3EaHN4ICRSY*EjO6gtx2c}(u-h-rRJq$HI;*#EB|l4b)_S#JSzwl{zKgc{H^ zMQ2Nel8YAhW~>usuk8x-Y|B8gxoXwjqQIiox;-u#z~l*r~|kFyNho(i}Ri0y2| zIN}q2Q%x=>Z!BCQRo+ebYHM#Uw^w7gyftbf`=I2eFmDi^)Uxr3K z2J)(|s@&aZSQFQqSybeb!|!zrd+X}*r$&U^%YOrvL#k`tTg{qQ`fg`BEF)e&3YZm} z-P@WX0_NqKz8f+3mh~#$9Np+4cPobS^F@b+m>=t1vpvXWtmp0~MvcG(OxUt|KbwA6 z`xqqe$kIG4Pv}&gKV#--+}-rj+f3o&T?t%*$RPJga)CsPj#LWwa^2f-x(jj6n3O5i@Bu^cBhP3Y}kQu2_AF3w6S^;?l=TkR(>-|>AQuJjmn8|^B#E)MHPH^=Lz!^;HEj>cb>*OIlM*e2} zOPPz1Qa0WSb(CN{2C!hw>Nw$R5r90Q>(;hgOtRe8bowe+H6Fjx33TKOwCWChIbvYx4rw#aY_0YM+5k!v9 zGv_ya?A@EAAHfB~l4(M)`?wf1Z4eWBLsA$LG#8r|HUjVnMK(jl5Q&!!Hp)9I4#g3c za_Gn62x6x$alH-@OB2e|5c|Orn#}ok0FtZg%MwtyKXzTmJWFP!YEOl9X8whGQS#0a zw;(ZC+lorF!4ZXLRT9yK06*4q?D|gTJcYB-bUf!NXDo%h)0K7tiz8k<>C4J*?XLDL zjIbbCrh~-0l*6_f>e+;d0Kc5&Q+8F|-D#}h^@QcIMD~}50Mbp{?krRq35%oC6*uj! z42v%W87?2T5T(87ls6lDS!>_7eZw%+Y&{%$MMn3g1NB0YW0w1#(OLGn2+7wQiA@^^d>TLNw-n`(Y#kU5)0V>~)b( z-AORn#4{ciMpOF#`plnQ;%G?TW~dkG&T~jn7hK8dT_*+vv=<%7rhBvk6wl-p+GZ!$ z*W!!Cn#=4Ty}+02gp%FOP3Y?@E6qEOy`{XTyBT8TBrVqIOmJ)p?b%Gd7I>4lv}Z*&!y z*llH%%@%fn>NTrWYmbEf2})X9bm+W#BBpH6jhjsJPImVg|M|3`#@}n9V)T{8nZM4i z{tT}`jnLUI6wVj7@tK6KBB9bywPv+;apdc#P` zEKPz9RX1_S)J6><5+Zz03SKWa+e5Y_yhoQbB?1Kt(jrU8a@OK?OU7k1eJSM#VVQrj zQzf>p;zP59L_x2Kl~iB&USQeYoG*`UZJClO*`&6(VLnhAx>MSe)}}BBQpS=M__FK! zDZV48KWsC01S))a=QC(^Hy)1UtPyrAtTwwNtr$yl&+gmM!WE@7m5I?BAP?qHRvQlYk7i4B=G*gahwA+Y02UXbdrQhVin&La_Fv-t!VJ>VS?GO2D*bhbOBitoamg8aFxF5NX)|*1APFdMFZqCz6pP2)kSg!`oJfd{ zv6(iY*QcQ>^&wm5?4iz!>7a!}v2$2dpl)5@D;#68L>?cPVE1b0)uk)MmY^qfo9TFD zgH}G6lse=Xah|@@20+!cGy~r-XIi@(2R3THFr6l9v1~GCxd#+X<8X`z9th)DrTvKN zNF_1kZP7`xxnBH|bHcDvns31=Rp#~+i8N$Ou~<2K^o^r$$)mVahWu+f6+4WMoz?R6 zKg$W9KIGdnLeIvtbPV5#@_l6F%IqW}FCSAqq?4s^ssm@^EUI`;4LZDa7g)sbS=ONFN zsuN6#8nt$}E>p~}otx|rFEaf3c)(!2Z*IAFY>7R!{r$B6vGAPm{Lzs!0hjL1TVQay z#O$D_4GH-(U{aL{?Y+8o3!M4=baA%o9b!kjD5YKbioEg>i$>+-)J*n>5Z}{eo^N44 zdy8%$4Z|@`vst=Qow2lAKl-2M`1FEq8O1TDqU&_oMAN>p1~X47$ole)Iq(KQ{)lHxg0v@3O~8wWNWejyXhEAl`><(FThmKl4f#~6Uvid1KjE^?YLR_Blq+k?~OzF50~Dew@R z5~N~YO|)C~J)MXd@qZqye0-xU)#a;~d7(!4#Y8Y8)I=_uoU6XAmHTHwxBhuZ5{_yG z*+MqI)>}TqTil(mQDkA2nRzO_ZM#1HSLak8quSHqEMx^oea|M_1C$;H!bu)50w@EF zeHfDWIRS&SRf~{}m^gvmZtKZ^`z>hvLOM9e`A|W{JkcH~ToO$J%kZr8pI<8y`hQWoFWQ=iaT z7EDnW91vkSsB{Dh^N*tW8Q~0ejgv*37`7h~4D$U>pOLk{Fb6FMmN%buNRO z7lo-ZA)MH_6$z-z%R{OKlu=X#xV`cQImsYbyGf|71)$iQ5v8gzKN8-Y{YHZmTl>v_ zYF^diV=nl*(IT$dS7ohL>JRwM#=GR#na5HdUFg4 zrZaHV&HU==(GSxLe7aPuIhtjx>)(a>y#t4;N_sM=VvugPzFU|!^7ICBAH}|KT>>U9 z6@-0}h~Px|6WCf0`>%(q>zL<)G$l00?%C3R3sUEJD5NL1uN=XDaMF@n z2ofx8pQ=@4LR>j%>!bUn$4Z7-$GN2ny#(RP+GtC4f366^hF`CTpHWCMO|cYRK9xp2 zVyZcEZAyC&-m0*cyL((~pMyL5o{fW#9C;U&=Nn+v#}PUiUzzoLJdTm6k32b#mxB4H zZoEltch?t(27FG!A-&&`6W9VSVF-xX2kYX{6;DJr^E8~WQt}ZMtGDre5U=9tkE2c> zL>ti=YTfL8bHdHZ2uI~r(W#V{hCXz0lCK1wE^vGH1}J>*HE4ib{wj$gZ2oJ>xZw^r zJarAMF8ga8s?p0Q{Zood?LLY#Ksc~46N>zCRBx=m^13Z65T1%5C#@BcW>NrB_h@x_ zv3`W5AsR9GQ|LcWzKt@dKZF{d%A<$HW&K6WhkT}&@+jRI2`kTEpuC<e}XY}p6+$#Z#rB$c>i{x{=?JHc3tFe7Xt(w zxVVl>s=km-jbO~0oGMsKbkzw^d9;1Wynm(VMRfPkE1U6{AGV2@TSjOaLOH;xR@Cqa zaNw#a6wYd)y`Wb(5gk}VjVMn%lMJsWll|VZh{A7Nq^Og7V@+IGwIaZ8(*p)n!yB`< zJQRCN8asoSfEe{66Pd_Y#yit(HzP?-S+I(i&6eDJ*}S(rWX@*j6k?-H@=}F{(ta6* zly2LGX%j6I`15%8*4sm2KyM*ikGMFdd|rA^*l;+9(ezB&Q1!}I%p zsY8zfyQ;!9*~yXxToXiTNR~EFGeOQ=j@3&N91`-T<*fMi>!4V?r$gCDL2@@MFs;}r zUCv=>Goj$S*l)}Cfz=sQ*#eu!jlH3+BY2iEvW&jAGV>2nI3JT}0<4~cig$+U>3CK+ z!A7d@yH$sx_#}?;D*CGKu&CJ;u7KJ%Wg|;cq00o0(^=A~!b77bI ze(@D7n*fUasYu@LuDwY%jv6tI=}luuw0axsi@ev;orc#iaP|qjIF_ z!g=qjuTzV}*cNti3=5={Ry2pBJ2nY6#Z>86i8=SQ=}7&$#r)7+|DE(h@X`TT`|ysV zU*w#v6)RlgCWRUU%gZhNKHIlz&Gp8ZGw{SgGvwF?9OtD;8td)(Qz+QBCaXBuYOGXT zAJ^s`qDe2>k%lI`=%#cY$LmD>6W#NiaaH92r7uNnys!@}tLOJp_@XBjxJwJ~hAWYD z!nNhN)VpHb1xj2b3^t*6Kxbb{cSI=z4^lbE107AD1tTz=WG@dVwZ8qD`6g>sNE?wr z6`u5RG(RiY^nn>tw>{222mS_UM()0c7ap4q!6-mlTjengC`4J`-qAsal+q1qx+Q|n zXyVRYLytzKRsdRWy4BZyC#Y2n`gXiIxRCFoCe(7g+2(A@eJ>|cE(kxX0=1ADrL5g) zS;&M}Sj4YU9T|5Q8@zt+&XcV~T2mI^LKpebnqP$L`M8*J3}1+^B$PZ$*pz@=J$vow zJjxLj-1@EFLWYIEdb@Y*r+bhzu#v<^PO=meSsrCH{RK99;ZMlTyO0Uj5Cs7H!;PGZ zYKqUuggmT`I6pGj^*+1L@RRz-%)G{#;@hD57X3oYK|Aqea97ejCHF#re=@DDU@ZI} zJzH2rk2Bdixv*_KX3*xnBxBL#W;%cmJhup|XzomkB!jB$DXI8mlGCRs>~fbr)$mq+ zjQ|??;MD)9p$1tfncv@Niq;hqUlyEcdJKB$q?~B7N4I5+%#H|&6N@XUs533R!KJez#|dXf$FE3d=fR77YmocOO$Bj`BoSh^E|!h zuGnOcH_{K_eWN<(K3i5W4s$_DDr1CTp9vG-p@DP3`h*viGt0S$oC zALpv7a%2A6%$OoDs9Tc$nlsiA_Ceemaa4b7WfIK{IWvltgL)kS#l8fwiGqQ0LaPph zfFElI%2UC-v)pP)TIg)MZZLVPwzic_p_On(`Z1vJ=iBK!`|XYGvyuC17(Pyz;<1jy zTne7AKg<0>vhHn#eoOH>ct}mMeNnWtuHq$VGomF|bQDJ%F%>#K^q0Oc)IN64=8UPF zd!z5y^@{Jy%BVZcaUknEHQzCAX_bezn<9?dxukd?j^4jcB=Kks{hdL0@-U9}`U3NW za~clG3E?N+x&BN6pm1Z=3YqkFGEJ9zQ^cJ4_{nB-V>(DH-VFpU$xnXZLhOg@IADx39GNAMEqiRL`vr?Yy9w}VG33}}VjfsL=eqE2;_L9ko zi%0$Cj-&5|xNmCy_Ok2?S5J?5zGL0uvp zJ*_+jmRo4G-xgk~N??ap?KcMnxRS}Hb9h$mfm>)a-z3K&9)s&J$V|kjm4%k=gxKV; z>+n6TCG5`Y8YZH%HomReY3cZuRxJ6lJJ5CSamiL+TRF?zRcpA!HnA~Cji1Tk$dhU1 z`K8jiulJ}6rsu5+rd>U0c9So8u>Ju*d_MW@J1oF&xfIM>+8)wJge#Y_5-}@QsBZ^I zoi@nRuiJS;^#!K*1S5eh@{T1CLp{Hw8|k}dY(A%3$rRgtJ~?v6yUI_+Q#pnEBB|!h zuMoEN6dR&)KoQVpybtVG47npxImvh{C*rNBJH}hnC|uZNxGcOV1mOxVb~xlD7Nu1+ zSKU`gRr9L(WW_%_R<7S^K|RuEZA2KF*ol7#YOzm=B%m+Of~%V2F*c`20^|blY8bHO zo*Rd;NqnODVjk(Cxnp`WGUG4-8XDbSm#$8~o%tJ>#NbZ6oK=F7nmcS7F@(*s`N-kA zhZi_nMIm-Itw4cyCquYvla`rvSd7yHKexwBkm@T3cvYk{;V6TB0M##WeA5`un&xz< zCDe%|7t91FJehpywZ&GEnYc1nH)EtYs_`Mq@$esm4hVnb?VGA+N|(HMvlLG1$Z}!h zFfVO?Nkbeqn{+;PYsKB2jGFxCU}N>p#hot8W7?m)eTFNVJ;u84*K}3AEC!RBf91R= z$4E7|hK~LNOs~9k=Y{3G9_2j_JVCCSgRgAjbxRhh((*gJ_lmi6n;U*;1{9w8Abti) zuV``=LswGDwt7>*r5$$z4)Ix5HPUmw9QA0;Wau6(%iZ~6#|4<5!iO!x-A)_h8T$*3 zPs@3$L+HG%rQbi5woNAV77Bbfx#B#z{+>s{Vd&~S3L^ueqKr|@*(feZekGCrj#|;! z8QHIW>{Bfr%M~hd5QUM{~WX4x(Ek4byJ7fJbZRhPDFQbu!Y%iprW&`}015a~iup-D71;s*f|T z41KR0y=nMX@Im>Xg^juAXR=GSuWZJQZUgvWcd{cQ6)qjtl-6)}p|4boV_7S9@-_RfiJ?293xy>bwXwKrqBz8&GmYAqC`I9p z$%_v@I0iCbV4d`6En>}@tgPhcCzepL^C<6<;vJJkcuV@A5JQ7X@!=qMn#^R!VJ3PDSFlw2O?*j|auA^KU^m(fci;neyRbIV(Ik)Cu&vioLd3w|rnKX(E~(mx z?$GUP&kICJ$HU$q!dpbV)Ld|&$AWlWq6n1g!*iO)^#{W|)cnt5!sWLtRUc~UXuwJ=1{ph8%G zg@yCbIgLvRs@Gh&FTDFL#xg=#qnP1Mj`2R zOV)Dzb@L;U^F?aNQ~gOv=qK4`3WnBUN*tH+BSEMOS@J$@jsv1w@0_0~!j9&EO(>6)`+H{KWhAglzzQFZJYcIMrhwD7KC`PCM} z3LJ~sDOoz6@oftZpkV(FeamB>bn;*{wL%NA_r@`Ns$pU8;%<++FyXuLty_rvrT%gWkC7%U0xicKDbQsNnNtP!bN|d ziuvf~=L|gqI!pW6W);oNSt;`eH|ER&NJ1l5Zg?$p!gtAa?7qdd)$3EhyTm$nbW5#Z zXwk8_2x6gDGSt3dn+m2Tf zbjmilZjyxV1QaQyMR<-c0gzLc#a74!Bv1y=8YR^nNqogJW#3R& z8%NWLH8PfBV~+yq*zdIPpRZn_K-t(19(q-S`B7$z^t1atU->?iFTdJ#FOG*Kgu8Eg z56?OdP(rUC37?<>5d1tXpD-Kaj#-lj7^n)4C~m zgo$UZLumBILot>wt}= z>XeXjMKp2LH;$gpeZI&&(<_ndC!-N&}e9HHUuIsF6$K(yY+3;GO z8e%u(IfWmaD@&iMgRb|!aF*(4Lj?y!)`yU-(dZ}?JmR~z(3wE#ej2?&Ew8E^F;US)&xIkI9O{9Q@3W`vA5ER{>@yg?Uh&an@sCtn4aYBed=4QV0)7(VPKW8 zWRzao`@<)A`NmSWf|;e^?O058;g8SnIxiXj8H+ins$`rhiWgZDal6I@h!%>XVZWt& zQDerd1;T01XsvzGwP4biz1oIdJAGUCZ2ECuG9`ShgB0CKa1?DO$)3NH7QuC@?Tvb} z@3TvzwU$5QCU;r-W<7tLBm8`?@@`Ih>L|myELmuKPxq|*@NU~UVEBK3>4&yh?66D6 V3OrRaQRUmG)5n|O%C-N9`X5CtX_Wu~ diff --git a/internal/static/performer/NoName07.png b/internal/static/performer/NoName07.png deleted file mode 100644 index 1bb5f6f82ae12737667297c7e8816020336d9bb6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8661 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 diff --git a/internal/static/performer/NoName07.svg b/internal/static/performer/NoName07.svg new file mode 100644 index 000000000..ac90cf6d1 --- /dev/null +++ b/internal/static/performer/NoName07.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/static/performer/NoName08.png b/internal/static/performer/NoName08.png deleted file mode 100644 index 8ff7ff73494144e9b10407a5e2815101cb267ebb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12597 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 \ No newline at end of file diff --git a/internal/static/performer/NoName10.png b/internal/static/performer/NoName10.png deleted file mode 100644 index a2b72043ab809118ac237a07d77fbbd85cb42c2a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10761 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 Y8&?KSe$?CzUmXh2O)QObX{T=fKN9cx`~Uy| diff --git a/internal/static/performer/NoName11.png b/internal/static/performer/NoName11.png index 01034c2b035f0ee06e151c46534de30610aed8c0..45158b09491b1808409fef7b0ddc9f383d96f936 100644 GIT binary patch literal 3587 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 diff --git a/internal/static/performer/NoName12.svg b/internal/static/performer/NoName12.svg new file mode 100644 index 000000000..89843a774 --- /dev/null +++ b/internal/static/performer/NoName12.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/static/performer/NoName13.png b/internal/static/performer/NoName13.png deleted file mode 100644 index fdefafb599bed0c29400490a85141e2230a6eb07..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8925 zcmc(FdpuP6|36x)5jL0F$Yn^JQK=*wA>(#t3Uv&vgiT4gq!LA<8$vR}AoojWWJFrm za^2mw(nur{l0}4EcE>G*@9WU^+0Wzm{r>%X9Nu%z`}(>*U$6K3oUr|t=3*pSl7N7K z7!5VS1O%4B|99OhmO)Kutny0%0l`iC_c2TrDcj}MG!>9-^6FdVR5$&9`P;SE4*r&w zUb^{TAD6m)>+tRITMf*xRQYYHZ+*TseXIFjZ~m`tOZA8TelY>!6Lj8dZX$3)SRRFk zm8VfVZvg>ODdJyHATjv|s9b)IW?{O#cf~Rhoi#O2^G*r~C<)L^j2IW%hCAwR4Wfyc zyBw3-{|5_hINTb`R*PSZu*e?*Xz~dzfV>N z(nv#2^D8&tix*jyxK<)d7Y$v!sC43z#FnmT=HhjoO@ICmt4iS?x_wAQ_^Ji9x4m};-gx!GO7!;ae z;vx_8_L+}S$;Bv z?O~x5-1MC;IhZYE?~9UEYYlK_!4OPPe(9E#5+|@(YNL-4{hChNgY0evstNDCNw+0> zvVTp?#$q%2M#Mf4jeI1Jq0cu2OJk3GsU#!;XHt2-iWo}V(DmK<+4ZL8Zprd{m5TS!H*y!`Rh$A2$1TkLF!$Oiqf?2Kq3im*|3Q1{ zJ@0p(ovRQr8%s^2PMi3RuWp93>4&FOzZ_*X?#X*PzI%2Zf){w{XWhB58AI)+Oh=SG zm{iJC;(?SW-qP~iq1urYDU|Heq4RFra-fOAZ>v@cW{kLezWznG&MAUTO2NxcC4I;@ zr5m5C;a_l)#O!hQ!(mEG{8WlH(~XHSKKA-J4o8(wWXPF!&b^;)^g6K@9~a8BDesIF zZs?iPAJ$l?lfee7+2w&AWypBl*j3HVjE^W+f)jS>LeHlKLwB4>ulzMhQKCco-tXtt zs-~@!IffrOwVrKWBx4dcM37KD=+ibE-Zl+G0HDdoGrh<=ep(eV?)?r)jQ2cyAc$$)tGs zmYo^5_uR@|hXq?$t+^QV>9RDoo8vG|4sJR+Jm7buDI(1&G6 zrZPoZ5JNAuhsDjWh(pN}!+pCpSt;FC31;Voq2KAibX#l{hFyK zSjlYo=nZM?%_UYIi%Q}in*~ixScoAgS;lTJ&mNU$Cc2qpspJWM*UHh<+a3xr{^3pQ z5xna8oeC>f8%!@&jbdtqTBDn4V`gQ!+`hm5u*Mzdy{2FMddypV?(Y(q(c8;^{;&OW z6RV4wGl}k5ZF+%&`l(~5=PWUUO}edd#!m50LG(EwrR0mVCT?ekPr|UXpZZ1=qZbO9 ztOYk2EXFgtN2*kDA^P}kToz~gkAB$iG-iLjiaI#M54vZ{679oPFb4;^aYmpS9H$w}SVtgA$uQStaB#6Hh-+ zrRK)ZE!^%K{2hz)}^Ag2blhI)y+!Uz0ogLl~x3(1UEgr%e8oVamLEa!EB!8 zif?onkN5vHmcKIkkjXsD9?xxSPg^ zqRv%;UMFikx5;DGK`Y0vgXNtUt^D3KhCDHQcc(8(Zoczp{#2?Obx^ik*DNPJ(=h#& zyN-URe0Ar--7c{9ONW*_-Mr-cTC+8pRMlz16MwR`eBd4x#7oJE!gZ9->wyW$hc|SL zA2x0NCFp}Jx1jB*Q*|;Ss0q z22FT@WsZk`Vt1DX{H$}yT7)Zy#RV=K&N}GA%wbaUH`Z~cUt9b^LhzxP;E}9&Bez?O zu;#G)FUMBUKzfw+cCMnWzF&0P@`cEQczCXCU(lFg{KvhoKK_Q=qOq;I%`*P3I&*QV zTvZHpsT?maBrGcIiRK_!pfogo&SR!>cz)X{(3 zax}22%O0Xh?XZ+nVN!)xmO)w~c=h0fZUz-(@QnKG)qp_rxYep4(3&A!27T zG=)ZO8d-;)%9-*?oVq*4_ddlZA)uHRTLcZHhRzs{=U%o5mB!9&e(h9llR7Ka72$-B zr-}Tyq3ZeVCdw1KB)35B&k3GS34RNP1?&7V8?j|TXDDs;_tWO`%tj}%zh4YH$0uYP zk0XGCq|Px}8=t=xqCJ>cSJu^bGN<#9%4*;J-yx=*+3~Hbk!>=q%Q0t4!5ZRD1`4}t z?AzgnS~u9YA-H9M*X)?Co0`_Th7R0Iw(!5NkIiIBDbyF4&^wF&e4HnZWef`%k+=;M z>iR@eO3^NrmzGL0Z6wK01~}DSec-h$GWUa8NdwE7S0=$t)=PKp2xXiQf7Lc>Oxd}7 zK6jQGUU#M1yo}5VEf+i*O{MGzjS2J!Vb6Rx9j(ZL#;y%8lXE-folin0Zpp<+W4|G{pgdv)6qSb;HQrEX6a|a;}+X7+q7mwFyqA9kYpos$__8T zZ8WmXdb8?cBlX!gy8`_ zAeNL@Q(7?;sfP`;CE-l$L|&R73p=4fw0PRZWhbmQr}(&3?&5Cy6IIQ}b-px*$Z-WJ z{ZrhCS7vTEJS>$|@zjCGKY`QINZeO8FTAWpk482rH3R3;Z_Sa+H&11`iHyCSYL_`I2_{x_FZVjq^MA9$%ujx8&2jQ9Xk?3@L9v~x(aj!E^M zO0f@%!Jvh0+jLF`%!_b~9CU%H0_`s7XacgOq}!d$Og5!bPWE1+k9#z*=x+;tJYW!X zjV;G*uoaO(!AE-pZB2Tx>q?V)8CyMPJMpxYM%E5* zlQi9YQxDsS0BIxm**Xn1=5`toCu?pxzp-~r2j#fiDkJ&Hdw6B_V4i1+G!L*3S}M6j zZ>K?m2&$qy2&T~?vjZ(lW9y2wiG>~0)vd;qV;@K=GN-gOA(R6jfNQ(!A{f8F+=!4} z&UbSH_9$@KoA+ThDV~oJHhT-(;i&gf@dB& zG_?}ZFm!ET@k)zy`e(Y#0zN2No5%Z1$}D}T*5)#Z2hgOsSCTC3*~>q$3dX%FKrA}L%ljfVvBx^Sig$fO)^c?i zN%(bkJCY=Y_O|o-t}{u)$2-(;YWqHnty!ZoQ6)}@A>#MG@34KX$$N6xac?eBDMk05 z>0W1?7%oEn+_f+-FRzYJ_U)N0iN|5m)}Z{`JZ;TCOv1r9LL`wa0EWb4qH4#%S*$;g zu+K-5W39olk)q?{nUf-`xw*Qa#%NBO8i&a%<;6fkzQYBOr7Cjf84}6@?snaT;0YtR zH)A{7`pL0JR07Lu8~e#Fy{;VquSClVqJ%s-lp$CL>q&llT-OW@&nK^sEj3GHsUJ=0 z!Av)?^;m*$3HUh60aAUIyg4#>;!h^8_{@Ccr=yu@YYO;32{Bh=V(akrDSJo=Ko=AA zG;xYuJwEfDI5xeLXSHv7oQzGE0MO7`b>`}WKfi_j19A>_Ay9*^k8EiNM8%6X+H(9152VS5q!5wlr-k&t9=}Gaqt8YDnkfjE0;qNUX`%j84}5aQBkGX zi=k&&ZR@t-+w&(1r7_Bd-h=D5;!HlOiJ98?6)i(z^(?>;=bKS7$9d|@yS;>9o=G4$ z-}A^rCa?PS4b*ehT5MV;YwaDqo=|pdT1-DmhCPozM;4%`^>%8Uq1+an@3l`+?k{^y zOy$JBw6zXUGi3EJ0ayk+A>+0oq6l8mAk6(e(QcZ|saXSy)tF64ch_AY$6o50IhOx7 zl}BXN^VrP{~P-i{nC z3o_@|l9ZDqB(}V_#b>#32OP~bs5#~1WRFU3c_rHW zF5o)8X^zQz*!u&`ZiehqtGQG0u^9>3YX}0`s*F*B+3_8*NBT?R>-SPA zNrNwUzz#PlBzxq<2FQzcED4vf0s%VwiV{~LY5YBuh)F_x!oK~m&htDr+~bWOI|{*& zw_1?3fAJ++?WPhS3E*WZVycYAC4TCFifEs-lO*oG+1m<8)RZ#dCG?L?M56fx8}Ron zWuP0?PgRP1?g8Ax@_L`x06?g-`>M?V`O0IJ2vojJCooq_0UfX&K1YKY{mmz?(`-W4 zg3kbnJtI3$Vg@%N*)*PkE_UaXo+oA-h>CSMcjE!imM!&pb7@`a_8MXX?7Nlt0TXlS zaJ?pk%q7l@R)92eXOq)GBWmi96&0HT7#h1h7~GEf-BAQjhxVs$B?(i$FOBIOjbsxX z5$5myfF+w=vL1VH5YqI$ysm~Jeyn4P;rcj|!GEa=E(IujUL%YhOHA_1^j2{UZ5=aq zV{EQfc7a2N!8O4wzB>^WGB&RQj0d8$Q(^Oj9edV|P6x;l4b2e!@Z2xVvl_8p2Or;J zl${ceJ|yQ0^g<=!JEWn*8@Lk7q8K@orqKnFw-^P3FL@Hkqga3Kv?oR+QQO z{*L}=$-${P%$HjP!5#yJxB>2Twc;~kkoi@3YpLQwddjM}vK}NaLzX{A0}z)0V>vf{ zw+KOZEwDwDY-1vJ0I;exSRH4E=PL8I=whs0yI=Wu#|vU-hHnf`ZdrzRC$8?@1oeV0 zK|+8Gi?BizFySH=p%$AIe=~#R^FGSD%s7_%32;L#8iH}>8mM3!2LiBoR$Ymd#sZ0C zc4>xhgCWBIXUfI9|D#Fr>uO}}oZaeCjKj;*%T7f~uRJnM>Q+o)DwA`?_B%#~F^(Ao zb@*M;!dROx^AiMteWBi?Kcc3Sg6uR%WOIdp0Oh1*i_Aj?;BWg8}eurz{T8N`_ZyWylU0cFVi-#lCajtLp~Vi)l` z5cy2WX)!qJIp!DzteoO3GDYW>=#g<{jXAw#??Z1fPIH1>e{EffUu}OBD+#nh*kj4e zrwBT^8&k@DcM2b|*@Bw3Ksv##*#gn1h8K9PF!Te9lz&D;6{i-CNG&b5t7O|3BGf#S z0Ca)W>7Bg_XuQ3gm{TSopxb$zMQ>;=vw{Q#To8-~q-_tHWBb5K8Nm`zCy9xFhU$N6 zg!_ttTbaZbONeFvRJwd{fw$aVG4eo zaZHCOp7BlOAuB~qOLhz{4kV=Pa`E>BJAB-&*E4fA1ClmsyX-j}+j$;V2Fkb|<#8K% z7mu^(_Gcu{U&vg`ElSCog@lI)+C=+4{@`&ie_t?V{;cc z;X9VhILTEGaPokW7vKAL2`-t^Uwl;KP9%G7<=39jeaY(ICu*)z(%!fHVn2XHjQ%+Q zLURNUz@)iH%HF2zp|iBX-r%%z$^5iA;${%{mG#!01a}>JjH+nw4-Tm4amHD)+0^$2 zT~f28DG>h6E6r>@li@%N zBvtSiq(NZni^^Us{>v@@J$gy9gOP;Sp~1e|4PZWYF6k`Dx`VF(F4PG3GN0TRRjrf1ryhr@a!S+PA@6s8`H1A;SZ-~ zQk8Xx$mq`xz1s(gjBg4IBKSY+`3CTnex*4j34+s4Er9V$=Cz4$xfW-VJdYBV1JPj7 zrl@=&5>(bLH~U(-{r<6YRER1fmW0 zvv4^7geHbQDS^EeAb zG@MkHNn92fGF_!#uIG?In^MRm5@9ZL?FnJ|ny9h_WxgOwb6%^lq zh}dQQXAuIrWz$87UPD4)yQU?}L5#}Y-$MpB1aWGS7&gcV4kX`y*#yrS#qWrP)K2{? zHiP&kJi`D3P5T-)0aDo>1-ib5XD~wzfLfV^eBXbCy;Gd)Z2!^_k#A9tNl{)7ITVk0 z?F95O*4fm2X%K{$Ps&M)_`oaEY<~l9rDR&+WbA$14?ysXyKOL_%uXqiurGPHml$s3@@?Z5IkV* zPYU;p=n}nfAO>^RS48=m(W5@NzBdO;Ns?mCQRx!nS4jVyX1v4+?yFS87q{4;O@jb< z$BH5-gfFDI^$zXKR-qpPusUaU(Oxau+ z6++dl)!@x~SN;(^$gp$wD>&83@Y{uT?nDP16{1Jm6_AeFDdFTqQ{KoQ_U@opUpU(# zfF24zJy3ISpS@>b92`?xJ)g>>(x=+CHWsR4W%1^Ekm4+597O+d8$GaRW3cBXBmy1H z*s5^K{l-k{CxKXl>#qy7u(G6U{a^;9I?-3NK6~g>W4vGBmGD5ILOgK{SrjJ;63@-V zt#t5lW5wHWMi>5K3Uo>-lG>=ROXj5U2g3}s(Sf_-$v;hzW!6C@I4_~KSa&)6veBZk zJhFXs2!=dpdvXv-ACKc71<AsdD?+V)Od*3o*=NqI{XI?XZif zICj)4AvjN-hFUbdIWwnVwcP85X?Wg4t^1KdXYhAk!o`2rQStDhik$ALKV5Avq5}bQ zZ%yCkO!=xX8%MR{=U%J%SvitI*drb;n`V;z4qXg^4gOBgIxoW!$t^GE!QA2ETPgkf zBZaS@D4?`|f28pB69s1Ue|@B|G>rJV0l7CXA=y3j;hKhrAAD6IKr^*8xljH1>i+ \ No newline at end of file diff --git a/internal/static/performer/NoName14.png b/internal/static/performer/NoName14.png deleted file mode 100644 index 20a20a2095e2754ae9892651bb3d58b7f0854eb3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9840 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 diff --git a/internal/static/performer/NoName14.svg b/internal/static/performer/NoName14.svg new file mode 100644 index 000000000..1d0231ab3 --- /dev/null +++ b/internal/static/performer/NoName14.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/static/performer/NoName15.png b/internal/static/performer/NoName15.png deleted file mode 100644 index cfc9d3a8c3b60b5c28887087224807ad08dd5432..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13849 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 Dzov|I diff --git a/internal/static/performer/NoName17.png b/internal/static/performer/NoName17.png deleted file mode 100644 index 068d1cf734435bf9204de7539351fb01e166d712..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10878 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 diff --git a/internal/static/performer/NoName17.svg b/internal/static/performer/NoName17.svg new file mode 100644 index 000000000..8df98d6c4 --- /dev/null +++ b/internal/static/performer/NoName17.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/static/performer/NoName18.png b/internal/static/performer/NoName18.png deleted file mode 100644 index 179d1d3232c8b05d87bfc0c3bf9ff50331930689..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12196 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 F{|{_hw;2Ec diff --git a/internal/static/performer/NoName19.png b/internal/static/performer/NoName19.png deleted file mode 100644 index 7349c26b2bf70066d38aa0e71f4721259c252279..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12482 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 iJX`;dXX>HT*AE7he=9Yj7 \ No newline at end of file diff --git a/internal/static/performer/NoName20.png b/internal/static/performer/NoName20.png deleted file mode 100644 index 86dd404bcbf5ae9d55209758be23ccc882f3a61e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12116 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 P?iH5iHfE2Ar>^`jQy>vJ diff --git a/internal/static/performer/NoName21.png b/internal/static/performer/NoName21.png deleted file mode 100644 index 7bee5cdb64259143049894a2cc77370b0bd97988..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10897 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 \ No newline at end of file diff --git a/internal/static/performer/NoName22.png b/internal/static/performer/NoName22.png deleted file mode 100644 index d92384f9329ec713f5ce0a2cde321aebe74d49a6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10689 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 \ No newline at end of file diff --git a/internal/static/performer/NoName23.png b/internal/static/performer/NoName23.png deleted file mode 100644 index f28ca89c84744171be35a1acb30c6d75e8ffdeb8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12121 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 YqsB?T)+e{X`|S-xGh5Sg<8#sf0|)kJ2mk;8 diff --git a/internal/static/performer/NoName23.svg b/internal/static/performer/NoName23.svg new file mode 100644 index 000000000..3156c267f --- /dev/null +++ b/internal/static/performer/NoName23.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/static/performer/NoName24.png b/internal/static/performer/NoName24.png deleted file mode 100644 index 7b9bb42a26bf6c4b136f6e2d6e0f6819840c852f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11273 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 diff --git a/internal/static/performer/NoName24.svg b/internal/static/performer/NoName24.svg new file mode 100644 index 000000000..3afd26f25 --- /dev/null +++ b/internal/static/performer/NoName24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/static/performer/NoName25.png b/internal/static/performer/NoName25.png deleted file mode 100644 index 1f4864eedb5982c566f0b7d527bd8e35942e1af1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11157 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 U1$Psi2^Dd6aJ4V9J$C7T0m$F0UjP6A diff --git a/internal/static/performer/NoName25.svg b/internal/static/performer/NoName25.svg new file mode 100644 index 000000000..ab040b917 --- /dev/null +++ b/internal/static/performer/NoName25.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/static/performer/NoName26.png b/internal/static/performer/NoName26.png deleted file mode 100644 index b63c47ab51b60f55ec937f541f7fc3b57d6a9d57..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10647 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 diff --git a/internal/static/performer/NoName26.svg b/internal/static/performer/NoName26.svg new file mode 100644 index 000000000..0c1679e16 --- /dev/null +++ b/internal/static/performer/NoName26.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/static/performer/NoName27.png b/internal/static/performer/NoName27.png deleted file mode 100644 index eb57d9cf45767c8174de81fe76a8202edec4fa96..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11884 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 \ No newline at end of file diff --git a/internal/static/performer/NoName28.png b/internal/static/performer/NoName28.png deleted file mode 100644 index c00fb15b5ff2bd3716a7f4eefd4a75b9711b3dc4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12687 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 fKlCG2i{@{rPKQ>FWh8;K{~| \ No newline at end of file diff --git a/internal/static/performer/NoName29.png b/internal/static/performer/NoName29.png index 21e9e27fa1dc3cf7a9a51bff6f115f6edd2cffbe..8a53967a55f999a84c39428076586e11f97b3d57 100644 GIT binary patch literal 2990 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 Cs>O%^ literal 11191 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 diff --git a/internal/static/performer/NoName30.png b/internal/static/performer/NoName30.png deleted file mode 100644 index ba968026df04faa2898e8f4e54a077c1182b4cc2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12278 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 J!Ceab{{UY!5Jmt1 diff --git a/internal/static/performer/NoName30.svg b/internal/static/performer/NoName30.svg new file mode 100644 index 000000000..c77b1163f --- /dev/null +++ b/internal/static/performer/NoName30.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/static/performer/NoName31.png b/internal/static/performer/NoName31.png deleted file mode 100644 index a4003fa7544ec6eeb6c741cc68501aecddfabebc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11289 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 AwEzGB diff --git a/internal/static/performer/NoName31.svg b/internal/static/performer/NoName31.svg new file mode 100644 index 000000000..5504136d2 --- /dev/null +++ b/internal/static/performer/NoName31.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/static/performer/NoName32.png b/internal/static/performer/NoName32.png deleted file mode 100644 index 0ca4aca171885302a3b67b4ecfd2b8500b81b120..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13180 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 z 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 n82kId@A|*&pNm)a`n^l^ex%U47C29)i diff --git a/internal/static/performer/NoName32.svg b/internal/static/performer/NoName32.svg new file mode 100644 index 000000000..ec72d0836 --- /dev/null +++ b/internal/static/performer/NoName32.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/static/performer/NoName33.png b/internal/static/performer/NoName33.png index 38ae2116c799f46f61dde20fc10058dc62936c56..025a1ff7fe97d03f7dfae1607431bc40fc05e7d1 100644 GIT binary patch literal 3506 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 literal 11984 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 A2><{9 diff --git a/internal/static/performer/NoName34.svg b/internal/static/performer/NoName34.svg new file mode 100644 index 000000000..49086ca8a --- /dev/null +++ b/internal/static/performer/NoName34.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/static/performer/NoName35.png b/internal/static/performer/NoName35.png index 92d9ad7845aaa3be52b65dbf2e08f18c64ad7d55..70dc814436b0bc59d55be9d1d9d3a7c16b62d771 100644 GIT binary patch literal 2503 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 DC}Y2h literal 8573 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 diff --git a/internal/static/performer/NoName36.svg b/internal/static/performer/NoName36.svg new file mode 100644 index 000000000..b69ce0aa3 --- /dev/null +++ b/internal/static/performer/NoName36.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/static/performer/NoName37.png b/internal/static/performer/NoName37.png deleted file mode 100644 index c47f0abac68d9348a268333a831fb2e81ece4100..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9818 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 Dd+?nn diff --git a/internal/static/performer/NoName37.svg b/internal/static/performer/NoName37.svg new file mode 100644 index 000000000..d0053cb58 --- /dev/null +++ b/internal/static/performer/NoName37.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/static/performer/NoName38.png b/internal/static/performer/NoName38.png deleted file mode 100644 index da9fa37c926cfde6943c277f9b41797cb4061d8f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10489 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 diff --git a/internal/static/performer/NoName38.svg b/internal/static/performer/NoName38.svg new file mode 100644 index 000000000..0131c7efe --- /dev/null +++ b/internal/static/performer/NoName38.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/static/performer/NoName39.png b/internal/static/performer/NoName39.png deleted file mode 100644 index a7921d01da971900bc1568fcbd3c0f30321c1be7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10928 zcmch7dpK0x`?nM_CDBMJ=g^+c3OS$IGe~Wt9we!#976~V<&f~m9%d3khdpUeMJa^v zbfn4oker`#9;b55Aip(rz2ED1{jT?YulKL_+U!|-?X~W;)_vdWv+l!=JABAej37f0 z5D*X}!)Azpz)JAH>f7p-peB~7R4X8`!r-tq)m)yWE(h(C+rD>;%1$|mfB*mUr)}l% zpMn0A|9cI1^`A;m`1ALl_W%9ue=q;1<^L%BpW6JX{~wM2A0Pkp6(2lB2Mk?t%F5DA z;JT133?4);zzzWd0&6z#|5pg4-`)f&MJ|$U%tiWFuM`m!^qXj43J53&kj;##m)gg= zYHqZ{_pf|a_S?Vn%Nw!pI}}$%3Ms6YJk>C{)bkHn-9=~k(DJ5Iuhd6b!P+UJs~0w> zesWt_7ETtNjR+SMh4>d4{(YHH{#=p>|4K%GU(t*|*Ea0;rKrNc{&f4(arpjh{*hpC@$lZ0X+Y;p)8uCs&j;J4-SiA!H#w|Yz;0Tr8P zi#JO^?{!8O8tNw!sa$VI6}P@vDtA}Al>OtSKA6?@)Q#Vc)s^dgBqq2|!OiClfq+r- ztroDq7g2xIga9!;TZEvb01J*Wr{x4j95d!ri9;e#(-4eXm)?Lw9xAr|5AaK{PM zgrc}bDTW~!G-SSN z;E6O?+xU*%nu3UE;Aj>@twe!|d>SW*vv8n=MK__Zu?M+FtkMAH9bjb4SWkLNJEi9Uvob#Kw$U$Y+C9;k88VuQ< zC0bFCc$G?czyIst5(29<=*S>WTUO%4bTd;uk;#DHD+X~Djni{nWl|&SE zmH86^tBCQ60}}}dI(7cELJYO)gQhS7J0wLj?x_S8>_aIEsjO&f{qdWUh;e`e1s1At zgA|!VqwnfJ7!e@bEF(%oa(mk*Sg}6~2y++IA&3*(`J@LZc__j0K;G~!RX`CU)+Jo% zY`;pC6w{ZsgJY_324EZfzJmy;^|^AQ<&n7Q_c8d?DX& z9mAg`6$P>T-60Gy%+y()n>e(Kp5Cz*W_f!|E%X!w+;0D}8fG>Bcm$2bxymD#Chuf$g|3+*3$3x92KTffQ_8bse=xNb zJ?I!{VRGourGHi-GSOihu9^}NUR%2Z5h46_r=zcHZ4x6~YUoCHri&!BwEcfv2u=qfYGkM3peoHHdQ*gZVb z;CSdEN-0}XDfcO!zO;6zeNl+IWyJ#n9VDM2;COUV_kYCfxh`T(noLX7=KQd zIHIg(56`@uFS))qmV||dTqxO@a4zE#q=eA8PdkVWMGjvM!!xJkz8#%bU1@~f^D%VV zNb`JI(1pf7>ka*Dx}f3htSO1@zSJ@u>geVyggkq(Lvr}y(|vSqxi=kA-GOpT$aF{P zEmHWpoNe*MEm__USjjMgwO2U}(Ybd5=>Bh+EGf9)IQ??2pt3nb?WjUa>j(4Q+Rsb? z;#BJ(Wg|!Xm1@V+ETwv(LElT)6ZK(z#2`e$B^!$R~UXS zLysRCkuF!>CQEHuFhuTV5JN=xaPXjs9{)0RlpJCUA;wNh_MHkwoR@pfSrJe|OP1QP zhbW;4dgu z+0+~W87+spQ=^E8>(o|9ENi@~=9u$GBko7Npf@k%!Gx_9W2n9XIU_bvFjO)1s`Cjx<5?!$`vd2wwL1psJcR^z&pzf9oMtMX z5xW4$lDxW8!1^9E(oN#Q036leZAvo_c5Da?uv9_@>YXRe+0XROe+K-C)Liuj{8S$@ zOD(-j2ZY5h0rKa9L($*tiyZvC4zMr1QaR?6b2+?p&L+~02*e<0^3Q`+iZRCZuY!pX zVds4Sai6w>F6snq^4!%c{5GTT6{~X4R7ijiW2TGgRZZK~Rr`g_KtuJftx2CN z({?l&v{rQ6EQHYeDZWu<|1uPzW1$&dukFII-zRjs@Uv)XQ9Jv9bAn)_=*_p8))@58(SWb`Gg&X^PJa*7sy zGvG8hwBC#lS@C&88lraU54o`=5V)U)@&XGK``p-KzaIz&_rpy{E#rIj_qnaVU_pza z-gD6V(RuHh(@6GpYF+-NZ*3uCCxgTFP>Q;3)+HV);iWyc0w+%O#Sx@k&SqXFAxl*$ zYNX6oYp~F*q4mxS>-!ofRzqO7h6juO_-F^tNi=lVJEdPz2-wd{X$xz8DHT|-C&u!nG!m1Wh4J##3CRk326hdeDYGlY3Xdd{K(qT^WQ~IoPmM^Y zk@?y~2Yya7WMY2-3C|SRF$}px2;(^hY*^8IFewv^);fX&mN#z&88SPp;14zM!6b1b_W00E2CV;%OlfCeHF z7&{!?a$-=9i_BlVLY1jG!VbKXevyWnkqT?KwH!QL_U`5NmAFraA9d;EGgSgEJQu(C z;Mbe7zDr3|9d8Bpr9`g8xN`7Hpe0rHhKWuFukwW9;+@Q(w-Xch2wFo{KgYCSeAGwo zLVfC6Aqb;hUiyoP2IzcR0X9d&i$r6W$wzhcMi<#F^3y7d;hGrL|M13HlWqaL+3~`Z zTZgI$EiH}{&9HxN`{Zya(M)kBQ1@Kc%k`yTE)$N5cE8^Eeld1IbE*`ioYhWoPW%+p z8?u_G2|A=WP9kjDca?=bE3xXkJC2uv0rA%#LogIcbYBVX%2SiT6y3YwI=CBU3WNIv z8xdILw*7N60N0P)JAAZ_NW+iQy^9+J@E22$9qs|BJX=%Pe&z}QiqBpdVxV<4sAv;_ zQdwreu$2HXZU$idI3qo+Hohx$2dkj#Cb z+jIEm8H$Hz51+u3(M3Lil(LGe*GBuSJkBqx5ukt}KP@E@NXScc9Wa|1TQK5N4NVw2 zx!(gt{;923U|lKJC*ZomH?0+<^0h@=TwXXcB4j33ZNh}3_G|P!iTc7^yualHDs$;_ zK-#m;$IbFvdn23cKG$FF@;*Gixz*AUKR=_%UrBSLQE3_VmV zO>zUVCoNxC6vDE~zf8yA(uYwK?nlx1Jp`K53OIv>$1_fby z&xP=bN1&T0WGq%#$PS}!krk9K5zY3wR#>(+;y%`Y(gvoSe zXu~i}vwL5uwXA0w(Fb$Z3L!nz(~AMs$&9=5NFu$xpUmm4qkCLwep-9RwP)(wHtbCb zZ>GG$;Q`j!lR&+MB8TiCA z-VzbT32K16h zmQk8>ptqHRkAcuL0dn=b6SndVBv;AUtvBy zMR-YWbo2^Pp7q%H@`CRkqoraCHH@9YTk5I3GWmTUW|Azkvj0deaK+9La8;+~wm2U> z#hB*Ofxv4zu!D9&^8$OPSp>~~?0Sy8tb{Q)k6j(*OXul|`&)1)*wuThN36ZER!Ir$ z7n?I&FAGg=fN7r}*6udjfWT#Wz!UboTatIhy zWRJfA8pLcXFb53{9&yTKEkP$Bjn0mJmR#o(aZ$~lrH;Dmt(OC@t-^b1Jj4P{h8nuw zA0;Q!3228d8tUyB%a9Jfm2JeOZ2(5iG2VCeucT-nV~kcwmk ze_b3m69q$h!JV4~!`rtg{=rxqZneQWU9R`}E1y1NMmd^d_DvVskj*@gZ7chlcS>G@S=F^ zUA(+Cn&H1KFptc2u}hipp$|T-2ayEBlFup{FLNVs8Jo(Eh%CvA`x0_HVcAEcx*d%X zw4?hY0aac@A*bB#*V6FZZ8*`$*kkWs8djt{&)0Vp?8k!evla){Fora4^A?+hp*;vQ zSFS7T=at)V7ulHIE*Ih1{@CFnHmNjI?R21MGabyAYL49hVE7u0?m$mha0?g2@8~Y> z&%OXW@|e!RSy&XmQ+lId$b_^V|NB8h@WV5<9u0#0Vk^Jc{XyH*0DI+3#P=fuYQW85 zCF&1eW8^CqunPybX7NycgmaDhe&p#f<%*<^=gGQweBSXe|BrzNO+!vc{na!c7Y;to zT3|S9Vuv309b&WeD|eQlgiz_IdC`6okH*@p{T7row4~tq;7$}##e#N?X}6&zZ<4if zV=L@tFM_kZV`ps*1A?vrO{8@!e(wC~ z9>bVao_UwGyhvJsF;wtIUvo2V%D@YSnL-Cs%)eON&teV2T&I>O`-|7j*a(E|)7Fzf z&rX3bliI_2%gON)2q%SFTsdx&^(!nGhHR122eVtT=-(n|JAbrXkj?s)u;0PRb)zgE z%zU-i{;Pu#P>JS2Z`5BshKhek9MiS~p_eNC!FZqp_OofVQd#zY^NT^W5$=42PLdE2 z2$~)A!S3s8_TWx9&D@gx?JoAF$9tnQOzW0~)PTFO&Hn2dk85Bt_Pl+=_T9);P;42> zjbfk#9j8~C3A-_qL*89A_txM&yAl_x?s?-g2$xWP+{5fi0M zsXD`^ewUlTdfI`z%xx{-#K*QfwD9HE6}jJAlR$(hVnWT=!e?Y~nYZ;sccGdvK8bao za8#dMcqqFM)(1-XxaeZgEdyL*A5635*7Z7VoZ0=_t4U2mQ5c^fFBNS2_e|eepjz!= zi!;53{=jO+NMk=Dmg%XE!I!GR028yxEP79>ENaZvMk1E(Za|iU%s}6upTQ}AE$D>R z9SlKsoI!avnir(<_b_c)!cnG^*v%@18CiVFFv5pQOS>{Jswg!by#Qq0mO|i3gaOE4 zCpwt(e;hyd?4D30!)2L4=RyfP<~#cLR4dGA;C}y#lQWwWH)G|h1)ja8x2+YK;&^>g zcs8&OGkl(mDgvI^j2xXs6@i`B_>!r=ZKk7Nf14g&~?o6L|G=Y*H>cx;eEn$zE6fG+6p^Se6e#=mM|oYEl|tuxg@HyJ4ehKYv<< zMkJ#>%?dN`%e$*8A1D8=Tsq&>7-!swX@dIss)=iS$l*?h+l&+zgscZ1b^b_c+3ALM z@)i&xYs=3pTM1$6nbV$JyU$Qc$*HJjm$429!F2WCQ?wZzIIAY97WhUO*OKixxC0L9 z(|RY27s)y*FsD8Jo*Hv$MFYnkfmt2xiZE!(Yj7&J1xwFhyD#(1-(VX%6#*sXZzQUiiIJ=$_3po%=AIvGF%V1ndEJfpl5{>gz$4R zn@0gVXBH)hR+Vai#n#^ynHQVNL)%~GCQuo>c%mMjp8z+cA2)Qno07UigO%?=2H4gI zf|}1?gM;!f)4Z1!Y>}GKsdJ(^hhc4Nq1!2ceE}{TX0X%J#ZfYW8WA6%&HG?s!l~MG z+zuEQSp}>3oFEVp&Z#~TBBDPg3}S+9vfDAnY?b(SjMudZ6h~bN+c8v-0F~{!nRDMM zG#c1GhvDHUrN3^JzmP&H4^OWOHftjQ@d}N(?oePJ&RA^?9#yDo>#922)`EHygGbcOxJZR|53jiW7x`uA4ThI2c|9>;p{ZVVh2L>K1KADLJ6v39LhlaDEmb<&uAduASk&eziNkFOqx~CnifUi@P z3zYPoMflNLB_`F=E*yrQylrmQGy=O)%zGu4%smw@2(bch<%^<}N5I2{&IEv7h_a#h zlqu<%LB#=$SBvdkXLDziSiD%gzjsFbzycb7f7cc2DcXhBX7}#yFBd%5C@}w;m`Hc5 zb<*#0Y!if8dXrAI9a*mrwh6$j&tp+jIa(S#!BE@V=A?$V%|CyYX(ueA2!v#$?pa>? zQUC(yAn*@{mgY}5h`*};i~D zRP7FT0TxDD5l0zB<$QiWw7r{10P%in5bp;-%eUH4q!JqsKZ&9FmjxknD-IYG_5ULZ zus!(s3MExHjyQz9I>ncd2Ui;GL2%{(7lt7U*ml5^zWrt+8Ea5rpnxLy!X%-#&~6F>-S7MzF$()i_)LSv7i zU<*=)Tny+;_>wDys2LtWiZ4Q29{?Ba=op$l&!TfMzps1w`5(`?=7ek+T+gx4jH9Q6 zq#ZaPM}79)NttQx9p@jg2Z7V>7*Y16l*~T^@zFoa3Jd#FG`Hq>+X184e-9GMHqF3Z zy_CkMJBlcr0B4j^o_drFx{~ID#*!W9-_t3t9DWa2=#$3Knp0w4%D72(NV-A&CbkA- zoQO{g%|UAdKSf5|4{23{rArZE=mg)SSM1*?4l-TlB3S3QtJF98gx?*!rLn^BU@Y0& z9-OeOD>i?~xT;~Vyg+hj2LVt5W`qgXM!G@|tYXR(S$pxq(<1P$=i* z$z}(v(X&$pn%H7QD${gM_Ky-l+||J50Sbcb#^f{wK+;D$0VI9+)?jnGG_dB8e|Ga# zu}eoa@F{e1)lMg1ACjwh<*e@VAOy=Yt13fNup;*fxXM0LY@b ztCq+si!4fe|~Pbv2~ zu=1tWZPnB_n?QhP{=4KTV=dTgyirLIYGm|pfZeP}onLDwMX}^ZZyvEu82rlM9^yHB zt;LXYTgTHMYl$haNLsoyUQj{0)piSzD09f7ct>X4Oam$qhPW&`0}SJod-jKg7@kW4 zMvm5+k{+pmv8z<~9K`L{hr196`4ft%i+t}*sH+!PLcLi! zSoNo+%+p}}qO(4g+n0tY7|4-63e0F;0A^zh^+^ZTUGUJ_!#6>pWn#Q$tFd0a5PrNg zy4Z$G>tJL2rz|axp#zoi+d!mWNI$%jl-&Mh8{BWgEIB)%b4Y&Tg zHhU>U9vtVV-wxzT|6bOeq^kk7Z$ktGR*&+(1&~z+$7*(v@5KRN^&rEnk7t?3oQ4KB zli`_SyOh`)F;ZlHilpq9hdFT0g!C)g9O%(h_gyk4w)KHVZlBp-$uA4QN+&(Oi~`5V zmE&$qnz6%W(Pc1&|ZR$Tl9~ zABN8(JGKbpSx335uW4I+dJYu0q1^LsyWg$zjNmj)4VgL#O-W?L*wE)IhzoSuvIONxs({fKM+yg5&jLX2Y;AMp^s1qhq-ND= zHG_a#I&DVq%;OM|)@!N&*H|4-qB28)@_nE=%RX4ADS^b%Jxh_46Hc9qXBdox24iAY-FLVRt-6#$E5x(82l<{60A zKpGQ4LEqxuGQ4A`dvpRTcI3S(5d(=f&=0`3N>BHQm>fKFCw03IGFP$!OFqBhJyiX< zN*urZfmV62{6pQ*LTN-4_2}OmdKO@I<2mV1KF4n~zujm`nzVR^hC=2Z=MofK7W`bC zqMo=yc55=$;^iqGBW-3=2&Nab5r0`~HtmyhSCoM9T;g()i)IgVia(XXZhoYh#`aVw z_}1i!t(cO}kyFy;MYG)r&S{(df{*o~OY1WZz`AntH6D17re=04$U`7={*rzn1K$IQ{X!t5Vh zS2C;;PAc%8w_;xYD>-;hNy?@F#9;Uh$r3e8S<1ElhR9T5#pjRp;y}ZFaFd0+%-v*; zGhojol^qoZ-Bhuu@ixKkdB~6a^fS!@OOUWN~UGtqUdT8h`a8!;1E7=QiI#PlN7$yLV1X7q|J^PDIFHxc1YZk`^Bns)BDH zeu&W#=4qp|x}y)LwJv_rL%Mo|M0`QBpc^ueW!!zc_r9f+zQ0@y?L)e+G zk-nX+!)ePKT&CV1HD{O3iYEK3DIv0j<7;p_P`!_d3py$@)dx5YLFR0EtmOCrO=HrO zEiM~o%7qp+cOuRO)+vlfWgr>edF(iE(%!dlCsL+RFf#e*2C$UkE3A^>Cjf|lsp=~g z&;~Mmyq&Pnz2hT6th&WCUvBpaOy~UaHrz7RW%e*I45Y$(4d|icXs9LUm+bP(yxvdx zwlw=aw=CJ>*zD4Lp(!@nGd7pJmv7Sr)(GKfxm3Vibfk0_P$_fDnC79|pw)YW@eyfU5aK&%OJl$bBeWd|K>{%1 z{L8$vm^%m`p*@%47>-`8x=6yCk04a4#g)ZHfX}K4<}cp`jMjIuJWGD8P@;&f#O{dg z)u)O34}iGY%*jrzv9@F73apppCtnI-Tal>W#rH55tDSMFk%bl%bawU(y$IQVva~*A)eg7JHIcS%UwMQ-otyJrzDo1xzF zTXS{5N@rQHI|94xK#IH|{t-RiUVPc`3d8Z71D&4%V^rgOpA_&VgzBe(w6^lI&YwxS z7TllunZU77A79f}*q&Oh%zDr|fS3PZI4$=^QPBAys=_~k@<-2}~)a)@OTJ|Tg46nfhR*OF3jW2)ZY18k}~nc0A%$JX2{jC&p0l{D1?ONvFD6qr(WO}|@tHYX}PC0&vAa2+)VF86I&SO^IJxe6JZ9coRhdP%}H5RXT_ zPc2JN_b;$~V7zQy(ke0-kl>Zq&5JH=rANEh2 zv~mRQM`Yn|7ak~H{3-A_?FyuUtOZzsB{_kw#gq;LvzFEfEHdH?aO~n!cY9Vq!_No6 za_(e`0o%3m0h(Oyv5Y<3fHbz+fZQTd?>;_kPI28)s(D_+h(c<*S8dvKGi@1$hw<{# zw$m3kDX8c-`JJhXWxOd|J_n7R8;Lch1RnRU%y1ET+;B1Q#83S{UlXFq|C_G~|JV0~ k|F_Q&|8!&+FN@#(5V%G*)=(XM7AZhBKV+6`ax(J20O^HBtN;K2 diff --git a/internal/static/performer/NoName39.svg b/internal/static/performer/NoName39.svg new file mode 100644 index 000000000..6cc5080ac --- /dev/null +++ b/internal/static/performer/NoName39.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/static/performer/NoName40.png b/internal/static/performer/NoName40.png deleted file mode 100644 index 0214efad4b0d68b2de1bb29ba60d6fcb9e6a12a6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13857 zcmcJ$c|6qL_c$(+OqMV$_L60WLRmug-P1PnG%6GkEz=^TLKL!#@U&(dJTbCVcCv4g zC7P0@lqE|dvSf+xonF89>+ybkKi}Wu_s{qDc+AY*&OP^>bI(2Z+;i`Q5X=nNF?<*X z1_pK`LtU7GfeCzfj_zgxH9@f=-3$ynvcc!cuZlDti24NB?hql#Q*x2Vnc} z%s<6{hxixZ|5*PI*gr7;cKL6}e=z@x^)_(z{ z9@LP5Om{dK8|X3wGYcAm0_!b9D>nv)Jv{X94u+)UgP@Y-wvnkG%fxOb7Do23gwCA| z42KwubhSwL27jcyiFj63rWuKhJmkm-xbl;Ui;-e@NRP;8B$HxD6y4fp>b8BBcY0#S zqqdTTi{afhUMV$ah^%TM{r^A0|Kz3K9li5PTN7_&ZF@K!$%R=ygXGYTCTuz)O5|VU-hqzi4#u%Dry~OW zW$gFD9>T6AH->)l?Lb9i9TFH3Zp+XC04wnI0ffFX(8S|!J_y8rK9Y`L-wUG{ThVl6 zujw0EW}MjTl_FTdnVSFw*FjO~4D^n#ISJ5d>Z3EPI58?VN|-*puFpgGB}7Da;>7Z3 zA$R}Nf`xWKy!o`aczOk?<;_kcnj|^u#0at8a!xea0kOiFbb^lqFe5mfo^8-TAr`xy zSfNn3YV9&|apTlX!`H&vF*)+81|MXL$^2GHG64`Y);Ez;`b9{#s#I zk$`IMuI_;zf90#))1v5vcuEi+EL;3|2s^z!j15@Ez1Ko(p9X!9vMf&#m`cLE7mUTH zUK~#NSL{sv9@#Gps;#UN_P)0-o869ada;%nJW z3zXF!+`HAis!NSb{x(bekyKmEl+ar zMZ(=%IxEvr(~&p@muBABtb;OL5`4vjD$nPSFo;Vo(_&nGBTnv!`EYM0(%dWSR0a(< zXQART(BWX>(lha^xR0Mm7VU`|{&F?aNb2!n<&xJT8x8kR#ms9T%?ecY1(K1nN8UjB zmeJMW$p;;i&wZqZ7LW^(u(Om=O7F)?%a)Ekb=Ajv?onhiXxPS{QwKv&3qDKzu&u5U zerKOQ777Vc@3ruqqF8o>>dh1#S^7Gxa7*E~9>SeqrfbJsxT<=^ve=qQV?Id3d;4ma zI3BTvJ{7)!e%RdpOFmi{hTY8IjLU7AlFei#8W>V8)#ZFMr$uuA-?tg)!z z5`Hj?Kt9~&o&tS1F{4nOxG50JWpkIjD*>g6|GcIWyK;Sa{Xp#61>sMmBHm z+M^(XQVxFx?OgYF71TXv{(+9`+a-Pf<)&Oy8C~kyYz^xlKgFs1feO{(&W2VU#M-Pj z4_=>3VC|c0Z2X=%1dxeozR0tv!d&r+yy6L|pD?5#hR!w?)Y8a9p{c2US{O9z+Yj|) zW@@ndzJ*lYEJBA5`auwzdil23E<6(b{q(aJ-*Ru=D^s~-ib3OwZ}hh&Xw5ih2MPtl zZIJ#k=F?^)6^c8MGo9s|vKeMzIW9%smRmw(L<0HG51#n`b>Otk9nNZ zQt$Z!=PpGk?O{cAD-*M6_9+#dD50ZQ3-TJdBpCohjY9r!pWfV1J{=c}LtNdT^xZeP zl(#0&d+QvvQ4?L#_b~zB%UUzk@EvHqH0p39wvTsB0T%#wbHdiut09ksA&+#V_4Ry3 zANgPfmDWN)<@J4zO&H=Pwhp0ki#B3nj5zdZ${qAph81M~6R0+@Ypu^GUu#YPE36hs0_Az}znv;bOfhBE5JV&tV-U>S(j zJtt){29Jp*KozHMaMwv#AYB4ccjk$8|Miav>0ymntTa?DGl2An&;xe`2!3{lCeV}G4!sv+7|BSoF4vo*_LT$E#4;E0ypvA z5n7v2ikvu$C8i@RYau3+MP{Ll=zYUAe65pD(e_>~O?-}jT~5FpzuI1O_zblCk?Z~- z%88pufXV&Ywmx3c`nq^d1il~I`)dcK*#|e_3x~S7^t9>jThc~c?eHyb$%f9OLdj?H z6hYz4!$7_fp7D`rMfmC1`k} ztA+3-4!-OaJ|iTJP0k<{+}gQIDU2T{wI^Q}yjeFlvSsMMt<;XiN_mK6%d7-h~tY;ad{#T#P7VG}3-CuuU;v^Sa zqt}A3#u7WYGHr>&rEh$pKTQ7nAa5R}EM&2@isOTpP=XbOw3-)IaAlx(%br>0Nab85 zZ2#tvLTt}+3(+0j_RJ%FizcqG*v{&EhlGY|zCDF>OrPT$dD^rF7jw@}6OIKtb4)r$v9vsGVq6sByCV z-7Hyass=SAO-|tEjWCNnF%MZ%6q;JlLAhYwY}(x6@8?dDkvX*={r3V&uI`;sX!qnn zJd^9oyI(e5lajgCW!z-kMfCrUkJT8~<*#@~vo4OjR{FV}sqKxB$7#ZhJ=67Xg+wVk zOkIKbvy^C_R#CYUNz2XC^CS*_r`EOS{VI0T#qRlM=lW1ulh&U?Bbur-tAL3oWk_5- zUzVI=XnTb)v*;ChVFn=-*|F+)y({cKQo#P;uruW>^~-V7uY0G%V1qHc#WIsc?Kf}t zeZZk+)bFjm&t>j!p^VkGzY8Or6FZi!5h#f3FlM8+m}}^AL;m<6iL76QL6OnZA6H`@ z|CZj>etvrQ<$*7<1w_bxw5Q!E_f1ntheEfhdkAADxL;u6lAFG{Jl_MI38x;xL zyLb?|zFi@+cEbqg#Z|^^)ifj9RKO2~JeU}kiu)~jQ_9-BXWM!7swE#UbUbQZ^H)sj zZZ)&(u{_O~a-3LO18@|>#Qf$NPe#6LnY=fy|6`07J(#t=w)RbNz`{4WXjZ=~P6vr@ zXg?-cba?Mca`<4qFa_&RQ$oLl^#m!U5JD5Nkm(z%`=QVZuI13gYRQE_%O=*aBhWG9 z-K7!S&9iKL!3-?MiLQusJ?ftL@Ns?(R_8|`-0Yne+DEyVyI5|>ipH^9zJAvxy2iNE@We=RBT3pnS{xGOW%jiji23<9tkI^zdUHNQ z1-FymoC)dgqN+0YUyHDxd}Tq79ppH(dMa+CTBP$J+?E30j~H>8@}C<{`8ajr)SVCl zS#*-p;WHVRD|wq&uirk21+hM1;vGvFPO*?|iF3K#BB2Z&GhFw6eSYtna?j_lu0|7( zQ;k|=ije5mIg`Y^I_4KelQ%xeNovRH6<Tz-6~S~ZqHPJ#!i6BDZmzf&ukon8nuI?q7+6rEi_ZP(~S~gX_}HfQwweAgI(9*HW4IE89X0@=mYT zUYx3huNx%~Xb@(Yg1Wo+R~M1|rGKdyhQo5B>cS!#j)_A$1oD$CxvxR+&&WR1YD)6y zsoM3VUkAKyS%157BB)k>CRTDCsj4c(-Zk&qo=YW=Hxsrt*hS|2KeI7IrmP!2A{hc` zdQn<4?!C_BCxPD9Fnf%-_$}A$WvtYD@6&ejzw0uvO{9&QWqwp$|8ZtM?dvQY0#w#&9G@K^+Yx>B<}y)jktU~WRC_PS`t?v46_odGK7%K4{R zwQl#zA8g0H^SB+6mZCq?+=r;3p&S$+bzM#!&-z2mBcf)yJ~Ier7x_ser6GK$N>p0c zh^RXhkfQT@V=_n!v%cQQfeKaLzEhYculFD|XlGU3xzBx8xaIo7Uv7VvS=w9w*v|wX zj&X4)J!8PF=CRS#pt{Rm82EAQ*}Ct#p2HCw7Uw>_P|9(d{LzMqg{r<`&mSjF&0Lop zSDy`ebyK&Kkaum#V>9#RHP)VKxAPGK%BOr=eP5gFh^=otT)JA3(-+x}g_b9s8_V~H z*>3AimYZ@hbX(4Z!tA!l|gEg zT~=9p75Q7~xQPdJ^Fb5UGSA~CGWI?9{Ydm&xH(QCfDhGYvsnIhmKGjL`Seld{pKF0 zCr%Kw2VJjJ4{YkvU3YKx${eRf`$YnO%9eIi9|?6p zX0Bv7bWRR73}yTHl)nA8m{c0uL70ghavqs6HLFm0y1w?l78CqVs?p?3o*uPOG1SNA z`-hGB%CoA~8;}2pJkrJ=?pRp}Epybqs8TOvK!8$5bMAfdht1{|(m4cCT1nrs+n33y zK^n6$0ZRU{I!M~01Pl*{zFtefiSR%(AP=+k9J>T+=odAq9>PYu;w?;^8H?JCQ6l6GW&At=%B0 zZMNT8*?h6`=vbD7uJYFX?DD2}?w4al7J}fH=eEu_K4FWPn3L2SX)x;0AImp9;s>u3MHOYz>bDb46=BaraC8?J$V{9tqvkF%_|*xW zV3CY?Y>~IjxFQzey@6B6`-GW~eJLBI*VyBT?#kP}QXn(FTi!v$TZej_t)MIGGp>dE zeZpQ;7Zl~+l`XTHnY-SSEDeR31Zvwq|8{B$v=#5RWGV>iId?8(qm!7|no}pRk5n4FHr&^qZ?zD{N5M|3{~E13Ut6+QndV^G zA3eSwjl0#F)#g#zTskm$cBX{dBB^xv28E*9st8&$z##z2v>3)@*_eSAa?id`*Z!n`!|4s@gg#%FCyrQJ z^T}G4U<9h<4HGl4pYJb87L7=KDB3tT-)3m!>wKN$yC3qkm0Y+xQnSMzATylGTgehB zy>I4*I7m)#z8v`R_Jvl*UUVw^4tki`?hoacged*YSCBw8HT~$_d>CpFc+@wo*3xH# zSnp=`Svky|q%gx|Y#y6&r%~J0NxzgIf#o;-dylI7WzRz)YS6Llj0c`mq!F9)Kf4u= zAfq2yIa8`i%>un~%*JO4!Tr^gXS#S~F`-hTc1if?{g3Gg1FQGDqH~^~H&z8t;?P?j zj7Kk+HH_4=%JCx()cB9iB08(Z!$fGeN_GoY`?%dR2RXn~fJueWvh42zf{K9LpFu8q z9`2G0<>|D(HOaFLIdWHv*4oA%HL*i4WSPxl^6~~!(hBniZQqE>v4;E;9gWHFQn3~u!f6IOjHzD2$G)9BCq8sT5vOOx{kqv}t(Aw(BJ^#KR^L0H zF|KTUi(qCqoAJQm!Xo}{SyO6PK=&1ea?kPw{xM{x?#-U-7sk}S;Y*eKWB|3g6j@f_ z66G+UY^O0fvrdulCH4KrwPmi_c%8H`jI~XCCZU7I@@y)_B%2V*x-T7RfNq#AneQ2( zAnng*t_dH9c)KE|dK5*F6Ul-{_JL!xgINHKb5fk-SK9{;#r`NNNV4zXU&IK@aw!zU9%-*So$q+=<<>GWpGzdsEmM#OcuwgyuL z1u=e0!KZ5CwKdv4W*`#lP-)5c~y z)=uaswZmB~`A*R4U1+$8VdoM@MX@oGhf&A|A@uD$r;Z-2`f3e1UKmL773UwusIT*@ z8kMcJU5z4$D}gFT8F{CRKks(BS{i@%A3QsB2+}?bnw?$V|PjqwR|Gm zR0^>O9q%wj;~x3+Pdxbkv-s2bT8S`h)icNvI)SC0{pr@tG^&og3}t4Wy|>^<9zHV1 zF~_@41Sy7YknUeCAN>{5@uDI0ep(YWA(=-b-OpP!pu_tyD`>4cJ(wJyl5jzN@NE+D zvDSOnncs+8xQSyAfFRY0ROT9aTlvJKlLj(BI2i`|zgED;Wfza~tzF8g5upPl6>P35 zTIL}W3&OEH-BP8MUGqZK%rR8GFuBSa==_Uz4|M+{p9Vi)ne!`eS?E3DitnkPeUseS zt^A%hQI$Y$Kk%Tzk-T>FZDW8WIck$Y)|!g%N;6?qy?Z?c&K(bd_h{F-t~f&D?Z<|! zEYqVUKp4|!UHv0HpsBoneZoz`0*<`d8!p)@`T!Yx?c;GV#cq}!@?&${GSNs@PK@?i zY39!SC_Env?-5_0c%{`7emoAe&+h&McL|1r^F?CY=8+Vc;!(qSMEl4HV*SJ@O(F5b z5qqPrJ0ZO2zS(2eu`e@*og?+P@Cq9D$(N?4ZCW|Q?vo^=zRSF5mDaE2M8lm{@}`~E z9m#+0SxQM9U4*G?pWN2pzVJD%CzTvDO@)j7b8o#&*5({0gg=7y<*I`eEhH>#O+hHq z4po$(s29I~*A@XRPqtk7$9Z{|-)C2u()n*rE}x|ipu2e_EVv`9id%3T+N;*o%6BpG zYoA zYn0AB3mk#EPDvxR2=?wgX7kfFm#|VbxY7%?aOCE^+b2%_Mo#poJ9+%}^@i>RETAZd zwC)_}kaMZpKVWq!V=v@gZ1>@TIJPh{r=%yBA%HLwqoi>_WdIGfuVMvw) zTS9z9LP(cKG#eO~HT!PHxLc!*#TFMRvFE672Yr6zJ^UdMo;T5uE&7dXSc3cNVYH%@ z3H?mH%wfS)Eqliz?eI%luG7X@LWiZev&nV`LH^HnBTX$hq(Hy)Ocf{Oek*bHM%<9B zQvuOYf{^!gC_k09tzoyBUo}oU7XihBrIa?sLtBZ2~0dY z7tdkf<71KqLaNlm;&B_6FeG^6^o(|$7XF23!-W#1t(?^BilI&*Dj8m%L9u!}l~!3i zn2Q=%W@63MT!nsfzDh8RO3}m*a0sz&Zd_I{;sS`@B;2vIey@?pXBCB%168}}miB?- z`HZPe(AD)OuuJro zGWLZyI(w45e%csWj0xM1GO{2UMd>sp4n7qd8Zg6aO^bpiX^Kz!=?Z3kR7i)sDglEb zEzTx=*}7GJW#$oa_*ekUbM~xvJT)~xk>Rc}g@%RQj}ylN|F*o_jo>GOi8pj4w5Ue8 z@XS;wY_q-_ns&?EcCM*#z_Mx}ebXdo!fj`|adm14%>K9>f`uqFfsTPs$KaS`aJqk) zY+X^;!oeHct41L2dXWp;1ck4Y4*S7xyNqCbeE9m=wqTI&OsL(QPa-CVg}}xM z#P3yZo+1h>y5|u;q3rqO2ez!HDHeGd7U{_6k`apwx81sP%d*VVk#8j+dLxq~h8!GrsrL zH+TtT%vUz_#L3vvb0$ci@cN(gl_8(x^FF=fB|v^gHmTBcf!=h&U=wA@l^!5!PbUfF&!mB~6zyY~xn<3oeU>9& zMTltJkoVN@UH+CIvT&XW{bqlOx#h>?0)_<{yrhT;fcdjj$L9x zT*Vi?k}o20Qtnf~6U&m@AHfkg%c=XXOm56c$lg533SF(ww-AGCTBT-3hBBP(8BoK_ z@0oq7!ICy{GqpDs9W8q_@f%0#3^(3Ut>R`XBO1#1O@NDn`gLy=tZWIceRw6*+QkJ_ zlf=Wki#L~n0c%Zv3t%3S&Sseh*<604O><_%K?Mr#&`mjyL~a~g z-s;plCCsjYGNZ$!yc1s+z@%~LpDxeDLhab8DZ4o9XfEof=>s^Y$8f{IleT9qkD+rj zpcn>QMA{P0d>ATNF*1-3gQeX*NYa-0VE4(gyIU2y{W*+ivXirZyN67OEqZ&4jGGXD zp-#CZc**sB9_i3^9OMyPb|m`SX{YZbNp9g~Lg;L>wbwB@u>Mlj)C3Cn@r_{#4 z(|v3?p~X)$c}ZCt??vzqANk1p;2u=ysq?PEkD>!`Xs=?GRmx8Nc&{9fH5nDx=%&h<>e^!x$je6`ZBTg`@uT9}_*21zjyMJ-9eg&tA8%>%6CAWC zh#F+I@R}E7Dpe;@&O55NeZdZIfVCW2q#cJ6yZ529)|LVk`7gQb~;rt zIum&@e%m|{L{)=0SOCk+AJ{Dbw2%w9{bL*g^+{Sf{Uxyn(eQEIe#ZoDybeULIiT&uonpxX2iSrm{ts(w#gAN)QM)x^8yPDkqc z;KW$@JyXEtd9(K+qhL6DZ!dqyCIRX_S@Hn?@si`J0DTa87RzS*mR}EL?r<=OKOG;A9<}0b>31)6nVPOY-ebYWep`%N93FFaEBhq6!{ zIsNS5H(-k|R@ND4Xq;E@+B$&!z&$(pNl_e;de*g;-!+-Og(s37%3syie2GbNj~VGb ze6#G?-IlcGY+jj$Dc}8Q_HxJ1Kat#)&>txmn^2Rfa)rrbot-CVJPeVf{j2V;M&NT) zS!dle^*ef0F|VA+s#IZAJV3w-=u599_E|+n(QT2h<+)M*DP@;+sYXSvnI_cSD*lf= zrWP}f*2`Q_*Z5`Q`wKrWpTeUN9@Ot)7i!>NK0Gy*5R$~*q_JhS7;Txx^H_lFe<~vi z5_^|+s&_LOr$8}I722L%e0f{x_y+0xvPK;fWLmiNtVH85WI7dX{vfV~WDzcubkplc zPg-RrZ6F_JMUo6QpUdQ(ym&k#K|1`5wRBBICJkBs+$6~v43GIg{wn(Ary(KVx;sWG zi#B!ZOG)1^3T-O>9qjgO!xDsZA%z7Y7F^Sx~LP8c3D^N*Rw0_DyG{6>FeEI}Dq3{*GdlhvU#G=8QqDElk#izi`V@!my zK``U~Z(9%X3es|_%F)p>Cp@Ipm3-?l^=LHCJ~zlvk4t{vt*uT9AyPKK?$=HPyeNVJH;Gs zV91z+HOy zc`nm+m;61DmJZlB4q@o<+;WN9c-cD%4)3s#@2uc2c?qb2e)AXpc=L)W;@9O{+y>@G zFx#v|9P-VA4;O^MhddXLg5-H6@cVtKQNoWwRn?&J#SCn4TxQx;Q-rbob!4Lz;`eym zXYEm|F8D9uty#;0zolm zeSM!2QSj=If_=>cHWyz}c92atxYqjwN8fhSAM%5;;Y%H$e@sU-2EiPXsK?kt;GZq@ zAYJEEFaqRz1+e)K_|B+Us^E`c_@`+IsA<*n0XXO<7%~txsu|5kSHQ4M!wm4+5cCsa z1PkZMxE}zB-hFhXFBr$15rZGxi!uN>k_$MOFd8}h1&IJ4mrS#-Xe0D41pQEYm+`+` z&m{p>c@w4#tSV1&Ul@=L9qTuf>?`Vjv1$TJymtR(0mJ|?qE@gHf8Tu^a}k0C ztRzIIy}qhO$C#U>hu>=%nr8*p``^Mn39+CEfDZUT%+bwsdfSEQ2>#9~4wNqh!pLu+ zACm6rKR|u#=miscU-BRan(jX^!E^U-Ciw4iR;EuyG+l6iXUWhN9H|(I6&rkL{Z5pg zfGL-rp;M5TF+oR0zM@R0N=cZN-o(f8SFV7IdPSHnRnfl!{&ph)x~hcu-TWr_wbT0s zj#({PsgoH@mdB8%GRUL{c^qKBl>oGvb00VXGa7Nz`#MI_IgfozL; z((@?#s-=rg2Oty&IO`N%+Zb(v4g#raLi1PK-2{z%1YM~Zr7>5>)}fTe9rqZd?*k5 z28b&IdT|tJKpx2`<}cx4e<}KZINCuc?x=+`@9rxn5WmB7Jh7pFGmx3HJ`neeeX+7> zAS~NVi#g7d#D-o7rb=9NeX0sI9I}X97>n3}_-S$cosHHtLqOF|dY*N)6bLF0Yjm5g zQ_}LhbXJFleCY%|Jw>2LwKe%C9MKAMA@st->DBf&z`d46d^?2x&BVxDzjy-RNlN)r z0aV%^Rr3Bjwe*Vp*|951^a%~# zBw!q49m-W9Es3E-$iF_IC;h<=H^5Csn_J%kS_#X6_}!U}1R#lhmeFmeLeq1~=$^Rats0TC|3 zycmS>Dtb0q8~?lQv?jGITabvU4G}{`K-}S{j${Uw|2`2jp8a>|tk}_6U`1uP52GiL z47mnj?ZaS~OUD|vdXM)KF^^(ZGO+c-dv?+J@Kox+TV`M%o*Qn9@Bo&V;7h-WZs4RV zwXFQywIM0`3u>@Nf14f2Bl*H#PDSN{OUNBAcE&!8f0tjUD1FuXv;4CH5Ju&dzq1w$ z^h3N2Trc@p(9JzKcz|6+aFv3+*JU-FyBDp$%rOe35HP_TqEzs95u86DUpBf{Ca8h% zUOGKC#dz4k16&g`?!tEq=-@AahnQdVhJ)bSQoHcP3cJvo0dU4Rj(IENLQ4O}ca#vS zMMpx{zTezn9B@Gx!cD;n?m>|Y%X@HTL+4(Olv*^UNrR!nWXXm|7tfaHSx za6jAjG5MpAN5`<&plu%~7kI4cS0~`qJ$1_MDht#g9POd+ls4M-C|eINGZ1FUUo&jx zo^ylPVIb`~`TAzNSvppSbg0t)00^93?%q(vN}q@Xi0{L>CZlf-W0*oje$8FC1=f2) z=X(6*0wpz#?+bRUS3D8dNQ-BIO}kM{k}xVTCRCC)wGnl=8elmu-SKFq?#YpPJXH_> zJWV%+C^PYrla{}fffVE9^EBeikAMdpg?%`A$?t!t^kC^3)_szt{0fST9E8vc=e33V zKrlElPgM}I1GhDwI>F7*1kOF?({y@U6XPGk(k^F>sNo>^>Kcp8>L^WU2#NRcfAB{sRWHx?h7X4+; zZKnCa5Yg)a5tBdq%65458R@l`CjK(+42kRyxE1Omjg@c=#g}c9TAlg)|J3J4R%I&}q zM$O**-C!R_Q=#`Y0xOI@SA2BLl|i9$ZS>W*_0mQT+F z9pX@>w1Qe2@Rsz(%YQ4GfMMIBsxGI01{*_zU6S^YrJy%-{@xzk#KCI+53_5}V9{P; zhh8*cOdg1%gQwsU_RptF+a*6ZuzzL>NJRL8M#XUe?np~(lgU7)V|>NzW@aR7d`+0 diff --git a/internal/static/performer/attribution.md b/internal/static/performer/attribution.md new file mode 100644 index 000000000..3cb40ca04 --- /dev/null +++ b/internal/static/performer/attribution.md @@ -0,0 +1,34 @@ +NoName02.svg - "[Exotic dancer silhouette](https://freesvg.org/exotic-dancer-silhouette)" by OpenClipart-Vectors under CC0 License +NoName05.svg - "[Fashion girl silhouette](https://creazilla.com/media/silhouette/76433/fashion-girl)" by Creazilla under CC0 License +NoName06.png - "[Woman, Female, Girl](https://pixabay.com/illustrations/woman-female-girl-lady-silhouette-163525/)" by No-longer-here under Pixabay License +NoName07.svg - "[Woman Silhouette 11](https://openclipart.org/detail/14083/woman-silhouette-11)" by nicubunu under CC0 License +NoName09.svg - "[Girl, Pose, Posing](https://pixabay.com/vectors/girl-pose-posing-female-woman-311535/)" by Clker-Free-Vector-Images under CC0 License +NoName11.png - "[Alpha Mask, Silhouette, Woman](https://pixabay.com/illustrations/alpha-mask-silhouette-woman-girl-3072470/)" by Wolfgang Eckert under Pixabay License +NoName12.svg - "[Dance, Dancer, Dancing](https://pixabay.com/vectors/dance-dancer-dancing-female-girl-2023863/)" by OpenClipart-Vectors under CC0 License +NoName13.svg - "[Dress, Silhouette, Woman](https://pixabay.com/vectors/dress-silhouette-woman-female-148745/)" by OpenClipart-Vectors under CC0 License +NoName14.svg - "[Woman in long dress silhouette](https://freesvg.org/woman-in-long-dress-silhouette)" by OpenClipart-Vectors under CC0 License +NoName17.svg - "[Female Model silhouette](https://creazilla.com/media/silhouette/2495/female-model)" by Natasha Sinegina under CC-BY-4.0 +NoName19.svg - "[Female, Girl, Heel](https://pixabay.com/vectors/female-girl-heel-silhouette-woman-2023898/)" by OpenClipart-Vectors under CC0 License +NoName21.svg - "[Lady, Silhouette, Woman](https://pixabay.com/vectors/lady-silhouette-woman-pink-296698/)" by Clker-Free-Vector-Images under CC0 License +NoName22.svg - "[Female, Girl, Heel](https://pixabay.com/vectors/female-girl-heel-silhouette-woman-2023856/)" by OpenClipart-Vectors under CC0 License +NoName23.svg - "[Woman, Female, Figure](https://pixabay.com/vectors/woman-female-figure-slender-slim-149723/)" by OpenClipart-Vectors under CC0 License +NoName24.svg - "[Silhouette, Woman, Bunny](https://pixabay.com/illustrations/silhouette-woman-bunny-girl-female-3196716/)" by Wolfgang Eckert under Pixabay License +NoName25.svg - "[Female, Girl, Silhouette](https://pixabay.com/vectors/female-girl-silhouette-woman-2023857/)" by OpenClipart-Vectors under CC0 License +NoName26.svg - "[Female, Girl, Silhouette](https://pixabay.com/vectors/female-girl-silhouette-woman-2024047/)" by OpenClipart-Vectors under CC0 License +NoName27.svg - "[Woman, School Clothes, Uniform](https://pixabay.com/illustrations/woman-school-clothes-uniform-644569/)" by Silvia under Pixabay License +NoName28.svg - "[Girl, Woman, Feminine](https://pixabay.com/illustrations/girl-woman-feminine-sensual-1369733/)" by Calzas under Pixabay License +NoName29.png - "[Alpha Mask, Silhouette, Woman](https://pixabay.com/illustrations/alpha-mask-silhouette-woman-girl-3066005/)" by Wolfgang Eckert under Pixabay License +NoName30.svg - "[Architetto](https://openclipart.org/detail/68047)" by Emilie Rollandin under CC0 License +NoName31.svg - "[Model silhouette](https://creazilla.com/media/silhouette/1785/model)" by Bob Comix under CC-BY-4.0 License +NoName32.svg - "[Fashion, Female, Girl](https://pixabay.com/vectors/fashion-female-girl-heel-model-2023859/)" by OpenClipart-Vectors under CC0 License +NoName33.png - "[Silhouette Donna 6](https://www.publicdomainpictures.net/view-image.php?image=82268)" by Tammy Sue under CC0 License +NoName34.svg - "[Donna in piedi 01](https://openclipart.org/detail/33139)" by Emilie Rollandin under CC0 License +NoName35.png - "[Silhouette, Woman, Young](https://pixabay.com/illustrations/silhouette-woman-young-move-female-3104942/)" by Wolfgang Eckert under Pixabay License +NoName36.svg - "[Fashion Model silhouette](https://creazilla.com/media/silhouette/2506/fashion-model)" by Natasha Sinegina under CC-BY-4.0 License +NoName37.svg - "[Female, Woman, Standing](https://pixabay.com/vectors/female-woman-standing-confident-2816234/)" by Mohamed Hassan under Pixabay License +NoName38.svg - "[Dress, Silhouette, Women](https://pixabay.com/vectors/dress-silhouette-women-dance-lady-3360422/)" by Mohamed Hassan under Pixabay License +NoName39.svg - "[Woman, Female, Lady](https://pixabay.com/illustrations/woman-female-lady-business-woman-220260/)" by No-longer-here under Pixabay License + +CC0 License: https://creativecommons.org/publicdomain/zero/1.0/ +CC-BY-4.0 License: https://creativecommons.org/licenses/by/4.0/ +Pixabay License: https://pixabay.com/service/license-summary/ \ No newline at end of file diff --git a/internal/static/performer_male/Male01.png b/internal/static/performer_male/Male01.png deleted file mode 100644 index 8a486299ab6fc4ea7ab3cbd98fc3778ff84f7162..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 29574 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 diff --git a/internal/static/performer_male/Male01.svg b/internal/static/performer_male/Male01.svg new file mode 100644 index 000000000..72599423a --- /dev/null +++ b/internal/static/performer_male/Male01.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/static/performer_male/Male02.png b/internal/static/performer_male/Male02.png deleted file mode 100644 index 673b120eb43392fbac967b16b2d2c167a8dfe815..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 27367 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 cd|P-vNC<@KZE{p3{)+0Lw*G \ No newline at end of file diff --git a/internal/static/performer_male/Male03.png b/internal/static/performer_male/Male03.png deleted file mode 100644 index 1814d05bb91edfafada0d52c7c3e6a970289d564..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 26475 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 z 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 \ No newline at end of file diff --git a/internal/static/performer_male/Male04.png b/internal/static/performer_male/Male04.png deleted file mode 100644 index 9dd1f0bcc5ba541b7a61f3b63264e3c2ff4ee66a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 26600 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 nZvPM5`+vDKGP4(rGR2N4+4@9X(|P2-c \ No newline at end of file diff --git a/internal/static/performer_male/Male05.png b/internal/static/performer_male/Male05.png deleted file mode 100644 index 35231f91487b3ef09414c023abefbbdbde4ddd9c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25812 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 \ No newline at end of file diff --git a/internal/static/performer_male/Male06.png b/internal/static/performer_male/Male06.png deleted file mode 100644 index 9530d274a09f79cfc4aa2c039cc7e63514836f98..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 31704 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 \ No newline at end of file diff --git a/internal/static/performer_male/attribution.md b/internal/static/performer_male/attribution.md new file mode 100644 index 000000000..119d73757 --- /dev/null +++ b/internal/static/performer_male/attribution.md @@ -0,0 +1,8 @@ +Male01.svg - "[Man Silhouette](https://freesvg.org/1528398040)" by "OpenClipart" under CC0 License +Male02.svg - "[Male pose silhouette](https://freesvg.org/male-pose-silhouette)" by OpenClipart under CC0 License +Male03.svg - "[Bald man walking in a suit silhouette vector image](https://freesvg.org/bald-man-walking-in-a-suit-silhouette-vector-image)" by OpenClipart under CC0 License +Male04.svg - "[Man silhouette vector clip art](https://freesvg.org/man-silhouette-vector-clip-art) by OpenClipart under CC0 License +Male05.svg - "[Man, Walking, Confident](https://pixabay.com/vectors/man-walking-confident-silhouette-2759950/)" by Mohamed Hassan under Pixabay License + +CC0 Licence: https://creativecommons.org/public-domain/cc0/ +Pixabay License: https://pixabay.com/service/license-summary/ \ No newline at end of file From fc31823fd2a525a68f169a43c22131d6ca1f134e Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Mon, 16 Feb 2026 06:06:40 +0200 Subject: [PATCH 066/177] docs:update links for custom CSS and themes in Interface.md (#6581) --- ui/v2.5/src/docs/en/Manual/Interface.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/docs/en/Manual/Interface.md b/ui/v2.5/src/docs/en/Manual/Interface.md index 8fd89d57e..951fb3323 100644 --- a/ui/v2.5/src/docs/en/Manual/Interface.md +++ b/ui/v2.5/src/docs/en/Manual/Interface.md @@ -39,9 +39,9 @@ By default, when a scene has a resume point, the scene player will automatically ## Custom CSS -The stash UI can be customised using custom CSS. See [here](https://docs.stashapp.cc/themes/custom-css-snippets/) for a community-curated set of CSS snippets to customise your UI. +The stash UI can be customised using custom CSS. See [here](https://discourse.stashapp.cc/t/custom-css-snippets/4043) for a community-curated set of CSS snippets to customise your UI. -There is also a [collection of community-created themes](https://docs.stashapp.cc/themes/list/#browse-themes) available. +There is also a [collection of community-created themes](https://discourse.stashapp.cc/tags/c/plugins/18/all/theme) available. ## Custom JavaScript From bede849fa64d959a5f515ae44c99735a72aa463d Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:28:41 +1100 Subject: [PATCH 067/177] Add sidebar to group list (#6573) * Add group filter criteria to tag and studio * Add sidebar to groups list * Refactor ListOperations to accept buttons * Move create new button back to navbar Having the create new button with a plus icon conflicted with the add sub-group button in the sub-groups view. * Simplify group-sub-groups view --- graphql/schema/types/filters.graphql | 6 + pkg/models/studio.go | 4 + pkg/models/tag.go | 2 + pkg/sqlite/studio.go | 5 + pkg/sqlite/studio_filter.go | 21 + pkg/sqlite/tag.go | 9 + pkg/sqlite/tag_filter.go | 9 + .../src/components/Galleries/GalleryList.tsx | 34 +- .../GroupDetails/GroupSubGroupsPanel.tsx | 51 +- ui/v2.5/src/components/Groups/GroupList.tsx | 600 +++++++++++++----- ui/v2.5/src/components/Groups/Groups.tsx | 4 +- .../components/List/FilteredListToolbar.tsx | 60 +- .../List/Filters/LabeledIdFilter.tsx | 14 + .../components/List/ListOperationButtons.tsx | 180 ++++-- ui/v2.5/src/components/List/util.ts | 17 +- ui/v2.5/src/components/List/views.ts | 1 + ui/v2.5/src/components/MainNavbar.tsx | 3 + .../PerformerDetails/PerformerGroupsPanel.tsx | 4 +- .../components/Performers/PerformerList.tsx | 28 +- ui/v2.5/src/components/Scenes/SceneList.tsx | 2 - .../StudioDetails/StudioGroupsPanel.tsx | 4 +- ui/v2.5/src/components/Studios/StudioList.tsx | 15 +- .../Tags/TagDetails/TagGroupsPanel.tsx | 11 +- 23 files changed, 708 insertions(+), 376 deletions(-) diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index a7fecca20..d1fd77006 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -483,6 +483,8 @@ input StudioFilterType { image_count: IntCriterionInput "Filter by gallery count" gallery_count: IntCriterionInput + "Filter by group count" + group_count: IntCriterionInput "Filter by tag count" tag_count: IntCriterionInput "Filter by url" @@ -499,6 +501,8 @@ input StudioFilterType { images_filter: ImageFilterType "Filter by related galleries that meet this criteria" galleries_filter: GalleryFilterType + "Filter by related groups that meet this criteria" + groups_filter: GroupFilterType "Filter by creation time" created_at: TimestampCriterionInput "Filter by last update time" @@ -658,6 +662,8 @@ input TagFilterType { images_filter: ImageFilterType "Filter by related galleries that meet this criteria" galleries_filter: GalleryFilterType + "Filter by related groups that meet this criteria" + groups_filter: GroupFilterType "Filter by related performers that meet this criteria" performers_filter: PerformerFilterType "Filter by related studios that meet this criteria" diff --git a/pkg/models/studio.go b/pkg/models/studio.go index be5d54445..5d1def1bc 100644 --- a/pkg/models/studio.go +++ b/pkg/models/studio.go @@ -28,6 +28,8 @@ type StudioFilterType struct { ImageCount *IntCriterionInput `json:"image_count"` // Filter by gallery count GalleryCount *IntCriterionInput `json:"gallery_count"` + // Filter by group count + GroupCount *IntCriterionInput `json:"group_count"` // Filter by url URL *StringCriterionInput `json:"url"` // Filter by studio aliases @@ -42,6 +44,8 @@ type StudioFilterType struct { ImagesFilter *ImageFilterType `json:"images_filter"` // Filter by related galleries that meet this criteria GalleriesFilter *GalleryFilterType `json:"galleries_filter"` + // Filter by related groups that meet this criteria + GroupsFilter *GroupFilterType `json:"groups_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at diff --git a/pkg/models/tag.go b/pkg/models/tag.go index bfb3f1ad3..3a133dcad 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -50,6 +50,8 @@ type TagFilterType struct { ImagesFilter *ImageFilterType `json:"images_filter"` // Filter by related galleries that meet this criteria GalleriesFilter *GalleryFilterType `json:"galleries_filter"` + // Filter by related groups that meet this criteria + GroupsFilter *GroupFilterType `json:"groups_filter"` // Filter by related performers that meet this criteria PerformersFilter *PerformerFilterType `json:"performers_filter"` // Filter by related studios that meet this criteria diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index d0c5c220c..949929c8d 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -101,6 +101,7 @@ type studioRepositoryType struct { scenes repository images repository galleries repository + groups repository } var ( @@ -127,6 +128,10 @@ var ( tableName: galleryTable, idColumn: studioIDColumn, }, + groups: repository{ + tableName: groupTable, + idColumn: studioIDColumn, + }, tags: joinRepository{ repository: repository{ tableName: studiosTagsTable, diff --git a/pkg/sqlite/studio_filter.go b/pkg/sqlite/studio_filter.go index cd7fc4440..889bd4c74 100644 --- a/pkg/sqlite/studio_filter.go +++ b/pkg/sqlite/studio_filter.go @@ -84,6 +84,7 @@ func (qb *studioFilterHandler) criterionHandler() criterionHandler { qb.sceneCountCriterionHandler(studioFilter.SceneCount), qb.imageCountCriterionHandler(studioFilter.ImageCount), qb.galleryCountCriterionHandler(studioFilter.GalleryCount), + qb.groupCountCriterionHandler(studioFilter.GroupCount), qb.parentCriterionHandler(studioFilter.Parents), qb.aliasCriterionHandler(studioFilter.Aliases), qb.tagsCriterionHandler(studioFilter.Tags), @@ -118,6 +119,15 @@ func (qb *studioFilterHandler) criterionHandler() criterionHandler { }, }, + &relatedFilterHandler{ + relatedIDCol: "groups.id", + relatedRepo: groupRepository.repository, + relatedHandler: &groupFilterHandler{studioFilter.GroupsFilter}, + joinFn: func(f *filterBuilder) { + studioRepository.groups.innerJoin(f, "", "studios.id") + }, + }, + &customFieldsFilterHandler{ table: studiosCustomFieldsTable.GetTable(), fkCol: studioIDColumn, @@ -179,6 +189,17 @@ func (qb *studioFilterHandler) galleryCountCriterionHandler(galleryCount *models } } +func (qb *studioFilterHandler) groupCountCriterionHandler(groupCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if groupCount != nil { + f.addLeftJoin("groups", "", "groups.studio_id = studios.id") + clause, args := getIntCriterionWhereClause("count(distinct groups.id)", *groupCount) + + f.addHaving(clause, args...) + } + } +} + func (qb *studioFilterHandler) tagCountCriterionHandler(tagCount *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: studioTable, diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 8a0561b0f..a926dd56e 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -107,6 +107,7 @@ type tagRepositoryType struct { scenes joinRepository images joinRepository galleries joinRepository + groups joinRepository performers joinRepository studios joinRepository } @@ -154,6 +155,14 @@ var ( fkColumn: galleryIDColumn, foreignTable: galleryTable, }, + groups: joinRepository{ + repository: repository{ + tableName: groupsTagsTable, + idColumn: tagIDColumn, + }, + fkColumn: groupIDColumn, + foreignTable: groupTable, + }, performers: joinRepository{ repository: repository{ tableName: performersTagsTable, diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index 92da1237c..b3a7c1756 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -135,6 +135,15 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler { }, }, + &relatedFilterHandler{ + relatedIDCol: "groups_tags.group_id", + relatedRepo: groupRepository.repository, + relatedHandler: &groupFilterHandler{tagFilter.GroupsFilter}, + joinFn: func(f *filterBuilder) { + tagRepository.groups.innerJoin(f, "", "tags.id") + }, + }, + &relatedFilterHandler{ relatedIDCol: "performers_tags.performer_id", relatedRepo: performerRepository.repository, diff --git a/ui/v2.5/src/components/Galleries/GalleryList.tsx b/ui/v2.5/src/components/Galleries/GalleryList.tsx index de0d23c19..d06aaf3a4 100644 --- a/ui/v2.5/src/components/Galleries/GalleryList.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryList.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import cloneDeep from "lodash-es/cloneDeep"; -import { useHistory, useLocation } from "react-router-dom"; +import { useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { useFilteredItemList } from "../List/ItemList"; @@ -40,7 +40,10 @@ import { SidebarRatingFilter } from "../List/Filters/RatingFilter"; import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter"; import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organized"; import { Button } from "react-bootstrap"; -import { ListOperations } from "../List/ListOperationButtons"; +import { + IListFilterOperation, + ListOperations, +} from "../List/ListOperationButtons"; import { FilteredListToolbar, IItemListOperation, @@ -227,12 +230,10 @@ export const FilteredGalleryList = PatchComponent( "FilteredGalleryList", (props: IGalleryList) => { const intl = useIntl(); - const history = useHistory(); - const location = useLocation(); const searchFocus = useFocus(); - const { filterHook, view, alterQuery } = props; + const { filterHook, view, alterQuery, extraOperations = [] } = props; // States const { @@ -312,15 +313,6 @@ export const FilteredGalleryList = PatchComponent( result, }); - function onCreateNew() { - let queryParam = new URLSearchParams(location.search).get("q"); - let newPath = "/galleries/new"; - if (queryParam) { - newPath += "?q=" + encodeURIComponent(queryParam); - } - history.push(newPath); - } - const viewRandom = useViewRandom(filter, totalCount); function onExport(all: boolean) { @@ -365,7 +357,19 @@ export const FilteredGalleryList = PatchComponent( ); } + const convertedExtraOperations: IListFilterOperation[] = + extraOperations.map((o) => ({ + ...o, + isDisplayed: o.isDisplayed + ? () => o.isDisplayed!(result, filter, selectedIds) + : undefined, + onClick: () => { + o.onClick(result, filter, selectedIds); + }, + })); + const otherOperations = [ + ...convertedExtraOperations, { text: intl.formatMessage({ id: "actions.select_all" }), onClick: () => onSelectAll(), @@ -411,8 +415,6 @@ export const FilteredGalleryList = PatchComponent( operations={otherOperations} onEdit={onEdit} onDelete={onDelete} - onCreateNew={onCreateNew} - entityType={intl.formatMessage({ id: "gallery" })} operationsMenuClassName="gallery-list-operations-dropdown" /> ); diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx index 32836ab24..6a11f7004 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx @@ -1,6 +1,6 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; -import { GroupList } from "../GroupList"; +import { FilteredGroupList } from "../GroupList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { ContainingGroupsCriterionOption, @@ -10,18 +10,7 @@ import { useRemoveSubGroups, useReorderSubGroupsMutation, } from "src/core/StashService"; -import { ButtonToolbar } from "react-bootstrap"; -import { ListOperationButtons } from "src/components/List/ListOperationButtons"; -import { useListContext } from "src/components/List/ListProvider"; -import { - PageSizeSelector, - SearchTermInput, -} from "src/components/List/ListFilter"; -import { useFilter } from "src/components/List/FilterProvider"; -import { - IFilteredListToolbar, - IItemListOperation, -} from "src/components/List/FilteredListToolbar"; +import { IItemListOperation } from "src/components/List/FilteredListToolbar"; import { showWhenNoneSelected, showWhenSelected, @@ -32,6 +21,7 @@ import { useToast } from "src/hooks/Toast"; import { useModal } from "src/hooks/modal"; import { AddSubGroupsDialog } from "./AddGroupsDialog"; import { PatchComponent } from "src/patch"; +import { View } from "src/components/List/views"; const useContainingGroupFilterHook = ( group: Pick, @@ -71,37 +61,6 @@ const useContainingGroupFilterHook = ( }; }; -const Toolbar: React.FC = ({ - onEdit, - onDelete, - operations, -}) => { - const { getSelected, onSelectAll, onSelectNone, onInvertSelection } = - useListContext(); - const { filter, setFilter } = useFilter(); - - return ( - -

- setFilter(filter.setPageSize(size))} - /> - 0} - otherOperations={operations} - onEdit={onEdit} - onDelete={onDelete} - /> - - ); -}; - interface IGroupSubGroupsPanel { active: boolean; group: GQL.GroupDataFragment; @@ -203,14 +162,14 @@ export const GroupSubGroupsPanel: React.FC = return ( <> {modal} - } + view={View.GroupSubGroups} /> ); diff --git a/ui/v2.5/src/components/Groups/GroupList.tsx b/ui/v2.5/src/components/Groups/GroupList.tsx index a08610569..6ce00831c 100644 --- a/ui/v2.5/src/components/Groups/GroupList.tsx +++ b/ui/v2.5/src/components/Groups/GroupList.tsx @@ -1,5 +1,5 @@ -import React, { PropsWithChildren, useState } from "react"; -import { useIntl } from "react-intl"; +import React, { useCallback, useEffect } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; import cloneDeep from "lodash-es/cloneDeep"; import Mousetrap from "mousetrap"; import { useHistory } from "react-router-dom"; @@ -11,208 +11,321 @@ import { useFindGroups, useGroupsDestroy, } from "src/core/StashService"; -import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList"; +import { useFilteredItemList } from "../List/ItemList"; import { ExportDialog } from "../Shared/ExportDialog"; import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog"; import { GroupCardGrid } from "./GroupCardGrid"; import { EditGroupsDialog } from "./EditGroupsDialog"; import { View } from "../List/views"; import { - IFilteredListToolbar, + FilteredListToolbar, IItemListOperation, } from "../List/FilteredListToolbar"; -import { PatchComponent } from "src/patch"; +import { PatchComponent, PatchContainerComponent } from "src/patch"; +import useFocus from "src/utils/focus"; +import { + Sidebar, + SidebarPane, + SidebarPaneContent, + SidebarStateContext, + useSidebarState, +} from "../Shared/Sidebar"; +import { useCloseEditDelete, useFilterOperations } from "../List/util"; +import { + FilteredSidebarHeader, + useFilteredSidebarKeybinds, +} from "../List/Filters/FilterSidebar"; +import { + IListFilterOperation, + ListOperations, +} from "../List/ListOperationButtons"; +import cx from "classnames"; +import { FilterTags } from "../List/FilterTags"; +import { Pagination, PaginationIndex } from "../List/Pagination"; +import { LoadedContent } from "../List/PagedList"; +import { SidebarStudiosFilter } from "../List/Filters/StudiosFilter"; +import { SidebarTagsFilter } from "../List/Filters/TagsFilter"; +import { SidebarRatingFilter } from "../List/Filters/RatingFilter"; +import { Button } from "react-bootstrap"; -const GroupExportDialog: React.FC<{ - open?: boolean; +const GroupList: React.FC<{ + groups: GQL.ListGroupDataFragment[]; + filter: ListFilterModel; selectedIds: Set; - isExportAll?: boolean; - onClose: () => void; -}> = ({ open = false, selectedIds, isExportAll = false, onClose }) => { - if (!open) { + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; + fromGroupId?: string; + onMove?: (srcIds: string[], targetId: string, after: boolean) => void; +}> = PatchComponent( + "GroupList", + ({ groups, filter, selectedIds, onSelectChange, fromGroupId, onMove }) => { + if (groups.length === 0) { + return null; + } + + if (filter.displayMode === DisplayMode.Grid) { + return ( + + ); + } + return null; } +); + +const GroupFilterSidebarSections = PatchContainerComponent( + "FilteredGroupList.SidebarSections" +); + +const SidebarContent: React.FC<{ + filter: ListFilterModel; + setFilter: (filter: ListFilterModel) => void; + filterHook?: (filter: ListFilterModel) => ListFilterModel; + view?: View; + sidebarOpen: boolean; + onClose?: () => void; + showEditFilter: (editingCriterion?: string) => void; + count?: number; + focus?: ReturnType; +}> = ({ + filter, + setFilter, + filterHook, + view, + showEditFilter, + sidebarOpen, + onClose, + count, + focus, +}) => { + const showResultsId = + count !== undefined ? "actions.show_count_results" : "actions.show_results"; + + const hideStudios = view === View.StudioScenes; return ( - + <> + + + + {!hideStudios && ( + + )} + + + + +
+ +
+ ); }; -const filterMode = GQL.FilterMode.Groups; - -function getItems(result: GQL.FindGroupsQueryResult) { - return result?.data?.findGroups?.groups ?? []; -} - -function getCount(result: GQL.FindGroupsQueryResult) { - return result?.data?.findGroups?.count ?? 0; -} - interface IGroupListContext { filterHook?: (filter: ListFilterModel) => ListFilterModel; defaultFilter?: ListFilterModel; view?: View; alterQuery?: boolean; - selectable?: boolean; } -export const GroupListContext: React.FC< - PropsWithChildren -> = ({ alterQuery, filterHook, defaultFilter, view, selectable, children }) => { - return ( - - {children} - - ); -}; - interface IGroupList extends IGroupListContext { fromGroupId?: string; onMove?: (srcIds: string[], targetId: string, after: boolean) => void; - renderToolbar?: (props: IFilteredListToolbar) => React.ReactNode; otherOperations?: IItemListOperation[]; } -export const GroupList: React.FC = PatchComponent( - "GroupList", - ({ - filterHook, - alterQuery, - defaultFilter, - view, - fromGroupId, - onMove, - selectable, - renderToolbar, - otherOperations: providedOperations = [], - }) => { +function useViewRandom(filter: ListFilterModel, count: number) { + const history = useHistory(); + + const viewRandom = useCallback(async () => { + // query for a random scene + if (count === 0) { + return; + } + + const index = Math.floor(Math.random() * count); + const filterCopy = cloneDeep(filter); + filterCopy.itemsPerPage = 1; + filterCopy.currentPage = index + 1; + const singleResult = await queryFindGroups(filterCopy); + if (singleResult.data.findGroups.groups.length === 1) { + const { id } = singleResult.data.findGroups.groups[0]; + // navigate to the image player page + history.push(`/groups/${id}`); + } + }, [history, filter, count]); + + return viewRandom; +} + +function useAddKeybinds(filter: ListFilterModel, count: number) { + const viewRandom = useViewRandom(filter, count); + + useEffect(() => { + Mousetrap.bind("p r", () => { + viewRandom(); + }); + + return () => { + Mousetrap.unbind("p r"); + }; + }, [viewRandom]); +} + +export const FilteredGroupList = PatchComponent( + "FilteredGroupList", + (props: IGroupList) => { const intl = useIntl(); - const history = useHistory(); - const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); - const [isExportAll, setIsExportAll] = useState(false); - const otherOperations = [ - { - text: intl.formatMessage({ id: "actions.view_random" }), - onClick: viewRandom, - }, - { - text: intl.formatMessage({ id: "actions.export" }), - onClick: onExport, - isDisplayed: showWhenSelected, - }, - { - text: intl.formatMessage({ id: "actions.export_all" }), - onClick: onExportAll, - }, - ...providedOperations, - ]; + const searchFocus = useFocus(); - function addKeybinds( - result: GQL.FindGroupsQueryResult, - filter: ListFilterModel - ) { - Mousetrap.bind("p r", () => { - viewRandom(result, filter); + const { + filterHook, + view, + alterQuery, + onMove, + fromGroupId, + otherOperations: providedOperations = [], + defaultFilter, + } = props; + + const withSidebar = view !== View.GroupSubGroups; + const filterable = view !== View.GroupSubGroups; + const sortable = view !== View.GroupSubGroups; + + // States + const { + showSidebar, + setShowSidebar, + sectionOpen, + setSectionOpen, + loading: sidebarStateLoading, + } = useSidebarState(view); + + const { filterState, queryResult, modalState, listSelect, showEditFilter } = + useFilteredItemList({ + filterStateProps: { + filterMode: GQL.FilterMode.Groups, + defaultFilter, + view, + useURL: alterQuery, + }, + queryResultProps: { + useResult: useFindGroups, + getCount: (r) => r.data?.findGroups.count ?? 0, + getItems: (r) => r.data?.findGroups.groups ?? [], + filterHook, + }, + }); + + const { filter, setFilter } = filterState; + + const { effectiveFilter, result, cachedResult, items, totalCount } = + queryResult; + + const { + selectedIds, + selectedItems, + onSelectChange, + onSelectAll, + onSelectNone, + onInvertSelection, + hasSelection, + } = listSelect; + + const { modal, showModal, closeModal } = modalState; + + // Utility hooks + const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({ + filter, + setFilter, + }); + + useAddKeybinds(filter, totalCount); + useFilteredSidebarKeybinds({ + showSidebar, + setShowSidebar, + }); + + useEffect(() => { + Mousetrap.bind("e", () => { + if (hasSelection) { + onEdit?.(); + } + }); + + Mousetrap.bind("d d", () => { + if (hasSelection) { + onDelete?.(); + } }); return () => { - Mousetrap.unbind("p r"); + Mousetrap.unbind("e"); + Mousetrap.unbind("d d"); }; - } + }); - async function viewRandom( - result: GQL.FindGroupsQueryResult, - filter: ListFilterModel - ) { - // query for a random image - if (result.data?.findGroups) { - const { count } = result.data.findGroups; + const onCloseEditDelete = useCloseEditDelete({ + closeModal, + onSelectNone, + result, + }); - const index = Math.floor(Math.random() * count); - const filterCopy = cloneDeep(filter); - filterCopy.itemsPerPage = 1; - filterCopy.currentPage = index + 1; - const singleResult = await queryFindGroups(filterCopy); - if (singleResult.data.findGroups.groups.length === 1) { - const { id } = singleResult.data.findGroups.groups[0]; - // navigate to the group page - history.push(`/groups/${id}`); - } - } - } + const viewRandom = useViewRandom(filter, totalCount); - async function onExport() { - setIsExportAll(false); - setIsExportDialogOpen(true); - } - - async function onExportAll() { - setIsExportAll(true); - setIsExportDialogOpen(true); - } - - function renderContent( - result: GQL.FindGroupsQueryResult, - filter: ListFilterModel, - selectedIds: Set, - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void - ) { - return ( - <> - setIsExportDialogOpen(false)} - /> - {filter.displayMode === DisplayMode.Grid && ( - - )} - + function onExport(all: boolean) { + showModal( + closeModal()} + /> ); } - function renderEditDialog( - selectedGroups: GQL.ListGroupDataFragment[], - onClose: (applied: boolean) => void - ) { - return ; + function onEdit() { + showModal( + + ); } - function renderDeleteDialog( - selectedGroups: GQL.SlimGroupDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ( + function onDelete() { + showModal( = PatchComponent( ); } - return ( - - ({ + ...o, + isDisplayed: o.isDisplayed + ? () => o.isDisplayed!(result, filter, selectedIds) + : undefined, + onClick: () => { + o.onClick(result, filter, selectedIds); + }, + })); + + const otherOperations = [ + ...convertedExtraOperations, + { + text: intl.formatMessage({ id: "actions.select_all" }), + onClick: () => onSelectAll(), + isDisplayed: () => totalCount > 0, + }, + { + text: intl.formatMessage({ id: "actions.select_none" }), + onClick: () => onSelectNone(), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.invert_selection" }), + onClick: () => onInvertSelection(), + isDisplayed: () => totalCount > 0, + }, + { + text: intl.formatMessage({ id: "actions.view_random" }), + onClick: viewRandom, + }, + { + text: intl.formatMessage({ id: "actions.export" }), + onClick: () => onExport(false), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.export_all" }), + onClick: () => onExport(true), + }, + ]; + + // render + if (sidebarStateLoading) return null; + + const operations = ( + + ); + + const content = ( + <> + - + + showEditFilter(c.criterionOption.type)} + onRemoveCriterion={removeCriterion} + onRemoveAll={clearAllCriteria} + /> + +
+ setFilter(filter.changePage(page))} + /> + +
+ + + + + + {totalCount > filter.itemsPerPage && ( +
+
+ +
+
+ )} + + ); + + if (!withSidebar) { + return content; + } + + return ( +
+ {modal} + + + + setShowSidebar(false)}> + setShowSidebar(false)} + count={cachedResult.loading ? undefined : totalCount} + focus={searchFocus} + /> + + setShowSidebar(!showSidebar)} + > + {content} + + + +
); } ); diff --git a/ui/v2.5/src/components/Groups/Groups.tsx b/ui/v2.5/src/components/Groups/Groups.tsx index 5ec7b4eaf..1a89444b0 100644 --- a/ui/v2.5/src/components/Groups/Groups.tsx +++ b/ui/v2.5/src/components/Groups/Groups.tsx @@ -4,11 +4,11 @@ import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; import Group from "./GroupDetails/Group"; import GroupCreate from "./GroupDetails/GroupCreate"; -import { GroupList } from "./GroupList"; +import { FilteredGroupList } from "./GroupList"; import { View } from "../List/views"; const Groups: React.FC = () => { - return ; + return ; }; const GroupRoutes: React.FC = () => { diff --git a/ui/v2.5/src/components/List/FilteredListToolbar.tsx b/ui/v2.5/src/components/List/FilteredListToolbar.tsx index 162b30ff3..a6a983dc4 100644 --- a/ui/v2.5/src/components/List/FilteredListToolbar.tsx +++ b/ui/v2.5/src/components/List/FilteredListToolbar.tsx @@ -80,6 +80,8 @@ export interface IFilteredListToolbar { operations?: IListFilterOperation[]; operationComponent?: React.ReactNode; zoomable?: boolean; + filterable?: boolean; + sortable?: boolean; } export const FilteredListToolbar: React.FC = ({ @@ -93,6 +95,8 @@ export const FilteredListToolbar: React.FC = ({ operations, operationComponent, zoomable = false, + filterable = true, + sortable = true, }) => { const filterOptions = filter.options; const { setDisplayMode, setZoom } = useFilterOperations({ @@ -128,32 +132,40 @@ export const FilteredListToolbar: React.FC = ({ /> ) : ( <> - + {filterable && ( + + )} - - - showEditFilter()} - count={filter.count()} - /> - + {filterable && ( + + + showEditFilter()} + count={filter.count()} + /> + + )} - setFilter(filter.setSortBy(e ?? undefined))} - onChangeSortDirection={() => - setFilter(filter.toggleSortDirection()) - } - onReshuffleRandomSort={() => - setFilter(filter.reshuffleRandomSort()) - } - /> + {sortable && ( + + setFilter(filter.setSortBy(e ?? undefined)) + } + onChangeSortDirection={() => + setFilter(filter.toggleSortDirection()) + } + onReshuffleRandomSort={() => + setFilter(filter.reshuffleRandomSort()) + } + /> + )} ; galleries_filter?: InputMaybe; gallery_count?: InputMaybe; + groups_filter?: InputMaybe; + group_count?: InputMaybe; studios_filter?: InputMaybe; studio_count?: InputMaybe; } @@ -533,6 +536,7 @@ export function setObjectFilter( | SceneFilterType | PerformerFilterType | GalleryFilterType + | GroupFilterType | StudioFilterType ) { const empty = Object.keys(relatedFilterOutput).length === 0; @@ -568,6 +572,16 @@ export function setObjectFilter( } out.galleries_filter = relatedFilterOutput as GalleryFilterType; break; + case FilterMode.Groups: + // if empty, only get objects with groups + if (empty) { + out.group_count = { + modifier: CriterionModifier.GreaterThan, + value: 0, + }; + } + out.groups_filter = relatedFilterOutput as GroupFilterType; + break; case FilterMode.Studios: // if empty, only get objects with studios if (empty) { diff --git a/ui/v2.5/src/components/List/ListOperationButtons.tsx b/ui/v2.5/src/components/List/ListOperationButtons.tsx index c214a947a..2a4232fb3 100644 --- a/ui/v2.5/src/components/List/ListOperationButtons.tsx +++ b/ui/v2.5/src/components/List/ListOperationButtons.tsx @@ -9,7 +9,6 @@ import { faPencil, faPencilAlt, faPlay, - faPlus, faTrash, } from "@fortawesome/free-solid-svg-icons"; import cx from "classnames"; @@ -61,6 +60,7 @@ export interface IListFilterOperation { isDisplayed?: () => boolean; icon?: IconDefinition; buttonVariant?: string; + className?: string; } interface IListOperationButtonsProps { @@ -268,22 +268,13 @@ export const ListOperationButtons: React.FC = ({ ); }; -export interface IListOperations { - text: string; - onClick: () => void; - isDisplayed?: () => boolean; - className?: string; -} - export const ListOperations: React.FC<{ items: number; hasSelection?: boolean; - operations?: IListOperations[]; + operations?: IListFilterOperation[]; onEdit?: () => void; onDelete?: () => void; onPlay?: () => void; - onCreateNew?: () => void; - entityType?: string; operationsClassName?: string; operationsMenuClassName?: string; }> = ({ @@ -293,79 +284,128 @@ export const ListOperations: React.FC<{ onEdit, onDelete, onPlay, - onCreateNew, - entityType, operationsClassName = "list-operations", operationsMenuClassName, }) => { const intl = useIntl(); + const dropdownOperations = useMemo(() => { + return operations.filter((o) => { + if (o.icon) { + return false; + } + + if (!o.isDisplayed) { + return true; + } + + return o.isDisplayed(); + }); + }, [operations]); + + const buttons = useMemo(() => { + const otherButtons = (operations ?? []).filter((o) => { + if (!o.icon) { + return false; + } + + if (!o.isDisplayed) { + return true; + } + + return o.isDisplayed(); + }); + + const ret: React.ReactNode[] = []; + + function addButton(b: React.ReactNode | null) { + if (b) { + ret.push(b); + } + } + + const playButton = + !!items && onPlay ? ( + + ) : null; + + const editButton = + hasSelection && onEdit ? ( + + ) : null; + + const deleteButton = + hasSelection && onDelete ? ( + + ) : null; + + addButton(playButton); + addButton(editButton); + addButton(deleteButton); + + otherButtons.forEach((button) => { + addButton( + + ); + }); + + if (ret.length === 0) { + return null; + } + + return ret; + }, [operations, hasSelection, onDelete, onEdit, onPlay, items, intl]); + + if (dropdownOperations.length === 0 && !buttons) { + return null; + } + return (
- {!!items && onPlay && ( - - )} - {!hasSelection && onCreateNew && ( - - )} + {buttons} - {hasSelection && (onEdit || onDelete) && ( - <> - {onEdit && ( - - )} - {onDelete && ( - - )} - - )} - - {operations.length > 0 && ( + {dropdownOperations.length > 0 && ( - {operations.map((o) => { - if (o.isDisplayed && !o.isDisplayed()) { - return null; - } - - return ( - - ); - })} + {dropdownOperations.map((o) => ( + + ))} )} diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index 707346848..d870c631f 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -139,6 +139,7 @@ function useEmptyFilter(props: { export interface IFilterStateHook { filterMode: GQL.FilterMode; + defaultFilter?: ListFilterModel; defaultSort?: string; view?: View; useURL?: boolean; @@ -149,7 +150,14 @@ export function useFilterState( config?: GQL.ConfigDataFragment; } ) { - const { filterMode, defaultSort, config, view, useURL } = props; + const { + filterMode, + defaultSort, + config, + view, + useURL, + defaultFilter: propDefaultFilter, + } = props; const [filter, setFilterState] = useState( () => @@ -158,10 +166,13 @@ export function useFilterState( const emptyFilter = useEmptyFilter({ filterMode, defaultSort, config }); - const { defaultFilter } = useDefaultFilter(emptyFilter, view); + const { defaultFilter: defaultFilterFromConfig } = useDefaultFilter( + emptyFilter, + view + ); const { setFilter } = useFilterURL(filter, setFilterState, { - defaultFilter, + defaultFilter: propDefaultFilter ?? defaultFilterFromConfig, active: useURL, }); diff --git a/ui/v2.5/src/components/List/views.ts b/ui/v2.5/src/components/List/views.ts index 5b9f9798f..4ea4e46d8 100644 --- a/ui/v2.5/src/components/List/views.ts +++ b/ui/v2.5/src/components/List/views.ts @@ -13,6 +13,7 @@ export enum View { TagScenes = "tag_scenes", TagImages = "tag_images", TagPerformers = "tag_performers", + TagGroups = "tag_groups", PerformerScenes = "performer_scenes", PerformerGalleries = "performer_galleries", diff --git a/ui/v2.5/src/components/MainNavbar.tsx b/ui/v2.5/src/components/MainNavbar.tsx index a73a3078b..c70994476 100644 --- a/ui/v2.5/src/components/MainNavbar.tsx +++ b/ui/v2.5/src/components/MainNavbar.tsx @@ -103,6 +103,7 @@ const allMenuItems: IMenuItem[] = [ href: "/scenes", icon: faPlayCircle, hotkey: "g s", + userCreatable: true, }, { name: "images", @@ -132,6 +133,7 @@ const allMenuItems: IMenuItem[] = [ href: "/galleries", icon: faImages, hotkey: "g l", + userCreatable: true, }, { name: "performers", @@ -139,6 +141,7 @@ const allMenuItems: IMenuItem[] = [ href: "/performers", icon: faUser, hotkey: "g p", + userCreatable: true, }, { name: "studios", diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGroupsPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGroupsPanel.tsx index 5475c1484..ce61edc42 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGroupsPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGroupsPanel.tsx @@ -1,6 +1,6 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; -import { GroupList } from "src/components/Groups/GroupList"; +import { FilteredGroupList } from "src/components/Groups/GroupList"; import { usePerformerFilterHook } from "src/core/performers"; import { View } from "src/components/List/views"; import { PatchComponent } from "src/patch"; @@ -14,7 +14,7 @@ export const PerformerGroupsPanel: React.FC = PatchComponent("PerformerGroupsPanel", ({ active, performer }) => { const filterHook = usePerformerFilterHook(performer); return ( - { const intl = useIntl(); const history = useHistory(); - const location = useLocation(); const searchFocus = useFocus(); @@ -444,15 +446,6 @@ export const FilteredPerformerList = PatchComponent( result, }); - function onCreateNew() { - let queryParam = new URLSearchParams(location.search).get("q"); - let newPath = "/performers/new"; - if (queryParam) { - newPath += "?q=" + encodeURIComponent(queryParam); - } - history.push(newPath); - } - const viewRandom = useViewRandom(filter, totalCount); function onExport(all: boolean) { @@ -505,8 +498,8 @@ export const FilteredPerformerList = PatchComponent( ); } - const convertedExtraOperations: IListOperations[] = extraOperations.map( - (o) => ({ + const convertedExtraOperations: IListFilterOperation[] = + extraOperations.map((o) => ({ ...o, isDisplayed: o.isDisplayed ? () => o.isDisplayed!(result, filter, selectedIds) @@ -514,10 +507,9 @@ export const FilteredPerformerList = PatchComponent( onClick: () => { o.onClick(result, filter, selectedIds); }, - }) - ); + })); - const otherOperations: IListOperations[] = [ + const otherOperations: IListFilterOperation[] = [ ...convertedExtraOperations, { text: intl.formatMessage({ id: "actions.select_all" }), @@ -564,8 +556,6 @@ export const FilteredPerformerList = PatchComponent( operations={otherOperations} onEdit={onEdit} onDelete={onDelete} - onCreateNew={onCreateNew} - entityType={intl.formatMessage({ id: "gallery" })} operationsMenuClassName="gallery-list-operations-dropdown" /> ); diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 79f470de8..a0458c5ac 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -627,8 +627,6 @@ export const FilteredSceneList = PatchComponent( onEdit={onEdit} onDelete={onDelete} onPlay={onPlay} - onCreateNew={onCreateNew} - entityType={intl.formatMessage({ id: "scene" })} operationsMenuClassName="scene-list-operations-dropdown" /> ); diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioGroupsPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioGroupsPanel.tsx index ba3a7cc02..75d001c21 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioGroupsPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioGroupsPanel.tsx @@ -1,6 +1,6 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; -import { GroupList } from "src/components/Groups/GroupList"; +import { FilteredGroupList } from "src/components/Groups/GroupList"; import { useStudioFilterHook } from "src/core/studios"; import { View } from "src/components/List/views"; @@ -17,7 +17,7 @@ export const StudioGroupsPanel: React.FC = ({ }) => { const filterHook = useStudioFilterHook(studio, showChildStudioContent); return ( - { const intl = useIntl(); - const history = useHistory(); - const location = useLocation(); const searchFocus = useFocus(); @@ -284,15 +282,6 @@ export const FilteredStudioList = PatchComponent( result, }); - function onCreateNew() { - let queryParam = new URLSearchParams(location.search).get("q"); - let newPath = "/studios/new"; - if (queryParam) { - newPath += "?q=" + encodeURIComponent(queryParam); - } - history.push(newPath); - } - const viewRandom = useViewRandom(filter, totalCount); function onExport(all: boolean) { @@ -378,8 +367,6 @@ export const FilteredStudioList = PatchComponent( operations={otherOperations} onEdit={onEdit} onDelete={onDelete} - onCreateNew={onCreateNew} - entityType={intl.formatMessage({ id: "studio" })} operationsMenuClassName="studio-list-operations-dropdown" /> ); diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagGroupsPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagGroupsPanel.tsx index 363efadde..89636f5d3 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagGroupsPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagGroupsPanel.tsx @@ -1,7 +1,8 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useTagFilterHook } from "src/core/tags"; -import { GroupList } from "src/components/Groups/GroupList"; +import { FilteredGroupList } from "src/components/Groups/GroupList"; +import { View } from "src/components/List/views"; export const TagGroupsPanel: React.FC<{ active: boolean; @@ -9,5 +10,11 @@ export const TagGroupsPanel: React.FC<{ showSubTagContent?: boolean; }> = ({ active, tag, showSubTagContent }) => { const filterHook = useTagFilterHook(tag, showSubTagContent); - return ; + return ( + + ); }; From adaadee368320897656cb1e2b2df01a0cb53c549 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:44:03 -0600 Subject: [PATCH 068/177] FR: Change Career Length to Career Start and Career End (#6449) Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- gqlgen.yml | 4 + graphql/schema/types/filters.graphql | 7 +- graphql/schema/types/performer.graphql | 16 +- .../schema/types/scraped-performer.graphql | 8 +- internal/api/resolver_model_performer.go | 10 + internal/api/resolver_mutation_performer.go | 46 ++++- pkg/models/jsonschema/performer.go | 4 +- pkg/models/model_performer.go | 6 +- pkg/models/model_scraped_item.go | 27 ++- pkg/models/model_scraped_item_test.go | 17 +- pkg/models/performer.go | 10 +- pkg/performer/export.go | 8 +- pkg/performer/export_test.go | 9 +- pkg/performer/import.go | 25 ++- pkg/performer/import_test.go | 83 +++++++++ pkg/scraper/mapped_result.go | 2 + pkg/scraper/performer.go | 2 + pkg/scraper/postprocessing.go | 14 ++ pkg/sqlite/database.go | 2 +- .../78_performer_career_dates.up.sql | 2 + pkg/sqlite/migrations/78_postmigrate.go | 143 ++++++++++++++ pkg/sqlite/performer.go | 15 +- pkg/sqlite/performer_filter.go | 69 ++++++- pkg/sqlite/performer_test.go | 176 +++++++++++++++--- pkg/sqlite/setup_test.go | 23 ++- pkg/stashbox/performer.go | 25 ++- pkg/utils/date.go | 79 ++++++++ pkg/utils/date_test.go | 65 +++++++ ui/v2.5/graphql/data/performer-slim.graphql | 3 +- ui/v2.5/graphql/data/performer.graphql | 3 +- ui/v2.5/graphql/data/scrapers.graphql | 6 +- .../Performers/EditPerformersDialog.tsx | 14 +- .../PerformerDetailsPanel.tsx | 6 +- .../PerformerDetails/PerformerEditPanel.tsx | 16 +- .../PerformerScrapeDialog.tsx | 37 ++-- .../components/Performers/PerformerList.tsx | 8 + .../Performers/PerformerListTable.tsx | 3 +- .../Performers/PerformerMergeDialog.tsx | 49 +++-- ui/v2.5/src/components/Performers/styles.scss | 3 +- .../Shared/ScrapeDialog/ScrapeDialogRow.tsx | 64 +++++++ .../src/components/Tagger/PerformerModal.tsx | 6 +- ui/v2.5/src/components/Tagger/constants.ts | 3 +- ui/v2.5/src/core/performers.ts | 5 +- ui/v2.5/src/locales/en-GB.json | 2 + .../models/list-filter/criteria/is-missing.ts | 3 +- ui/v2.5/src/models/list-filter/performers.ts | 6 +- ui/v2.5/src/models/list-filter/types.ts | 2 + 47 files changed, 1004 insertions(+), 132 deletions(-) create mode 100644 pkg/sqlite/migrations/78_performer_career_dates.up.sql create mode 100644 pkg/sqlite/migrations/78_postmigrate.go diff --git a/gqlgen.yml b/gqlgen.yml index b949d44dc..4a3d73d51 100644 --- a/gqlgen.yml +++ b/gqlgen.yml @@ -140,4 +140,8 @@ models: fields: plugins: resolver: true + Performer: + fields: + career_length: + resolver: true diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index d1fd77006..81f91f22a 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -154,8 +154,13 @@ input PerformerFilterType { penis_length: FloatCriterionInput "Filter by ciricumcision" circumcised: CircumcisionCriterionInput - "Filter by career length" + "Deprecated: use career_start and career_end. This filter is non-functional." career_length: StringCriterionInput + @deprecated(reason: "Use career_start and career_end") + "Filter by career start year" + career_start: IntCriterionInput + "Filter by career end year" + career_end: IntCriterionInput "Filter by tattoos" tattoos: StringCriterionInput "Filter by piercings" diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index 7275d4495..97a80b94f 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -30,7 +30,9 @@ type Performer { fake_tits: String penis_length: Float circumcised: CircumisedEnum - career_length: String + career_length: String @deprecated(reason: "Use career_start and career_end") + career_start: Int + career_end: Int tattoos: String piercings: String alias_list: [String!]! @@ -77,7 +79,9 @@ input PerformerCreateInput { fake_tits: String penis_length: Float circumcised: CircumisedEnum - career_length: String + career_length: String @deprecated(reason: "Use career_start and career_end") + career_start: Int + career_end: Int tattoos: String piercings: String "Duplicate aliases and those equal to name will be ignored (case-insensitive)" @@ -116,7 +120,9 @@ input PerformerUpdateInput { fake_tits: String penis_length: Float circumcised: CircumisedEnum - career_length: String + career_length: String @deprecated(reason: "Use career_start and career_end") + career_start: Int + career_end: Int tattoos: String piercings: String "Duplicate aliases and those equal to name will be ignored (case-insensitive)" @@ -160,7 +166,9 @@ input BulkPerformerUpdateInput { fake_tits: String penis_length: Float circumcised: CircumisedEnum - career_length: String + career_length: String @deprecated(reason: "Use career_start and career_end") + career_start: Int + career_end: Int tattoos: String piercings: String "Duplicate aliases and those equal to name will result in an error (case-insensitive)" diff --git a/graphql/schema/types/scraped-performer.graphql b/graphql/schema/types/scraped-performer.graphql index 487c89516..0818e61c2 100644 --- a/graphql/schema/types/scraped-performer.graphql +++ b/graphql/schema/types/scraped-performer.graphql @@ -18,7 +18,9 @@ type ScrapedPerformer { fake_tits: String penis_length: String circumcised: String - career_length: String + career_length: String @deprecated(reason: "Use career_start and career_end") + career_start: Int + career_end: Int tattoos: String piercings: String # aliases must be comma-delimited to be parsed correctly @@ -54,7 +56,9 @@ input ScrapedPerformerInput { fake_tits: String penis_length: String circumcised: String - career_length: String + career_length: String @deprecated(reason: "Use career_start and career_end") + career_start: Int + career_end: Int tattoos: String piercings: String aliases: String diff --git a/internal/api/resolver_model_performer.go b/internal/api/resolver_model_performer.go index 94da62932..b770f5801 100644 --- a/internal/api/resolver_model_performer.go +++ b/internal/api/resolver_model_performer.go @@ -10,6 +10,7 @@ import ( "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/performer" + "github.com/stashapp/stash/pkg/utils" ) func (r *performerResolver) AliasList(ctx context.Context, obj *models.Performer) ([]string, error) { @@ -109,6 +110,15 @@ func (r *performerResolver) HeightCm(ctx context.Context, obj *models.Performer) return obj.Height, nil } +func (r *performerResolver) CareerLength(ctx context.Context, obj *models.Performer) (*string, error) { + if obj.CareerStart == nil && obj.CareerEnd == nil { + return nil, nil + } + + ret := utils.FormatYearRange(obj.CareerStart, obj.CareerEnd) + return &ret, nil +} + func (r *performerResolver) Birthdate(ctx context.Context, obj *models.Performer) (*string, error) { if obj.Birthdate != nil { ret := obj.Birthdate.String() diff --git a/internal/api/resolver_mutation_performer.go b/internal/api/resolver_mutation_performer.go index fd18ecb95..653348304 100644 --- a/internal/api/resolver_mutation_performer.go +++ b/internal/api/resolver_mutation_performer.go @@ -52,7 +52,17 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per newPerformer.FakeTits = translator.string(input.FakeTits) newPerformer.PenisLength = input.PenisLength newPerformer.Circumcised = input.Circumcised - newPerformer.CareerLength = translator.string(input.CareerLength) + newPerformer.CareerStart = input.CareerStart + newPerformer.CareerEnd = input.CareerEnd + // if career_start/career_end not provided, parse deprecated career_length + if newPerformer.CareerStart == nil && newPerformer.CareerEnd == nil && input.CareerLength != nil { + start, end, err := utils.ParseYearRangeString(*input.CareerLength) + if err != nil { + return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err) + } + newPerformer.CareerStart = start + newPerformer.CareerEnd = end + } newPerformer.Tattoos = translator.string(input.Tattoos) newPerformer.Piercings = translator.string(input.Piercings) newPerformer.Favorite = translator.bool(input.Favorite) @@ -261,7 +271,22 @@ func performerPartialFromInput(input models.PerformerUpdateInput, translator cha updatedPerformer.FakeTits = translator.optionalString(input.FakeTits, "fake_tits") updatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, "penis_length") updatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), "circumcised") - updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length") + // prefer career_start/career_end over deprecated career_length + if translator.hasField("career_start") || translator.hasField("career_end") { + updatedPerformer.CareerStart = translator.optionalInt(input.CareerStart, "career_start") + updatedPerformer.CareerEnd = translator.optionalInt(input.CareerEnd, "career_end") + } else if translator.hasField("career_length") && input.CareerLength != nil { + start, end, err := utils.ParseYearRangeString(*input.CareerLength) + if err != nil { + return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err) + } + if start != nil { + updatedPerformer.CareerStart = models.NewOptionalInt(*start) + } + if end != nil { + updatedPerformer.CareerEnd = models.NewOptionalInt(*end) + } + } updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings") updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite") @@ -417,7 +442,22 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe updatedPerformer.FakeTits = translator.optionalString(input.FakeTits, "fake_tits") updatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, "penis_length") updatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), "circumcised") - updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length") + // prefer career_start/career_end over deprecated career_length + if translator.hasField("career_start") || translator.hasField("career_end") { + updatedPerformer.CareerStart = translator.optionalInt(input.CareerStart, "career_start") + updatedPerformer.CareerEnd = translator.optionalInt(input.CareerEnd, "career_end") + } else if translator.hasField("career_length") && input.CareerLength != nil { + start, end, err := utils.ParseYearRangeString(*input.CareerLength) + if err != nil { + return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err) + } + if start != nil { + updatedPerformer.CareerStart = models.NewOptionalInt(*start) + } + if end != nil { + updatedPerformer.CareerEnd = models.NewOptionalInt(*end) + } + } updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings") diff --git a/pkg/models/jsonschema/performer.go b/pkg/models/jsonschema/performer.go index 5edd5724c..b738fbfac 100644 --- a/pkg/models/jsonschema/performer.go +++ b/pkg/models/jsonschema/performer.go @@ -48,7 +48,9 @@ type Performer struct { FakeTits string `json:"fake_tits,omitempty"` PenisLength float64 `json:"penis_length,omitempty"` Circumcised string `json:"circumcised,omitempty"` - CareerLength string `json:"career_length,omitempty"` + CareerLength string `json:"career_length,omitempty"` // deprecated - for import only + CareerStart *int `json:"career_start,omitempty"` + CareerEnd *int `json:"career_end,omitempty"` Tattoos string `json:"tattoos,omitempty"` Piercings string `json:"piercings,omitempty"` Aliases StringOrStringList `json:"aliases,omitempty"` diff --git a/pkg/models/model_performer.go b/pkg/models/model_performer.go index 566dcae1e..a30eafa0a 100644 --- a/pkg/models/model_performer.go +++ b/pkg/models/model_performer.go @@ -19,7 +19,8 @@ type Performer struct { FakeTits string `json:"fake_tits"` PenisLength *float64 `json:"penis_length"` Circumcised *CircumisedEnum `json:"circumcised"` - CareerLength string `json:"career_length"` + CareerStart *int `json:"career_start"` + CareerEnd *int `json:"career_end"` Tattoos string `json:"tattoos"` Piercings string `json:"piercings"` Favorite bool `json:"favorite"` @@ -75,7 +76,8 @@ type PerformerPartial struct { FakeTits OptionalString PenisLength OptionalFloat64 Circumcised OptionalString - CareerLength OptionalString + CareerStart OptionalInt + CareerEnd OptionalInt Tattoos OptionalString Piercings OptionalString Favorite OptionalBool diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index bd6db10c8..3c0e083c1 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -176,7 +176,9 @@ type ScrapedPerformer struct { FakeTits *string `json:"fake_tits"` PenisLength *string `json:"penis_length"` Circumcised *string `json:"circumcised"` - CareerLength *string `json:"career_length"` + CareerLength *string `json:"career_length"` // deprecated: use CareerStart/CareerEnd + CareerStart *int `json:"career_start"` + CareerEnd *int `json:"career_end"` Tattoos *string `json:"tattoos"` Piercings *string `json:"piercings"` Aliases *string `json:"aliases"` @@ -219,8 +221,16 @@ func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool ret.DeathDate = &date } } - if p.CareerLength != nil && !excluded["career_length"] { - ret.CareerLength = *p.CareerLength + + // assume that career length is _not_ populated in favour of start/end + + if p.CareerStart != nil && !excluded["career_start"] { + cs := *p.CareerStart + ret.CareerStart = &cs + } + if p.CareerEnd != nil && !excluded["career_end"] { + ce := *p.CareerEnd + ret.CareerEnd = &ce } if p.Country != nil && !excluded["country"] { ret.Country = *p.Country @@ -356,7 +366,16 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, } } if p.CareerLength != nil && !excluded["career_length"] { - ret.CareerLength = NewOptionalString(*p.CareerLength) + // parse career_length into career_start/career_end + start, end, err := utils.ParseYearRangeString(*p.CareerLength) + if err == nil { + if start != nil { + ret.CareerStart = NewOptionalInt(*start) + } + if end != nil { + ret.CareerEnd = NewOptionalInt(*end) + } + } } if p.Country != nil && !excluded["country"] { ret.Country = NewOptionalString(*p.Country) diff --git a/pkg/models/model_scraped_item_test.go b/pkg/models/model_scraped_item_test.go index 545543652..09d8fbb32 100644 --- a/pkg/models/model_scraped_item_test.go +++ b/pkg/models/model_scraped_item_test.go @@ -8,6 +8,8 @@ import ( "github.com/stretchr/testify/assert" ) +func intPtr(i int) *int { return &i } + func Test_scrapedToStudioInput(t *testing.T) { const name = "name" url := "url" @@ -124,9 +126,10 @@ func Test_scrapedToPerformerInput(t *testing.T) { endpoint := "endpoint" remoteSiteID := "remoteSiteID" - var stringValues []string - for i := 0; i < 20; i++ { - stringValues = append(stringValues, strconv.Itoa(i)) + const nValues = 19 + stringValues := make([]string, nValues) + for i := 0; i < nValues; i++ { + stringValues[i] = strconv.Itoa(i) } upTo := 0 @@ -183,7 +186,8 @@ func Test_scrapedToPerformerInput(t *testing.T) { Weight: nextVal(), Measurements: nextVal(), FakeTits: nextVal(), - CareerLength: nextVal(), + CareerStart: intPtr(2005), + CareerEnd: intPtr(2015), Tattoos: nextVal(), Piercings: nextVal(), Aliases: nextVal(), @@ -208,8 +212,9 @@ func Test_scrapedToPerformerInput(t *testing.T) { Weight: nextIntVal(), Measurements: *nextVal(), FakeTits: *nextVal(), - CareerLength: *nextVal(), - Tattoos: *nextVal(), + CareerStart: intPtr(2005), + CareerEnd: intPtr(2015), + Tattoos: *nextVal(), // skip CareerLength counter slot Piercings: *nextVal(), Aliases: NewRelatedStrings([]string{*nextVal()}), URLs: NewRelatedStrings([]string{*nextVal(), *nextVal(), *nextVal()}), diff --git a/pkg/models/performer.go b/pkg/models/performer.go index 63a08b30c..e4fb8dd98 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -137,7 +137,11 @@ type PerformerFilterType struct { // Filter by circumcision Circumcised *CircumcisionCriterionInput `json:"circumcised"` // Filter by career length - CareerLength *StringCriterionInput `json:"career_length"` + CareerLength *StringCriterionInput `json:"career_length"` // deprecated + // Filter by career start year + CareerStart *IntCriterionInput `json:"career_start"` + // Filter by career end year + CareerEnd *IntCriterionInput `json:"career_end"` // Filter by tattoos Tattoos *StringCriterionInput `json:"tattoos"` // Filter by piercings @@ -224,6 +228,8 @@ type PerformerCreateInput struct { PenisLength *float64 `json:"penis_length"` Circumcised *CircumisedEnum `json:"circumcised"` CareerLength *string `json:"career_length"` + CareerStart *int `json:"career_start"` + CareerEnd *int `json:"career_end"` Tattoos *string `json:"tattoos"` Piercings *string `json:"piercings"` Aliases *string `json:"aliases"` @@ -263,6 +269,8 @@ type PerformerUpdateInput struct { PenisLength *float64 `json:"penis_length"` Circumcised *CircumisedEnum `json:"circumcised"` CareerLength *string `json:"career_length"` + CareerStart *int `json:"career_start"` + CareerEnd *int `json:"career_end"` Tattoos *string `json:"tattoos"` Piercings *string `json:"piercings"` Aliases *string `json:"aliases"` diff --git a/pkg/performer/export.go b/pkg/performer/export.go index 1455fb7bf..691175b1f 100644 --- a/pkg/performer/export.go +++ b/pkg/performer/export.go @@ -30,7 +30,6 @@ func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *mode EyeColor: performer.EyeColor, Measurements: performer.Measurements, FakeTits: performer.FakeTits, - CareerLength: performer.CareerLength, Tattoos: performer.Tattoos, Piercings: performer.Piercings, Favorite: performer.Favorite, @@ -71,6 +70,13 @@ func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *mode newPerformerJSON.PenisLength = *performer.PenisLength } + if performer.CareerStart != nil { + newPerformerJSON.CareerStart = performer.CareerStart + } + if performer.CareerEnd != nil { + newPerformerJSON.CareerEnd = performer.CareerEnd + } + if err := performer.LoadAliases(ctx, reader); err != nil { return nil, fmt.Errorf("loading performer aliases: %w", err) } diff --git a/pkg/performer/export_test.go b/pkg/performer/export_test.go index e51049e14..1a87bc2b1 100644 --- a/pkg/performer/export_test.go +++ b/pkg/performer/export_test.go @@ -26,7 +26,6 @@ const ( performerName = "testPerformer" disambiguation = "disambiguation" url = "url" - careerLength = "careerLength" country = "country" ethnicity = "ethnicity" eyeColor = "eyeColor" @@ -49,6 +48,8 @@ var ( rating = 5 height = 123 weight = 60 + careerStart = 2005 + careerEnd = 2015 penisLength = 1.23 circumcisedEnum = models.CircumisedEnumCut circumcised = circumcisedEnum.String() @@ -87,7 +88,8 @@ func createFullPerformer(id int, name string) *models.Performer { URLs: models.NewRelatedStrings([]string{url, twitter, instagram}), Aliases: models.NewRelatedStrings(aliases), Birthdate: &birthDate, - CareerLength: careerLength, + CareerStart: &careerStart, + CareerEnd: &careerEnd, Country: country, Ethnicity: ethnicity, EyeColor: eyeColor, @@ -132,7 +134,8 @@ func createFullJSONPerformer(name string, image string, withCustomFields bool) * URLs: []string{url, twitter, instagram}, Aliases: aliases, Birthdate: birthDate.String(), - CareerLength: careerLength, + CareerStart: &careerStart, + CareerEnd: &careerEnd, Country: country, Ethnicity: ethnicity, EyeColor: eyeColor, diff --git a/pkg/performer/import.go b/pkg/performer/import.go index a8e3f7a7a..1df69521a 100644 --- a/pkg/performer/import.go +++ b/pkg/performer/import.go @@ -32,14 +32,17 @@ type Importer struct { } func (i *Importer) PreImport(ctx context.Context) error { - i.performer = performerJSONToPerformer(i.Input) + var err error + i.performer, err = performerJSONToPerformer(i.Input) + if err != nil { + return err + } i.customFields = i.Input.CustomFields if err := i.populateTags(ctx); err != nil { return err } - var err error if len(i.Input.Image) > 0 { i.imageData, err = utils.ProcessBase64Image(i.Input.Image) if err != nil { @@ -196,7 +199,7 @@ func (i *Importer) Update(ctx context.Context, id int) error { return nil } -func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Performer { +func performerJSONToPerformer(performerJSON jsonschema.Performer) (models.Performer, error) { newPerformer := models.Performer{ Name: performerJSON.Name, Disambiguation: performerJSON.Disambiguation, @@ -205,7 +208,6 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform EyeColor: performerJSON.EyeColor, Measurements: performerJSON.Measurements, FakeTits: performerJSON.FakeTits, - CareerLength: performerJSON.CareerLength, Tattoos: performerJSON.Tattoos, Piercings: performerJSON.Piercings, Aliases: models.NewRelatedStrings(performerJSON.Aliases), @@ -282,5 +284,18 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform } } - return newPerformer + // prefer explicit career_start/career_end, fall back to parsing legacy career_length + if performerJSON.CareerStart != nil || performerJSON.CareerEnd != nil { + newPerformer.CareerStart = performerJSON.CareerStart + newPerformer.CareerEnd = performerJSON.CareerEnd + } else if performerJSON.CareerLength != "" { + start, end, err := utils.ParseYearRangeString(performerJSON.CareerLength) + if err != nil { + return models.Performer{}, fmt.Errorf("invalid career_length %q: %w", performerJSON.CareerLength, err) + } + newPerformer.CareerStart = start + newPerformer.CareerEnd = end + } + + return newPerformer, nil } diff --git a/pkg/performer/import_test.go b/pkg/performer/import_test.go index 455a6e7a3..ca28c1990 100644 --- a/pkg/performer/import_test.go +++ b/pkg/performer/import_test.go @@ -315,3 +315,86 @@ func TestUpdate(t *testing.T) { db.AssertExpectations(t) } + +func TestImportCareerFields(t *testing.T) { + startYear := 2005 + endYear := 2015 + + // explicit career_start/career_end should be used directly + t.Run("explicit fields", func(t *testing.T) { + input := jsonschema.Performer{ + Name: "test", + CareerStart: &startYear, + CareerEnd: &endYear, + } + + p, err := performerJSONToPerformer(input) + assert.Nil(t, err) + assert.Equal(t, &startYear, p.CareerStart) + assert.Equal(t, &endYear, p.CareerEnd) + }) + + // explicit fields take priority over legacy career_length + t.Run("explicit fields override legacy", func(t *testing.T) { + input := jsonschema.Performer{ + Name: "test", + CareerStart: &startYear, + CareerEnd: &endYear, + CareerLength: "1990 - 1995", + } + + p, err := performerJSONToPerformer(input) + assert.Nil(t, err) + assert.Equal(t, &startYear, p.CareerStart) + assert.Equal(t, &endYear, p.CareerEnd) + }) + + // legacy career_length should be parsed when explicit fields are absent + t.Run("legacy career_length fallback", func(t *testing.T) { + input := jsonschema.Performer{ + Name: "test", + CareerLength: "2005 - 2015", + } + + p, err := performerJSONToPerformer(input) + assert.Nil(t, err) + assert.Equal(t, &startYear, p.CareerStart) + assert.Equal(t, &endYear, p.CareerEnd) + }) + + // legacy career_length with only start year + t.Run("legacy career_length start only", func(t *testing.T) { + input := jsonschema.Performer{ + Name: "test", + CareerLength: "2005 -", + } + + p, err := performerJSONToPerformer(input) + assert.Nil(t, err) + assert.Equal(t, &startYear, p.CareerStart) + assert.Nil(t, p.CareerEnd) + }) + + // unparseable career_length should return an error + t.Run("legacy career_length unparseable", func(t *testing.T) { + input := jsonschema.Performer{ + Name: "test", + CareerLength: "not a year range", + } + + _, err := performerJSONToPerformer(input) + assert.NotNil(t, err) + }) + + // no career fields at all + t.Run("no career fields", func(t *testing.T) { + input := jsonschema.Performer{ + Name: "test", + } + + p, err := performerJSONToPerformer(input) + assert.Nil(t, err) + assert.Nil(t, p.CareerStart) + assert.Nil(t, p.CareerEnd) + }) +} diff --git a/pkg/scraper/mapped_result.go b/pkg/scraper/mapped_result.go index eb06a4eba..1260f3082 100644 --- a/pkg/scraper/mapped_result.go +++ b/pkg/scraper/mapped_result.go @@ -140,6 +140,8 @@ func (r mappedResult) scrapedPerformer() *models.ScrapedPerformer { PenisLength: r.stringPtr("PenisLength"), Circumcised: r.stringPtr("Circumcised"), CareerLength: r.stringPtr("CareerLength"), + CareerStart: r.IntPtr("CareerStart"), + CareerEnd: r.IntPtr("CareerEnd"), Tattoos: r.stringPtr("Tattoos"), Piercings: r.stringPtr("Piercings"), Aliases: r.stringPtr("Aliases"), diff --git a/pkg/scraper/performer.go b/pkg/scraper/performer.go index 98e931762..4684a6683 100644 --- a/pkg/scraper/performer.go +++ b/pkg/scraper/performer.go @@ -20,6 +20,8 @@ type ScrapedPerformerInput struct { PenisLength *string `json:"penis_length"` Circumcised *string `json:"circumcised"` CareerLength *string `json:"career_length"` + CareerStart *int `json:"career_start"` + CareerEnd *int `json:"career_end"` Tattoos *string `json:"tattoos"` Piercings *string `json:"piercings"` Aliases *string `json:"aliases"` diff --git a/pkg/scraper/postprocessing.go b/pkg/scraper/postprocessing.go index c2653743a..8a4d4de7d 100644 --- a/pkg/scraper/postprocessing.go +++ b/pkg/scraper/postprocessing.go @@ -125,6 +125,20 @@ func (c *postScraper) postScrapePerformer(ctx context.Context, p models.ScrapedP } } + isEmptyStr := func(s *string) bool { return s == nil || *s == "" } + isEmptyInt := func(s *int) bool { return s == nil || *s == 0 } + + // populate career start/end from career length and vice versa + if !isEmptyStr(p.CareerLength) && isEmptyInt(p.CareerStart) && isEmptyInt(p.CareerEnd) { + p.CareerStart, p.CareerEnd, err = utils.ParseYearRangeString(*p.CareerLength) + if err != nil { + logger.Warnf("Could not parse career length %s: %v", *p.CareerLength, err) + } + } else if isEmptyStr(p.CareerLength) && (!isEmptyInt(p.CareerStart) || !isEmptyInt(p.CareerEnd)) { + v := utils.FormatYearRange(p.CareerStart, p.CareerEnd) + p.CareerLength = &v + } + return p, nil } diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 51889ff20..197602ecd 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 77 +var appSchemaVersion uint = 78 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/78_performer_career_dates.up.sql b/pkg/sqlite/migrations/78_performer_career_dates.up.sql new file mode 100644 index 000000000..006d9fae7 --- /dev/null +++ b/pkg/sqlite/migrations/78_performer_career_dates.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE "performers" ADD COLUMN "career_start" integer; +ALTER TABLE "performers" ADD COLUMN "career_end" integer; diff --git a/pkg/sqlite/migrations/78_postmigrate.go b/pkg/sqlite/migrations/78_postmigrate.go new file mode 100644 index 000000000..15d040457 --- /dev/null +++ b/pkg/sqlite/migrations/78_postmigrate.go @@ -0,0 +1,143 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/jmoiron/sqlx" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/sqlite" + "github.com/stashapp/stash/pkg/utils" +) + +type schema78Migrator struct { + migrator +} + +func post78(ctx context.Context, db *sqlx.DB) error { + logger.Info("Running post-migration for schema version 78") + + m := schema78Migrator{ + migrator: migrator{ + db: db, + }, + } + + if err := m.migrateCareerLength(ctx); err != nil { + return fmt.Errorf("migrating career_length: %w", err) + } + + if err := m.dropCareerLength(); err != nil { + return fmt.Errorf("dropping career_length column: %w", err) + } + + return nil +} + +func (m *schema78Migrator) migrateCareerLength(ctx context.Context) error { + logger.Info("Migrating career_length to career_start/career_end") + + const limit = 1000 + + lastID := 0 + parsed := 0 + unparseable := 0 + + for { + gotSome := false + + if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { + query := `SELECT id, career_length FROM performers + WHERE career_length IS NOT NULL AND career_length != ''` + + if lastID != 0 { + query += fmt.Sprintf(" AND id > %d", lastID) + } + + query += fmt.Sprintf(" ORDER BY id LIMIT %d", limit) + + rows, err := tx.Query(query) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var ( + id int + careerLength string + ) + + if err := rows.Scan(&id, &careerLength); err != nil { + return err + } + + lastID = id + gotSome = true + + start, end, err := utils.ParseYearRangeString(careerLength) + if err != nil { + logger.Warnf("Could not parse career_length %q for performer %d: %v — preserving as custom field", careerLength, id, err) + + if err := m.preserveAsCustomField(tx, id, careerLength); err != nil { + return fmt.Errorf("preserving career_length for performer %d: %w", id, err) + } + unparseable++ + continue + } + + if err := m.updateCareerFields(tx, id, start, end); err != nil { + return fmt.Errorf("updating career fields for performer %d: %w", id, err) + } + parsed++ + } + + return rows.Err() + }); err != nil { + return err + } + + if !gotSome { + break + } + } + + logger.Infof("Career length migration complete: %d parsed, %d unparseable (preserved as custom fields)", parsed, unparseable) + return nil +} + +func (m *schema78Migrator) updateCareerFields(tx *sqlx.Tx, id int, start *int, end *int) error { + _, err := tx.Exec( + "UPDATE performers SET career_start = ?, career_end = ? WHERE id = ?", + start, end, id, + ) + return err +} + +func (m *schema78Migrator) preserveAsCustomField(tx *sqlx.Tx, id int, value string) error { + // check if a career_length custom field already exists + var existing sql.NullString + err := tx.Get(&existing, "SELECT value FROM performer_custom_fields WHERE performer_id = ? AND field = 'career_length'", id) + if err == nil { + logger.Debugf("career_length custom field already exists for performer %d, skipping", id) + return nil + } + + _, err = tx.Exec( + "INSERT INTO performer_custom_fields (performer_id, field, value) VALUES (?, 'career_length', ?)", + id, value, + ) + return err +} + +func (m *schema78Migrator) dropCareerLength() error { + logger.Info("Dropping career_length column from performers table") + return m.execAll([]string{ + "ALTER TABLE performers DROP COLUMN career_length", + }) +} + +func init() { + sqlite.RegisterPostMigration(78, post78) +} diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index bc4461f5f..298a681fd 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -44,7 +44,8 @@ type performerRow struct { FakeTits zero.String `db:"fake_tits"` PenisLength null.Float `db:"penis_length"` Circumcised zero.String `db:"circumcised"` - CareerLength zero.String `db:"career_length"` + CareerStart null.Int `db:"career_start"` + CareerEnd null.Int `db:"career_end"` Tattoos zero.String `db:"tattoos"` Piercings zero.String `db:"piercings"` Favorite bool `db:"favorite"` @@ -82,7 +83,8 @@ func (r *performerRow) fromPerformer(o models.Performer) { if o.Circumcised != nil && o.Circumcised.IsValid() { r.Circumcised = zero.StringFrom(o.Circumcised.String()) } - r.CareerLength = zero.StringFrom(o.CareerLength) + r.CareerStart = intFromPtr(o.CareerStart) + r.CareerEnd = intFromPtr(o.CareerEnd) r.Tattoos = zero.StringFrom(o.Tattoos) r.Piercings = zero.StringFrom(o.Piercings) r.Favorite = o.Favorite @@ -110,7 +112,8 @@ func (r *performerRow) resolve() *models.Performer { Measurements: r.Measurements.String, FakeTits: r.FakeTits.String, PenisLength: nullFloatPtr(r.PenisLength), - CareerLength: r.CareerLength.String, + CareerStart: nullIntPtr(r.CareerStart), + CareerEnd: nullIntPtr(r.CareerEnd), Tattoos: r.Tattoos.String, Piercings: r.Piercings.String, Favorite: r.Favorite, @@ -155,7 +158,8 @@ func (r *performerRowRecord) fromPartial(o models.PerformerPartial) { r.setNullString("fake_tits", o.FakeTits) r.setNullFloat64("penis_length", o.PenisLength) r.setNullString("circumcised", o.Circumcised) - r.setNullString("career_length", o.CareerLength) + r.setNullInt("career_start", o.CareerStart) + r.setNullInt("career_end", o.CareerEnd) r.setNullString("tattoos", o.Tattoos) r.setNullString("piercings", o.Piercings) r.setBool("favorite", o.Favorite) @@ -776,7 +780,8 @@ func (qb *PerformerStore) sortByScenesDuration(direction string) string { var performerSortOptions = sortOptions{ "birthdate", - "career_length", + "career_start", + "career_end", "created_at", "galleries_count", "height", diff --git a/pkg/sqlite/performer_filter.go b/pkg/sqlite/performer_filter.go index 401664e33..5296d5a25 100644 --- a/pkg/sqlite/performer_filter.go +++ b/pkg/sqlite/performer_filter.go @@ -47,6 +47,29 @@ func (qb *performerFilterHandler) validate() error { } } + // if legacy career length filter used, ensure only supported modifiers are used and value is valid + if filter.CareerLength != nil { + careerLength := filter.CareerLength + switch careerLength.Modifier { + case models.CriterionModifierEquals: + start, end, err := utils.ParseYearRangeString(careerLength.Value) + if err != nil { + return fmt.Errorf("invalid career length value: %s", careerLength.Value) + } + // ensure career start/end is not set + if start != nil && filter.CareerStart != nil { + return fmt.Errorf("cannot use legacy CareerLength filter with CareerStart filter") + } + if end != nil && filter.CareerEnd != nil { + return fmt.Errorf("cannot use legacy CareerLength filter with CareerEnd filter") + } + case models.CriterionModifierIsNull, models.CriterionModifierNotNull: + // valid modifiers, no value parsing needed + default: + return fmt.Errorf("invalid career length modifier: %s", careerLength.Modifier) + } + } + return nil } @@ -71,10 +94,13 @@ func (qb *performerFilterHandler) handle(ctx context.Context, f *filterBuilder) } func (qb *performerFilterHandler) criterionHandler() criterionHandler { - filter := qb.performerFilter + // make a copy of the filter to modify with legacy conversions without affecting original filter used for subfilters + filter := *qb.performerFilter const tableName = performerTable heightCmCrit := filter.HeightCm + convertLegacyCareerLengthFilter(&filter) + return compoundHandler{ stringCriterionHandler(filter.Name, tableName+".name"), stringCriterionHandler(filter.Disambiguation, tableName+".disambiguation"), @@ -129,7 +155,9 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler { } }), - stringCriterionHandler(filter.CareerLength, tableName+".career_length"), + // CareerLength filter is deprecated and non-functional (column removed in schema 78) + intCriterionHandler(filter.CareerStart, tableName+".career_start", nil), + intCriterionHandler(filter.CareerEnd, tableName+".career_end", nil), stringCriterionHandler(filter.Tattoos, tableName+".tattoos"), stringCriterionHandler(filter.Piercings, tableName+".piercings"), intCriterionHandler(filter.Rating100, tableName+".rating", nil), @@ -221,6 +249,43 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler { } } +func convertLegacyCareerLengthFilter(filter *models.PerformerFilterType) { + // convert legacy career length filter to career start/end filters + if filter.CareerLength != nil { + careerLength := filter.CareerLength + switch careerLength.Modifier { + case models.CriterionModifierEquals: + start, end, _ := utils.ParseYearRangeString(careerLength.Value) + if start != nil { + filter.CareerStart = &models.IntCriterionInput{ + Value: (*start) - 1, // minus one to make it exclusive + Modifier: models.CriterionModifierGreaterThan, + } + } + if end != nil { + filter.CareerEnd = &models.IntCriterionInput{ + Value: (*end) + 1, // plus one to make it exclusive + Modifier: models.CriterionModifierLessThan, + } + } + case models.CriterionModifierIsNull: + filter.CareerStart = &models.IntCriterionInput{ + Modifier: models.CriterionModifierIsNull, + } + filter.CareerEnd = &models.IntCriterionInput{ + Modifier: models.CriterionModifierIsNull, + } + case models.CriterionModifierNotNull: + filter.CareerStart = &models.IntCriterionInput{ + Modifier: models.CriterionModifierNotNull, + } + filter.CareerEnd = &models.IntCriterionInput{ + Modifier: models.CriterionModifierNotNull, + } + } + } +} + // TODO - we need to provide a whitelist of possible values func (qb *performerFilterHandler) performerIsMissingCriterionHandler(isMissing *string) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index 8d53ca0db..46a5febee 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -66,7 +66,8 @@ func Test_PerformerStore_Create(t *testing.T) { fakeTits = "fakeTits" penisLength = 1.23 circumcised = models.CircumisedEnumCut - careerLength = "careerLength" + careerStart = 2005 + careerEnd = 2015 tattoos = "tattoos" piercings = "piercings" aliases = []string{"alias1", "alias2"} @@ -107,7 +108,8 @@ func Test_PerformerStore_Create(t *testing.T) { FakeTits: fakeTits, PenisLength: &penisLength, Circumcised: &circumcised, - CareerLength: careerLength, + CareerStart: &careerStart, + CareerEnd: &careerEnd, Tattoos: tattoos, Piercings: piercings, Favorite: favorite, @@ -204,8 +206,6 @@ func Test_PerformerStore_Create(t *testing.T) { } assert.Equal(tt.newObject.CustomFields, cf) - - return }) } } @@ -229,7 +229,8 @@ func Test_PerformerStore_Update(t *testing.T) { fakeTits = "fakeTits" penisLength = 1.23 circumcised = models.CircumisedEnumCut - careerLength = "careerLength" + careerStart = 2005 + careerEnd = 2015 tattoos = "tattoos" piercings = "piercings" aliases = []string{"alias1", "alias2"} @@ -271,7 +272,8 @@ func Test_PerformerStore_Update(t *testing.T) { FakeTits: fakeTits, PenisLength: &penisLength, Circumcised: &circumcised, - CareerLength: careerLength, + CareerStart: &careerStart, + CareerEnd: &careerEnd, Tattoos: tattoos, Piercings: piercings, Favorite: favorite, @@ -422,7 +424,8 @@ func clearPerformerPartial() models.PerformerPartial { FakeTits: nullString, PenisLength: nullFloat, Circumcised: nullString, - CareerLength: nullString, + CareerStart: nullInt, + CareerEnd: nullInt, Tattoos: nullString, Piercings: nullString, Aliases: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet}, @@ -455,7 +458,8 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { fakeTits = "fakeTits" penisLength = 1.23 circumcised = models.CircumisedEnumCut - careerLength = "careerLength" + careerStart = 2005 + careerEnd = 2015 tattoos = "tattoos" piercings = "piercings" aliases = []string{"alias1", "alias2"} @@ -501,7 +505,8 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { FakeTits: models.NewOptionalString(fakeTits), PenisLength: models.NewOptionalFloat64(penisLength), Circumcised: models.NewOptionalString(circumcised.String()), - CareerLength: models.NewOptionalString(careerLength), + CareerStart: models.NewOptionalInt(careerStart), + CareerEnd: models.NewOptionalInt(careerEnd), Tattoos: models.NewOptionalString(tattoos), Piercings: models.NewOptionalString(piercings), Aliases: &models.UpdateStrings{ @@ -552,7 +557,8 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { FakeTits: fakeTits, PenisLength: &penisLength, Circumcised: &circumcised, - CareerLength: careerLength, + CareerStart: &careerStart, + CareerEnd: &careerEnd, Tattoos: tattoos, Piercings: piercings, Aliases: models.NewRelatedStrings(aliases), @@ -1766,30 +1772,117 @@ func verifyPerformerAge(t *testing.T, ageCriterion models.IntCriterionInput) { }) } -func TestPerformerQueryCareerLength(t *testing.T) { - const value = "2005" - careerLengthCriterion := models.StringCriterionInput{ +func TestPerformerQueryLegacyCareerLength(t *testing.T) { + const value = "2002 - 2012" + + tests := []struct { + name string + c models.StringCriterionInput + careerStartCrit *models.IntCriterionInput + careerEndCrit *models.IntCriterionInput + err bool + }{ + { + name: "valid format", + c: models.StringCriterionInput{ + Value: value, + Modifier: models.CriterionModifierEquals, + }, + careerStartCrit: &models.IntCriterionInput{ + Value: 2002, + Modifier: models.CriterionModifierEquals, + }, + careerEndCrit: &models.IntCriterionInput{ + Value: 2012, + Modifier: models.CriterionModifierEquals, + }, + err: false, + }, + { + name: "invalid format", + c: models.StringCriterionInput{ + Value: "invalid format", + Modifier: models.CriterionModifierEquals, + }, + err: true, + }, + { + name: "is null", + c: models.StringCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + careerStartCrit: &models.IntCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + careerEndCrit: &models.IntCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + err: false, + }, + { + name: "not null", + c: models.StringCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + careerStartCrit: &models.IntCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + careerEndCrit: &models.IntCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + err: false, + }, + { + name: "invalid modifier", + c: models.StringCriterionInput{ + Value: value, + Modifier: models.CriterionModifierMatchesRegex, + }, + err: true, + }, + } + + qb := db.Performer + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + performers, _, err := qb.Query(ctx, &models.PerformerFilterType{ + CareerLength: &tt.c, + }, nil) + + if err != nil && !tt.err { + t.Errorf("Error querying performer: %s", err.Error()) + } else if err == nil && tt.err { + t.Errorf("Expected error but got none") + } + + if err != nil || tt.err { + return + } + + if len(performers) == 0 { + t.Errorf("Expected to find performers but found none") + } + + for _, performer := range performers { + verifyIntPtr(t, performer.CareerStart, *tt.careerStartCrit) + verifyIntPtr(t, performer.CareerEnd, *tt.careerEndCrit) + } + }) + } +} + +func TestPerformerQueryCareerStart(t *testing.T) { + const value = 2002 + criterion := models.IntCriterionInput{ Value: value, Modifier: models.CriterionModifierEquals, } - verifyPerformerCareerLength(t, careerLengthCriterion) - - careerLengthCriterion.Modifier = models.CriterionModifierNotEquals - verifyPerformerCareerLength(t, careerLengthCriterion) - - careerLengthCriterion.Modifier = models.CriterionModifierMatchesRegex - verifyPerformerCareerLength(t, careerLengthCriterion) - - careerLengthCriterion.Modifier = models.CriterionModifierNotMatchesRegex - verifyPerformerCareerLength(t, careerLengthCriterion) -} - -func verifyPerformerCareerLength(t *testing.T, criterion models.StringCriterionInput) { withTxn(func(ctx context.Context) error { qb := db.Performer performerFilter := models.PerformerFilterType{ - CareerLength: &criterion, + CareerStart: &criterion, } performers, _, err := qb.Query(ctx, &performerFilter, nil) @@ -1798,8 +1891,33 @@ func verifyPerformerCareerLength(t *testing.T, criterion models.StringCriterionI } for _, performer := range performers { - cl := performer.CareerLength - verifyString(t, cl, criterion) + verifyIntPtr(t, performer.CareerStart, criterion) + } + + return nil + }) +} + +func TestPerformerQueryCareerEnd(t *testing.T) { + const value = 2012 + criterion := models.IntCriterionInput{ + Value: value, + Modifier: models.CriterionModifierEquals, + } + + withTxn(func(ctx context.Context) error { + qb := db.Performer + performerFilter := models.PerformerFilterType{ + CareerEnd: &criterion, + } + + performers, _, err := qb.Query(ctx, &performerFilter, nil) + if err != nil { + t.Errorf("Error querying performer: %s", err.Error()) + } + + for _, performer := range performers { + verifyIntPtr(t, performer.CareerEnd, criterion) } return nil diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index bdb83b1df..ffa60457e 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1513,15 +1513,28 @@ func getPerformerDeathDate(index int) *models.Date { return &ret } -func getPerformerCareerLength(index int) *string { +func getPerformerCareerStart(index int) *int { if index%5 == 0 { return nil } - ret := fmt.Sprintf("20%2d", index) + ret := 2000 + index return &ret } +func getPerformerCareerEnd(index int) *int { + if index%5 == 0 { + return nil + } + + // only set career_end for even indices + if index%2 == 0 { + ret := 2010 + index + return &ret + } + return nil +} + func getPerformerPenisLength(index int) *float64 { if index%5 == 0 { return nil @@ -1615,10 +1628,8 @@ func createPerformers(ctx context.Context, n int, o int) error { TagIDs: models.NewRelatedIDs(tids), } - careerLength := getPerformerCareerLength(i) - if careerLength != nil { - performer.CareerLength = *careerLength - } + performer.CareerStart = getPerformerCareerStart(i) + performer.CareerEnd = getPerformerCareerEnd(i) if (index+1)%5 != 0 { performer.StashIDs = models.NewRelatedStashIDs([]models.StashID{ diff --git a/pkg/stashbox/performer.go b/pkg/stashbox/performer.go index 38824eba1..231b936d6 100644 --- a/pkg/stashbox/performer.go +++ b/pkg/stashbox/performer.go @@ -231,6 +231,16 @@ func performerFragmentToScrapedPerformer(p graphql.PerformerFragment) *models.Sc sp.Height = &hs } + if p.CareerStartYear != nil { + cs := *p.CareerStartYear + sp.CareerStart = &cs + } + + if p.CareerEndYear != nil { + ce := *p.CareerEndYear + sp.CareerEnd = &ce + } + if p.BirthDate != nil { sp.Birthdate = padFuzzyDate(p.BirthDate) } @@ -388,16 +398,11 @@ func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Perf aliases := strings.Join(performer.Aliases.List(), ",") draft.Aliases = &aliases } - if performer.CareerLength != "" { - var career = strings.Split(performer.CareerLength, "-") - if i, err := strconv.Atoi(strings.TrimSpace(career[0])); err == nil { - draft.CareerStartYear = &i - } - if len(career) == 2 { - if y, err := strconv.Atoi(strings.TrimSpace(career[1])); err == nil { - draft.CareerEndYear = &y - } - } + if performer.CareerStart != nil { + draft.CareerStartYear = performer.CareerStart + } + if performer.CareerEnd != nil { + draft.CareerEndYear = performer.CareerEnd } if len(performer.URLs.List()) > 0 { diff --git a/pkg/utils/date.go b/pkg/utils/date.go index de5566e4d..4b805862a 100644 --- a/pkg/utils/date.go +++ b/pkg/utils/date.go @@ -2,6 +2,8 @@ package utils import ( "fmt" + "strconv" + "strings" "time" ) @@ -25,3 +27,80 @@ func ParseDateStringAsTime(dateString string) (time.Time, error) { return time.Time{}, fmt.Errorf("ParseDateStringAsTime failed: dateString <%s>", dateString) } + +// ParseYearRangeString parses a year range string into start and end year integers. +// Supported formats: "YYYY", "YYYY - YYYY", "YYYY-YYYY", "YYYY -", "- YYYY", "YYYY-present". +// Returns nil for start/end if not present in the string. +func ParseYearRangeString(s string) (start *int, end *int, err error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, nil, fmt.Errorf("empty year range string") + } + + // normalize "present" to empty end + lower := strings.ToLower(s) + lower = strings.ReplaceAll(lower, "present", "") + + // split on "-" if it contains one + var parts []string + if strings.Contains(lower, "-") { + parts = strings.SplitN(lower, "-", 2) + } else { + // single value, treat as start year + year, err := parseYear(lower) + if err != nil { + return nil, nil, fmt.Errorf("invalid year range %q: %w", s, err) + } + return &year, nil, nil + } + + startStr := strings.TrimSpace(parts[0]) + endStr := strings.TrimSpace(parts[1]) + + if startStr != "" { + y, err := parseYear(startStr) + if err != nil { + return nil, nil, fmt.Errorf("invalid start year in %q: %w", s, err) + } + start = &y + } + + if endStr != "" { + y, err := parseYear(endStr) + if err != nil { + return nil, nil, fmt.Errorf("invalid end year in %q: %w", s, err) + } + end = &y + } + + if start == nil && end == nil { + return nil, nil, fmt.Errorf("could not parse year range %q", s) + } + + return start, end, nil +} + +func parseYear(s string) (int, error) { + s = strings.TrimSpace(s) + year, err := strconv.Atoi(s) + if err != nil { + return 0, fmt.Errorf("invalid year %q: %w", s, err) + } + if year < 1900 || year > 2200 { + return 0, fmt.Errorf("year %d out of reasonable range", year) + } + return year, nil +} + +func FormatYearRange(start *int, end *int) string { + switch { + case start == nil && end == nil: + return "" + case end == nil: + return fmt.Sprintf("%d -", *start) + case start == nil: + return fmt.Sprintf("- %d", *end) + default: + return fmt.Sprintf("%d - %d", *start, *end) + } +} diff --git a/pkg/utils/date_test.go b/pkg/utils/date_test.go index ae077c21e..a9e174094 100644 --- a/pkg/utils/date_test.go +++ b/pkg/utils/date_test.go @@ -2,6 +2,8 @@ package utils import ( "testing" + + "github.com/stretchr/testify/assert" ) func TestParseDateStringAsTime(t *testing.T) { @@ -41,3 +43,66 @@ func TestParseDateStringAsTime(t *testing.T) { }) } } + +func TestParseYearRangeString(t *testing.T) { + intPtr := func(v int) *int { return &v } + + tests := []struct { + name string + input string + wantStart *int + wantEnd *int + wantErr bool + }{ + {"single year", "2005", intPtr(2005), nil, false}, + {"year range with spaces", "2005 - 2010", intPtr(2005), intPtr(2010), false}, + {"year range no spaces", "2005-2010", intPtr(2005), intPtr(2010), false}, + {"year dash open", "2005 -", intPtr(2005), nil, false}, + {"year dash open no space", "2005-", intPtr(2005), nil, false}, + {"dash year", "- 2010", nil, intPtr(2010), false}, + {"year present", "2005-present", intPtr(2005), nil, false}, + {"year Present caps", "2005 - Present", intPtr(2005), nil, false}, + {"whitespace padding", " 2005 - 2010 ", intPtr(2005), intPtr(2010), false}, + {"empty string", "", nil, nil, true}, + {"garbage", "not a year", nil, nil, true}, + {"partial garbage start", "abc - 2010", nil, nil, true}, + {"partial garbage end", "2005 - abc", nil, nil, true}, + {"year out of range", "1800", nil, nil, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + start, end, err := ParseYearRangeString(tt.input) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.wantStart, start) + assert.Equal(t, tt.wantEnd, end) + }) + } +} + +func TestFormatYearRange(t *testing.T) { + intPtr := func(v int) *int { return &v } + + tests := []struct { + name string + start *int + end *int + want string + }{ + {"both nil", nil, nil, ""}, + {"only start", intPtr(2005), nil, "2005 -"}, + {"only end", nil, intPtr(2010), "- 2010"}, + {"start and end", intPtr(2005), intPtr(2010), "2005 - 2010"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FormatYearRange(tt.start, tt.end) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/ui/v2.5/graphql/data/performer-slim.graphql b/ui/v2.5/graphql/data/performer-slim.graphql index 56a30842d..9bb628fba 100644 --- a/ui/v2.5/graphql/data/performer-slim.graphql +++ b/ui/v2.5/graphql/data/performer-slim.graphql @@ -16,7 +16,8 @@ fragment SlimPerformerData on Performer { fake_tits penis_length circumcised - career_length + career_start + career_end tattoos piercings alias_list diff --git a/ui/v2.5/graphql/data/performer.graphql b/ui/v2.5/graphql/data/performer.graphql index 035c8abc7..2a75fbb95 100644 --- a/ui/v2.5/graphql/data/performer.graphql +++ b/ui/v2.5/graphql/data/performer.graphql @@ -13,7 +13,8 @@ fragment PerformerData on Performer { fake_tits penis_length circumcised - career_length + career_start + career_end tattoos piercings alias_list diff --git a/ui/v2.5/graphql/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql index 4a0f588a4..e58c21a20 100644 --- a/ui/v2.5/graphql/data/scrapers.graphql +++ b/ui/v2.5/graphql/data/scrapers.graphql @@ -38,7 +38,8 @@ fragment ScrapedPerformerData on ScrapedPerformer { fake_tits penis_length circumcised - career_length + career_start + career_end tattoos piercings aliases @@ -68,7 +69,8 @@ fragment ScrapedScenePerformerData on ScrapedPerformer { fake_tits penis_length circumcised - career_length + career_start + career_end tattoos piercings aliases diff --git a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx index 677ac3aa1..d60118d4b 100644 --- a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx +++ b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx @@ -42,7 +42,8 @@ const performerFields = [ "gender", "birthdate", "death_date", - "career_length", + "career_start", + "career_end", "country", "ethnicity", "eye_color", @@ -363,8 +364,15 @@ export const EditPerformersDialog: React.FC = ( {renderTextField("piercings", updateInput.piercings, (v) => setUpdateField({ piercings: v }) )} - {renderTextField("career_length", updateInput.career_length, (v) => - setUpdateField({ career_length: v }) + {renderTextField( + "career_start", + updateInput.career_start?.toString(), + (v) => setUpdateField({ career_start: v ? parseInt(v) : undefined }) + )} + {renderTextField( + "career_end", + updateInput.career_end?.toString(), + (v) => setUpdateField({ career_end: v ? parseInt(v) : undefined }) )} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx index 95e03ff8b..473bbbd47 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx @@ -12,6 +12,7 @@ import { FormatHeight, FormatPenisLength, FormatWeight, + formatYearRange, } from "../PerformerList"; import { PatchComponent } from "src/patch"; import { CustomFields } from "src/components/Shared/CustomFields"; @@ -174,7 +175,10 @@ export const PerformerDetailsPanel: React.FC = /> diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index 0e769edf9..98871bf9a 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -126,7 +126,8 @@ export const PerformerEditPanel: React.FC = ({ circumcised: yupInputEnum(GQL.CircumisedEnum).nullable().defined(), tattoos: yup.string().ensure(), piercings: yup.string().ensure(), - career_length: yup.string().ensure(), + career_start: yupInputNumber().positive().nullable().defined(), + career_end: yupInputNumber().positive().nullable().defined(), urls: yupUniqueStringList(intl), details: yup.string().ensure(), tag_ids: yup.array(yup.string().required()).defined(), @@ -155,7 +156,8 @@ export const PerformerEditPanel: React.FC = ({ circumcised: performer.circumcised ?? null, tattoos: performer.tattoos ?? "", piercings: performer.piercings ?? "", - career_length: performer.career_length ?? "", + career_start: performer.career_start ?? null, + career_end: performer.career_end ?? null, urls: performer.urls ?? [], details: performer.details ?? "", tag_ids: (performer.tags ?? []).map((t) => t.id), @@ -256,8 +258,11 @@ export const PerformerEditPanel: React.FC = ({ if (state.fake_tits) { formik.setFieldValue("fake_tits", state.fake_tits); } - if (state.career_length) { - formik.setFieldValue("career_length", state.career_length); + if (state.career_start) { + formik.setFieldValue("career_start", state.career_start); + } + if (state.career_end) { + formik.setFieldValue("career_end", state.career_end); } if (state.tattoos) { formik.setFieldValue("tattoos", state.tattoos); @@ -747,7 +752,8 @@ export const PerformerEditPanel: React.FC = ({ {renderInputField("tattoos", "textarea")} {renderInputField("piercings", "textarea")} - {renderInputField("career_length")} + {renderInputField("career_start", "number")} + {renderInputField("career_end", "number")} {renderURLListField("urls", onScrapePerformerURL, urlScrapable)} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx index afb57a66e..d5146592b 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx @@ -8,6 +8,7 @@ import { ScrapedTextAreaRow, ScrapedCountryRow, ScrapedStringListRow, + ScrapedNumberRow, } from "src/components/Shared/ScrapeDialog/ScrapeDialogRow"; import { ScrapeDialog } from "src/components/Shared/ScrapeDialog/ScrapeDialog"; import { Form } from "react-bootstrap"; @@ -272,10 +273,16 @@ export const PerformerScrapeDialog: React.FC = ( const [fakeTits, setFakeTits] = useState>( new ScrapeResult(props.performer.fake_tits, props.scraped.fake_tits) ); - const [careerLength, setCareerLength] = useState>( - new ScrapeResult( - props.performer.career_length, - props.scraped.career_length + const [careerStart, setCareerStart] = useState>( + new ScrapeResult( + props.performer.career_start, + props.scraped.career_start + ) + ); + const [careerEnd, setCareerEnd] = useState>( + new ScrapeResult( + props.performer.career_end, + props.scraped.career_end ) ); const [tattoos, setTattoos] = useState>( @@ -347,7 +354,8 @@ export const PerformerScrapeDialog: React.FC = ( fakeTits, penisLength, circumcised, - careerLength, + careerStart, + careerEnd, tattoos, piercings, urls, @@ -379,7 +387,8 @@ export const PerformerScrapeDialog: React.FC = ( height: height.getNewValue(), measurements: measurements.getNewValue(), fake_tits: fakeTits.getNewValue(), - career_length: careerLength.getNewValue(), + career_start: careerStart.getNewValue(), + career_end: careerEnd.getNewValue(), tattoos: tattoos.getNewValue(), piercings: piercings.getNewValue(), urls: urls.getNewValue(), @@ -493,11 +502,17 @@ export const PerformerScrapeDialog: React.FC = ( result={fakeTits} onChange={(value) => setFakeTits(value)} /> - setCareerLength(value)} + setCareerStart(value)} + /> + setCareerEnd(value)} /> { ); }; +export function formatYearRange( + start?: number | null, + end?: number | null +): string | undefined { + if (!start && !end) return undefined; + return `${start ?? ""} - ${end ?? ""}`; +} + export const FormatCircumcised = (circumcised?: GQL.CircumisedEnum | null) => { const intl = useIntl(); if (!circumcised) { diff --git a/ui/v2.5/src/components/Performers/PerformerListTable.tsx b/ui/v2.5/src/components/Performers/PerformerListTable.tsx index 58538e7e2..3b500cee6 100644 --- a/ui/v2.5/src/components/Performers/PerformerListTable.tsx +++ b/ui/v2.5/src/components/Performers/PerformerListTable.tsx @@ -17,6 +17,7 @@ import { FormatHeight, FormatPenisLength, FormatWeight, + formatYearRange, } from "./PerformerList"; import TextUtils from "src/utils/text"; import { getCountryByISO } from "src/utils/country"; @@ -188,7 +189,7 @@ export const PerformerListTable: React.FC = ( ); const CareerLengthCell = (performer: GQL.PerformerDataFragment) => ( - {performer.career_length} + <>{formatYearRange(performer.career_start, performer.career_end) ?? ""} ); const SceneCountCell = (performer: GQL.PerformerDataFragment) => ( diff --git a/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx index ab4a6fed5..efa51f1db 100644 --- a/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx @@ -102,8 +102,11 @@ const PerformerMergeDetails: React.FC = ({ const [fakeTits, setFakeTits] = useState>( new ScrapeResult(dest.fake_tits) ); - const [careerLength, setCareerLength] = useState>( - new ScrapeResult(dest.career_length) + const [careerStart, setCareerStart] = useState>( + new ScrapeResult(dest.career_start?.toString()) + ); + const [careerEnd, setCareerEnd] = useState>( + new ScrapeResult(dest.career_end?.toString()) ); const [tattoos, setTattoos] = useState>( new ScrapeResult(dest.tattoos) @@ -264,11 +267,18 @@ const PerformerMergeDetails: React.FC = ({ !dest.fake_tits ) ); - setCareerLength( + setCareerStart( new ScrapeResult( - dest.career_length, - sources.find((s) => s.career_length)?.career_length, - !dest.career_length + dest.career_start?.toString(), + sources.find((s) => s.career_start)?.career_start?.toString(), + !dest.career_start + ) + ); + setCareerEnd( + new ScrapeResult( + dest.career_end?.toString(), + sources.find((s) => s.career_end)?.career_end?.toString(), + !dest.career_end ) ); setTattoos( @@ -378,7 +388,8 @@ const PerformerMergeDetails: React.FC = ({ penisLength, measurements, fakeTits, - careerLength, + careerStart, + careerEnd, tattoos, piercings, urls, @@ -404,7 +415,8 @@ const PerformerMergeDetails: React.FC = ({ penisLength, measurements, fakeTits, - careerLength, + careerStart, + careerEnd, tattoos, piercings, urls, @@ -520,10 +532,16 @@ const PerformerMergeDetails: React.FC = ({ onChange={(value) => setFakeTits(value)} /> setCareerLength(value)} + field="career_start" + title={intl.formatMessage({ id: "career_start" })} + result={careerStart} + onChange={(value) => setCareerStart(value)} + /> + setCareerEnd(value)} /> = ({ : undefined, measurements: measurements.getNewValue(), fake_tits: fakeTits.getNewValue(), - career_length: careerLength.getNewValue(), + career_start: careerStart.getNewValue() + ? parseInt(careerStart.getNewValue()!) + : undefined, + career_end: careerEnd.getNewValue() + ? parseInt(careerEnd.getNewValue()!) + : undefined, tattoos: tattoos.getNewValue(), piercings: piercings.getNewValue(), urls: urls.getNewValue(), diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index 17ca3a737..54a010e50 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -68,7 +68,8 @@ .collapsed { .detail-item.tattoos, .detail-item.piercings, - .detail-item.career_length, + .detail-item.career_start, + .detail-item.career_end, .detail-item.details, .detail-item.tags, .detail-item.stash_ids { diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx index 677ecb87f..a0fe6489e 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx @@ -171,6 +171,70 @@ export const ScrapedInputGroupRow: React.FC = ( ); }; +interface IScrapedNumberInputProps { + isNew?: boolean; + placeholder?: string; + locked?: boolean; + result: ScrapeResult; + onChange?: (value: number) => void; +} + +const ScrapedNumberInput: React.FC = (props) => { + return ( + { + if (props.isNew && props.onChange) { + props.onChange(Number(e.target.value)); + } + }} + className="bg-secondary text-white border-secondary" + type="number" + /> + ); +}; + +interface IScrapedNumberRowProps { + title: string; + field: string; + className?: string; + placeholder?: string; + result: ScrapeResult; + locked?: boolean; + onChange: (value: ScrapeResult) => void; +} + +export const ScrapedNumberRow: React.FC = (props) => { + return ( + + } + newField={ + + props.onChange(props.result.cloneWithValue(value)) + } + /> + } + onChange={props.onChange} + /> + ); +}; + interface IScrapedStringListProps { isNew?: boolean; placeholder?: string; diff --git a/ui/v2.5/src/components/Tagger/PerformerModal.tsx b/ui/v2.5/src/components/Tagger/PerformerModal.tsx index 79f80708a..ac9444c5b 100755 --- a/ui/v2.5/src/components/Tagger/PerformerModal.tsx +++ b/ui/v2.5/src/components/Tagger/PerformerModal.tsx @@ -240,7 +240,8 @@ const PerformerModal: React.FC = ({ height_cm: Number.parseFloat(performer.height ?? "") ?? undefined, measurements: performer.measurements, fake_tits: performer.fake_tits, - career_length: performer.career_length, + career_start: performer.career_start, + career_end: performer.career_end, tattoos: performer.tattoos, piercings: performer.piercings, urls: performer.urls, @@ -326,7 +327,8 @@ const PerformerModal: React.FC = ({ {maybeRenderField("measurements", performer.measurements)} {performer?.gender !== GQL.GenderEnum.Male && maybeRenderField("fake_tits", performer.fake_tits)} - {maybeRenderField("career_length", performer.career_length)} + {maybeRenderField("career_start", performer.career_start?.toString())} + {maybeRenderField("career_end", performer.career_end?.toString())} {maybeRenderField("tattoos", performer.tattoos, false)} {maybeRenderField("piercings", performer.piercings, false)} {maybeRenderField("weight", performer.weight, false)} diff --git a/ui/v2.5/src/components/Tagger/constants.ts b/ui/v2.5/src/components/Tagger/constants.ts index d499062aa..d59a6d3d5 100644 --- a/ui/v2.5/src/components/Tagger/constants.ts +++ b/ui/v2.5/src/components/Tagger/constants.ts @@ -75,7 +75,8 @@ export const PERFORMER_FIELDS = [ "fake_tits", "tattoos", "piercings", - "career_length", + "career_start", + "career_end", "urls", "details", ]; diff --git a/ui/v2.5/src/core/performers.ts b/ui/v2.5/src/core/performers.ts index 9712c9824..016e9e13f 100644 --- a/ui/v2.5/src/core/performers.ts +++ b/ui/v2.5/src/core/performers.ts @@ -104,7 +104,10 @@ export const scrapedPerformerToCreateInput = ( height_cm: toCreate.height ? Number(toCreate.height) : undefined, measurements: toCreate.measurements, fake_tits: toCreate.fake_tits, - career_length: toCreate.career_length, + career_start: toCreate.career_start + ? Number(toCreate.career_start) + : undefined, + career_end: toCreate.career_end ? Number(toCreate.career_end) : undefined, tattoos: toCreate.tattoos, piercings: toCreate.piercings, alias_list: aliases, diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index b8800216c..595ff4c61 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -175,7 +175,9 @@ "filesystem": "Filesystem" }, "captions": "Captions", + "career_end": "Career End", "career_length": "Career Length", + "career_start": "Career Start", "chapters": "Chapters", "circumcised": "Circumcised", "circumcised_types": { diff --git a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts index 58e3535a6..512616f3c 100644 --- a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts +++ b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts @@ -58,7 +58,8 @@ export const PerformerIsMissingCriterionOption = new IsMissingCriterionOption( "weight", "measurements", "fake_tits", - "career_length", + "career_start", + "career_end", "tattoos", "piercings", "aliases", diff --git a/ui/v2.5/src/models/list-filter/performers.ts b/ui/v2.5/src/models/list-filter/performers.ts index c0bcb3bba..372dad342 100644 --- a/ui/v2.5/src/models/list-filter/performers.ts +++ b/ui/v2.5/src/models/list-filter/performers.ts @@ -32,7 +32,8 @@ const sortByOptions = [ "play_count", "last_played_at", "latest_scene", - "career_length", + "career_start", + "career_end", "weight", "measurements", "scenes_duration", @@ -75,6 +76,8 @@ const numberCriteria: CriterionType[] = [ "age", "weight", "penis_length", + "career_start", + "career_end", ]; const stringCriteria: CriterionType[] = [ @@ -86,7 +89,6 @@ const stringCriteria: CriterionType[] = [ "eye_color", "measurements", "fake_tits", - "career_length", "tattoos", "piercings", "aliases", diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 442099a53..7fe334c4c 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -166,6 +166,8 @@ export type CriterionType = | "penis_length" | "circumcised" | "career_length" + | "career_start" + | "career_end" | "tattoos" | "piercings" | "aliases" From e289199911d37f84703c7707662067d52877c3b1 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:50:32 +1100 Subject: [PATCH 069/177] Scene custom field backend support (#6584) * Add custom fields to scenes * Generalise set custom fields tests to other object types --- graphql/schema/types/filters.graphql | 2 + graphql/schema/types/scene.graphql | 8 + internal/api/loaders/dataloaders.go | 32 ++- internal/api/resolver_model_scene.go | 13 + internal/api/resolver_mutation_scene.go | 52 +++- internal/manager/repository.go | 2 +- pkg/models/jsonschema/scene.go | 2 + pkg/models/mocks/SceneReaderWriter.go | 60 +++++ pkg/models/model_scene.go | 14 + pkg/models/repository_scene.go | 2 + pkg/models/scene.go | 7 +- pkg/scene/create.go | 24 +- pkg/scene/export.go | 6 + pkg/scene/export_test.go | 45 +++- pkg/scene/import.go | 12 + pkg/scene/import_test.go | 100 +++++++ pkg/sqlite/anonymise.go | 4 + pkg/sqlite/custom_fields_test.go | 50 +++- pkg/sqlite/database.go | 2 +- .../migrations/79_scene_custom_fields.up.sql | 9 + pkg/sqlite/scene.go | 5 + pkg/sqlite/scene_filter.go | 7 + pkg/sqlite/scene_test.go | 247 ++++++++++++++++++ pkg/sqlite/setup_test.go | 16 ++ pkg/sqlite/tables.go | 1 + 25 files changed, 687 insertions(+), 35 deletions(-) create mode 100644 pkg/sqlite/migrations/79_scene_custom_fields.up.sql diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 81f91f22a..7633457ce 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -371,6 +371,8 @@ input SceneFilterType { markers_filter: SceneMarkerFilterType "Filter by related files that meet this criteria" files_filter: FileFilterType + + custom_fields: [CustomFieldCriterionInput!] } input MovieFilterType { diff --git a/graphql/schema/types/scene.graphql b/graphql/schema/types/scene.graphql index 5fba3819d..4d99e0a21 100644 --- a/graphql/schema/types/scene.graphql +++ b/graphql/schema/types/scene.graphql @@ -79,6 +79,8 @@ type Scene { performers: [Performer!]! stash_ids: [StashID!]! + custom_fields: Map! + "Return valid stream paths" sceneStreams: [SceneStreamEndpoint!]! } @@ -120,6 +122,8 @@ input SceneCreateInput { Files must not already be primary for another scene. """ file_ids: [ID!] + + custom_fields: Map } input SceneUpdateInput { @@ -158,6 +162,8 @@ input SceneUpdateInput { ) primary_file_id: ID + + custom_fields: CustomFieldsInput } enum BulkUpdateIdMode { @@ -190,6 +196,8 @@ input BulkSceneUpdateInput { tag_ids: BulkUpdateIds group_ids: BulkUpdateIds movie_ids: BulkUpdateIds @deprecated(reason: "Use group_ids") + + custom_fields: CustomFieldsInput } input SceneDestroyInput { diff --git a/internal/api/loaders/dataloaders.go b/internal/api/loaders/dataloaders.go index ecb0bbac2..520714432 100644 --- a/internal/api/loaders/dataloaders.go +++ b/internal/api/loaders/dataloaders.go @@ -42,13 +42,14 @@ const ( ) type Loaders struct { - SceneByID *SceneLoader - SceneFiles *SceneFileIDsLoader - ScenePlayCount *ScenePlayCountLoader - SceneOCount *SceneOCountLoader - ScenePlayHistory *ScenePlayHistoryLoader - SceneOHistory *SceneOHistoryLoader - SceneLastPlayed *SceneLastPlayedLoader + SceneByID *SceneLoader + SceneFiles *SceneFileIDsLoader + ScenePlayCount *ScenePlayCountLoader + SceneOCount *SceneOCountLoader + ScenePlayHistory *ScenePlayHistoryLoader + SceneOHistory *SceneOHistoryLoader + SceneLastPlayed *SceneLastPlayedLoader + SceneCustomFields *CustomFieldsLoader ImageFiles *ImageFileIDsLoader GalleryFiles *GalleryFileIDsLoader @@ -107,6 +108,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler { maxBatch: maxBatch, fetch: m.fetchStudioCustomFields(ctx), }, + SceneCustomFields: &CustomFieldsLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchSceneCustomFields(ctx), + }, StudioByID: &StudioLoader{ wait: wait, maxBatch: maxBatch, @@ -207,6 +213,18 @@ func (m Middleware) fetchScenes(ctx context.Context) func(keys []int) ([]*models } } +func (m Middleware) fetchSceneCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) { + return func(keys []int) (ret []models.CustomFieldMap, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Scene.GetCustomFieldsBulk(ctx, keys) + return err + }) + + return ret, toErrorSlice(err) + } +} + func (m Middleware) fetchImages(ctx context.Context) func(keys []int) ([]*models.Image, []error) { return func(keys []int) (ret []*models.Image, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { diff --git a/internal/api/resolver_model_scene.go b/internal/api/resolver_model_scene.go index 2600c9538..81113d858 100644 --- a/internal/api/resolver_model_scene.go +++ b/internal/api/resolver_model_scene.go @@ -410,3 +410,16 @@ func (r *sceneResolver) OHistory(ctx context.Context, obj *models.Scene) ([]*tim return ptrRet, nil } + +func (r *sceneResolver) CustomFields(ctx context.Context, obj *models.Scene) (map[string]interface{}, error) { + m, err := loaders.From(ctx).SceneCustomFields.Load(obj.ID) + if err != nil { + return nil, err + } + + if m == nil { + return make(map[string]interface{}), nil + } + + return m, nil +} diff --git a/internal/api/resolver_mutation_scene.go b/internal/api/resolver_mutation_scene.go index 5347de806..70158fc6f 100644 --- a/internal/api/resolver_mutation_scene.go +++ b/internal/api/resolver_mutation_scene.go @@ -103,8 +103,15 @@ func (r *mutationResolver) SceneCreate(ctx context.Context, input models.SceneCr } } + customFields := convertMapJSONNumbers(input.CustomFields) + if err := r.withTxn(ctx, func(ctx context.Context) error { - ret, err = r.Resolver.sceneService.Create(ctx, &newScene, fileIDs, coverImageData) + ret, err = r.Resolver.sceneService.Create(ctx, models.CreateSceneInput{ + Scene: &newScene, + FileIDs: fileIDs, + CoverImage: coverImageData, + CustomFields: customFields, + }) return err }); err != nil { return nil, err @@ -306,6 +313,15 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp } } + var customFields *models.CustomFieldsInput + if input.CustomFields != nil { + cfCopy := *input.CustomFields + customFields = &cfCopy + // convert json.Numbers to int/float + customFields.Full = convertMapJSONNumbers(customFields.Full) + customFields.Partial = convertMapJSONNumbers(customFields.Partial) + } + scene, err := qb.UpdatePartial(ctx, sceneID, *updatedScene) if err != nil { return nil, err @@ -317,6 +333,12 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp } } + if customFields != nil { + if err := qb.SetCustomFields(ctx, scene.ID, *customFields); err != nil { + return nil, err + } + } + return scene, nil } @@ -387,6 +409,12 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input BulkSceneU } } + var customFields *models.CustomFieldsInput + if input.CustomFields != nil { + cf := handleUpdateCustomFields(*input.CustomFields) + customFields = &cf + } + ret := []*models.Scene{} // Start the transaction and save the scenes @@ -399,6 +427,12 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input BulkSceneU return err } + if customFields != nil { + if err := qb.SetCustomFields(ctx, scene.ID, *customFields); err != nil { + return err + } + } + ret = append(ret, scene) } @@ -575,6 +609,7 @@ func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput var values *models.ScenePartial var coverImageData []byte + var customFields *models.CustomFieldsInput if input.Values != nil { translator := changesetTranslator{ @@ -593,6 +628,11 @@ func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput return nil, fmt.Errorf("processing cover image: %w", err) } } + + if input.Values.CustomFields != nil { + cf := handleUpdateCustomFields(*input.Values.CustomFields) + customFields = &cf + } } else { v := models.NewScenePartial() values = &v @@ -626,7 +666,15 @@ func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput // only update cover image if one was provided if len(coverImageData) > 0 { - return r.sceneUpdateCoverImage(ctx, ret, coverImageData) + if err := r.sceneUpdateCoverImage(ctx, ret, coverImageData); err != nil { + return err + } + } + + if customFields != nil { + if err := r.Resolver.repository.Scene.SetCustomFields(ctx, ret.ID, *customFields); err != nil { + return err + } } return nil diff --git a/internal/manager/repository.go b/internal/manager/repository.go index e51e737ee..afbf0b963 100644 --- a/internal/manager/repository.go +++ b/internal/manager/repository.go @@ -10,7 +10,7 @@ import ( ) type SceneService interface { - Create(ctx context.Context, input *models.Scene, fileIDs []models.FileID, coverImage []byte) (*models.Scene, error) + Create(ctx context.Context, input models.CreateSceneInput) (*models.Scene, error) AssignFile(ctx context.Context, sceneID int, fileID models.FileID) error Merge(ctx context.Context, sourceIDs []int, destinationID int, fileDeleter *scene.FileDeleter, options scene.MergeOptions) error Destroy(ctx context.Context, scene *models.Scene, fileDeleter *scene.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error diff --git a/pkg/models/jsonschema/scene.go b/pkg/models/jsonschema/scene.go index c2f266d5c..8f15b9c5d 100644 --- a/pkg/models/jsonschema/scene.go +++ b/pkg/models/jsonschema/scene.go @@ -80,6 +80,8 @@ type Scene struct { PlayDuration float64 `json:"play_duration,omitempty"` StashIDs []models.StashID `json:"stash_ids,omitempty"` + + CustomFields map[string]interface{} `json:"custom_fields,omitempty"` } func (s Scene) Filename(id int, basename string, hash string) string { diff --git a/pkg/models/mocks/SceneReaderWriter.go b/pkg/models/mocks/SceneReaderWriter.go index ef10c890d..0053ad6f8 100644 --- a/pkg/models/mocks/SceneReaderWriter.go +++ b/pkg/models/mocks/SceneReaderWriter.go @@ -754,6 +754,52 @@ func (_m *SceneReaderWriter) GetCover(ctx context.Context, sceneID int) ([]byte, return r0, r1 } +// GetCustomFields provides a mock function with given fields: ctx, id +func (_m *SceneReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) { + ret := _m.Called(ctx, id) + + var r0 map[string]interface{} + if rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]interface{}) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetCustomFieldsBulk provides a mock function with given fields: ctx, ids +func (_m *SceneReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) { + ret := _m.Called(ctx, ids) + + var r0 []models.CustomFieldMap + if rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok { + r0 = rf(ctx, ids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.CustomFieldMap) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { + r1 = rf(ctx, ids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetFiles provides a mock function with given fields: ctx, relatedID func (_m *SceneReaderWriter) GetFiles(ctx context.Context, relatedID int) ([]*models.VideoFile, error) { ret := _m.Called(ctx, relatedID) @@ -1332,6 +1378,20 @@ func (_m *SceneReaderWriter) SaveActivity(ctx context.Context, sceneID int, resu return r0, r1 } +// SetCustomFields provides a mock function with given fields: ctx, id, fields +func (_m *SceneReaderWriter) SetCustomFields(ctx context.Context, id int, fields models.CustomFieldsInput) error { + ret := _m.Called(ctx, id, fields) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, models.CustomFieldsInput) error); ok { + r0 = rf(ctx, id, fields) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Size provides a mock function with given fields: ctx func (_m *SceneReaderWriter) Size(ctx context.Context) (float64, error) { ret := _m.Called(ctx) diff --git a/pkg/models/model_scene.go b/pkg/models/model_scene.go index cf0499388..64ad34b9c 100644 --- a/pkg/models/model_scene.go +++ b/pkg/models/model_scene.go @@ -53,6 +53,20 @@ func NewScene() Scene { } } +type CreateSceneInput struct { + *Scene + + FileIDs []FileID + CoverImage []byte + CustomFields CustomFieldMap `json:"custom_fields"` +} + +type UpdateSceneInput struct { + *Scene + + CustomFields CustomFieldsInput `json:"custom_fields"` +} + // ScenePartial represents part of a Scene object. It is used to update // the database entry. type ScenePartial struct { diff --git a/pkg/models/repository_scene.go b/pkg/models/repository_scene.go index 8c2833470..6b795c3af 100644 --- a/pkg/models/repository_scene.go +++ b/pkg/models/repository_scene.go @@ -104,6 +104,7 @@ type SceneReader interface { SceneGroupLoader StashIDLoader VideoFileLoader + CustomFieldsReader All(ctx context.Context) ([]*Scene, error) Wall(ctx context.Context, q *string) ([]*Scene, error) @@ -140,6 +141,7 @@ type SceneWriter interface { ViewHistoryWriter SaveActivity(ctx context.Context, sceneID int, resumeTime *float64, playDuration *float64) (bool, error) ResetActivity(ctx context.Context, sceneID int, resetResume bool, resetDuration bool) (bool, error) + CustomFieldsWriter } // SceneReaderWriter provides all scene methods. diff --git a/pkg/models/scene.go b/pkg/models/scene.go index 22863c4d9..839452501 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -139,6 +139,9 @@ type SceneFilterType struct { CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at UpdatedAt *TimestampCriterionInput `json:"updated_at"` + + // Filter by custom fields + CustomFields []CustomFieldCriterionInput `json:"custom_fields"` } type SceneQueryOptions struct { @@ -192,7 +195,8 @@ type SceneCreateInput struct { // The first id will be assigned as primary. // Files will be reassigned from existing scenes if applicable. // Files must not already be primary for another scene. - FileIds []string `json:"file_ids"` + FileIds []string `json:"file_ids"` + CustomFields map[string]any `json:"custom_fields,omitempty"` } type SceneUpdateInput struct { @@ -221,6 +225,7 @@ type SceneUpdateInput struct { PlayDuration *float64 `json:"play_duration"` PlayCount *int `json:"play_count"` PrimaryFileID *string `json:"primary_file_id"` + CustomFields *CustomFieldsInput } type SceneDestroyInput struct { diff --git a/pkg/scene/create.go b/pkg/scene/create.go index cd9234b5d..248906295 100644 --- a/pkg/scene/create.go +++ b/pkg/scene/create.go @@ -10,14 +10,14 @@ import ( "github.com/stashapp/stash/pkg/plugin/hook" ) -func (s *Service) Create(ctx context.Context, input *models.Scene, fileIDs []models.FileID, coverImage []byte) (*models.Scene, error) { +func (s *Service) Create(ctx context.Context, input models.CreateSceneInput) (*models.Scene, error) { // title must be set if no files are provided - if input.Title == "" && len(fileIDs) == 0 { + if input.Scene.Title == "" && len(input.FileIDs) == 0 { return nil, errors.New("title must be set if scene has no files") } now := time.Now() - newScene := *input + newScene := *input.Scene newScene.CreatedAt = now newScene.UpdatedAt = now @@ -27,16 +27,24 @@ func (s *Service) Create(ctx context.Context, input *models.Scene, fileIDs []mod return nil, fmt.Errorf("creating new scene: %w", err) } - for _, f := range fileIDs { + if len(input.CustomFields) > 0 { + if err := s.Repository.SetCustomFields(ctx, newScene.ID, models.CustomFieldsInput{ + Full: input.CustomFields, + }); err != nil { + return nil, fmt.Errorf("setting custom fields on new scene: %w", err) + } + } + + for _, f := range input.FileIDs { if err := s.AssignFile(ctx, newScene.ID, f); err != nil { return nil, fmt.Errorf("assigning file %d to new scene: %w", f, err) } } - if len(fileIDs) > 0 { + if len(input.FileIDs) > 0 { // assign the primary to the first if _, err := s.Repository.UpdatePartial(ctx, newScene.ID, models.ScenePartial{ - PrimaryFileID: &fileIDs[0], + PrimaryFileID: &input.FileIDs[0], }); err != nil { return nil, fmt.Errorf("setting primary file on new scene: %w", err) } @@ -48,8 +56,8 @@ func (s *Service) Create(ctx context.Context, input *models.Scene, fileIDs []mod return nil, err } - if len(coverImage) > 0 { - if err := s.Repository.UpdateCover(ctx, ret.ID, coverImage); err != nil { + if len(input.CoverImage) > 0 { + if err := s.Repository.UpdateCover(ctx, ret.ID, input.CoverImage); err != nil { return nil, fmt.Errorf("setting cover on new scene: %w", err) } } diff --git a/pkg/scene/export.go b/pkg/scene/export.go index a012d1850..069bd587f 100644 --- a/pkg/scene/export.go +++ b/pkg/scene/export.go @@ -17,6 +17,7 @@ import ( type ExportGetter interface { models.ViewDateReader models.ODateReader + models.CustomFieldsReader GetCover(ctx context.Context, sceneID int) ([]byte, error) } @@ -92,6 +93,11 @@ func ToBasicJSON(ctx context.Context, reader ExportGetter, scene *models.Scene) newSceneJSON.OHistory = append(newSceneJSON.OHistory, json.JSONTime{Time: date}) } + newSceneJSON.CustomFields, err = reader.GetCustomFields(ctx, scene.ID) + if err != nil { + return nil, fmt.Errorf("getting scene custom fields: %v", err) + } + return &newSceneJSON, nil } diff --git a/pkg/scene/export_test.go b/pkg/scene/export_test.go index cde421bd8..9547ab5e7 100644 --- a/pkg/scene/export_test.go +++ b/pkg/scene/export_test.go @@ -22,6 +22,7 @@ const ( studioID = 4 missingStudioID = 5 errStudioID = 6 + customFieldsID = 7 noTagsID = 11 errTagsID = 12 @@ -33,6 +34,7 @@ const ( errMarkersID = 17 errFindPrimaryTagID = 18 errFindByMarkerID = 19 + errCustomFieldsID = 20 ) var ( @@ -82,6 +84,13 @@ var ( updateTime = time.Date(2002, 01, 01, 0, 0, 0, 0, time.UTC) ) +var ( + emptyCustomFields = make(map[string]interface{}) + customFields = map[string]interface{}{ + "customField1": "customValue1", + } +) + func createFullScene(id int) models.Scene { return models.Scene{ ID: id, @@ -123,7 +132,7 @@ func createEmptyScene(id int) models.Scene { } } -func createFullJSONScene(image string) *jsonschema.Scene { +func createFullJSONScene(image string, customFields map[string]interface{}) *jsonschema.Scene { return &jsonschema.Scene{ Title: title, Files: []string{path}, @@ -142,6 +151,7 @@ func createFullJSONScene(image string) *jsonschema.Scene { StashIDs: []models.StashID{ stashID, }, + CustomFields: customFields, } } @@ -155,32 +165,49 @@ func createEmptyJSONScene() *jsonschema.Scene { UpdatedAt: json.JSONTime{ Time: updateTime, }, + CustomFields: emptyCustomFields, } } type basicTestScenario struct { - input models.Scene - expected *jsonschema.Scene - err bool + input models.Scene + customFields map[string]interface{} + expected *jsonschema.Scene + err bool } var scenarios = []basicTestScenario{ { createFullScene(sceneID), - createFullJSONScene(imageBase64), + emptyCustomFields, + createFullJSONScene(imageBase64, emptyCustomFields), + false, + }, + { + createFullScene(customFieldsID), + customFields, + createFullJSONScene("", customFields), false, }, { createEmptyScene(noImageID), + emptyCustomFields, createEmptyJSONScene(), false, }, { createFullScene(errImageID), - createFullJSONScene(""), + emptyCustomFields, + createFullJSONScene("", emptyCustomFields), // failure to get image should not cause an error false, }, + { + createFullScene(errCustomFieldsID), + customFields, + createFullJSONScene("", customFields), + true, + }, } func TestToJSON(t *testing.T) { @@ -191,8 +218,12 @@ func TestToJSON(t *testing.T) { db.Scene.On("GetCover", testCtx, sceneID).Return(imageBytes, nil).Once() db.Scene.On("GetCover", testCtx, noImageID).Return(nil, nil).Once() db.Scene.On("GetCover", testCtx, errImageID).Return(nil, imageErr).Once() + db.Scene.On("GetCover", testCtx, mock.Anything).Return(nil, nil) db.Scene.On("GetViewDates", testCtx, mock.Anything).Return(nil, nil) db.Scene.On("GetODates", testCtx, mock.Anything).Return(nil, nil) + db.Scene.On("GetCustomFields", testCtx, customFieldsID).Return(customFields, nil).Once() + db.Scene.On("GetCustomFields", testCtx, errCustomFieldsID).Return(nil, errors.New("error getting custom fields")).Once() + db.Scene.On("GetCustomFields", testCtx, mock.Anything).Return(emptyCustomFields, nil) for i, s := range scenarios { scene := s.input @@ -203,6 +234,8 @@ func TestToJSON(t *testing.T) { t.Errorf("[%d] unexpected error: %s", i, err.Error()) case s.err && err == nil: t.Errorf("[%d] expected error not returned", i) + case err != nil: + // error case already handled, no need for assertion default: assert.Equal(t, s.expected, json, "[%d]", i) } diff --git a/pkg/scene/import.go b/pkg/scene/import.go index 58604e1a5..24dbf1cc0 100644 --- a/pkg/scene/import.go +++ b/pkg/scene/import.go @@ -18,6 +18,7 @@ type ImporterReaderWriter interface { models.SceneCreatorUpdater models.ViewHistoryWriter models.OHistoryWriter + models.CustomFieldsWriter FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Scene, error) } @@ -35,6 +36,7 @@ type Importer struct { ID int scene models.Scene + customFields map[string]interface{} coverImageData []byte viewHistory []time.Time oHistory []time.Time @@ -75,6 +77,8 @@ func (i *Importer) PreImport(ctx context.Context) error { } } + i.customFields = i.Input.CustomFields + i.populateViewHistory() i.populateOHistory() @@ -449,6 +453,14 @@ func (i *Importer) PostImport(ctx context.Context, id int) error { return err } + if len(i.customFields) > 0 { + if err := i.ReaderWriter.SetCustomFields(ctx, id, models.CustomFieldsInput{ + Full: i.customFields, + }); err != nil { + return fmt.Errorf("error setting scene custom fields: %v", err) + } + } + return nil } diff --git a/pkg/scene/import_test.go b/pkg/scene/import_test.go index 4936ec2bb..98924e20d 100644 --- a/pkg/scene/import_test.go +++ b/pkg/scene/import_test.go @@ -549,3 +549,103 @@ func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) { db.AssertExpectations(t) } + +func TestImporterPostImport(t *testing.T) { + db := mocks.NewDatabase() + + vt := time.Now() + ot := vt.Add(time.Minute) + + var ( + okID = 1 + errViewHistoryID = 2 + errOHistoryID = 3 + errImageID = 4 + errCustomFieldsID = 5 + ) + + var ( + errImage = errors.New("error updating cover image") + errViewHistory = errors.New("error updating view history") + errOHistory = errors.New("error updating o history") + errCustomFields = errors.New("error updating custom fields") + ) + + table := []struct { + name string + importer Importer + err bool + }{ + { + name: "all set successfully", + importer: Importer{ + ID: okID, + coverImageData: []byte(imageBase64), + viewHistory: []time.Time{vt}, + oHistory: []time.Time{ot}, + customFields: customFields, + }, + err: false, + }, + { + name: "cover image set with error", + importer: Importer{ + ID: errImageID, + coverImageData: []byte(invalidImage), + }, + err: true, + }, + { + name: "view history set with error", + importer: Importer{ + ID: errViewHistoryID, + viewHistory: []time.Time{vt}, + }, + err: true, + }, + { + name: "o history set with error", + importer: Importer{ + ID: errOHistoryID, + oHistory: []time.Time{ot}, + }, + err: true, + }, + { + name: "custom fields set with error", + importer: Importer{ + ID: errCustomFieldsID, + customFields: customFields, + }, + err: true, + }, + } + + db.Scene.On("UpdateCover", testCtx, okID, []byte(imageBase64)).Return(nil).Once() + db.Scene.On("UpdateCover", testCtx, errImageID, []byte(invalidImage)).Return(errImage).Once() + db.Scene.On("AddViews", testCtx, okID, []time.Time{vt}).Return([]time.Time{vt}, nil).Once() + db.Scene.On("AddViews", testCtx, errViewHistoryID, []time.Time{vt}).Return(nil, errViewHistory).Once() + db.Scene.On("AddO", testCtx, okID, []time.Time{ot}).Return([]time.Time{ot}, nil).Once() + db.Scene.On("AddO", testCtx, errOHistoryID, []time.Time{ot}).Return(nil, errOHistory).Once() + db.Scene.On("SetCustomFields", testCtx, okID, models.CustomFieldsInput{ + Full: customFields, + }).Return(nil).Once() + db.Scene.On("SetCustomFields", testCtx, errCustomFieldsID, models.CustomFieldsInput{ + Full: customFields, + }).Return(errCustomFields).Once() + + for _, tt := range table { + t.Run(tt.name, func(t *testing.T) { + i := tt.importer + i.ReaderWriter = db.Scene + + err := i.PostImport(testCtx, i.ID) + + if tt.err { + assert.NotNil(t, err, "expected error but got nil") + } else { + assert.Nil(t, err, "unexpected error: %v", err) + } + }) + } +} diff --git a/pkg/sqlite/anonymise.go b/pkg/sqlite/anonymise.go index e3b7492cc..e0a354980 100644 --- a/pkg/sqlite/anonymise.go +++ b/pkg/sqlite/anonymise.go @@ -332,6 +332,10 @@ func (db *Anonymiser) anonymiseScenes(ctx context.Context) error { return err } + if err := db.anonymiseCustomFields(ctx, goqu.T(scenesCustomFieldsTable.GetTable()), "scene_id"); err != nil { + return err + } + return nil } diff --git a/pkg/sqlite/custom_fields_test.go b/pkg/sqlite/custom_fields_test.go index 8ee154aec..a2c045851 100644 --- a/pkg/sqlite/custom_fields_test.go +++ b/pkg/sqlite/custom_fields_test.go @@ -11,11 +11,23 @@ import ( "github.com/stretchr/testify/assert" ) -func TestSetCustomFields(t *testing.T) { - performerIdx := performerIdx1WithScene +type customFieldsReaderWriter interface { + models.CustomFieldsReader + models.CustomFieldsWriter +} + +func testSetCustomFields(t *testing.T, namePrefix string, store customFieldsReaderWriter, id int, origCustomFields map[string]interface{}) { + getCustomFields := func() map[string]interface{} { + m := make(map[string]interface{}) + for k, v := range origCustomFields { + m[k] = v + } + return m + } mergeCustomFields := func(i map[string]interface{}) map[string]interface{} { - m := getPerformerCustomFields(performerIdx) + m := getCustomFields() + for k, v := range i { m[k] = v } @@ -70,7 +82,7 @@ func TestSetCustomFields(t *testing.T) { Remove: []string{"real"}, }, func() map[string]interface{} { - m := getPerformerCustomFields(performerIdx) + m := getCustomFields() delete(m, "real") return m }(), @@ -180,12 +192,8 @@ func TestSetCustomFields(t *testing.T) { }, } - // use performer custom fields store - store := db.Performer - id := performerIDs[performerIdx] - for _, tt := range tests { - runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + runWithRollbackTxn(t, namePrefix+" "+tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) err := store.SetCustomFields(ctx, id, tt.input) @@ -208,3 +216,27 @@ func TestSetCustomFields(t *testing.T) { }) } } + +func TestPerformerSetCustomFields(t *testing.T) { + performerIdx := performerIdx1WithScene + + testSetCustomFields(t, "Performer", db.Performer, performerIDs[performerIdx], getPerformerCustomFields(performerIdx)) +} + +func TestTagSetCustomFields(t *testing.T) { + tagIdx := tagIdx1WithScene + + testSetCustomFields(t, "Tag", db.Tag, tagIDs[tagIdx], getTagCustomFields(tagIdx)) +} + +func TestStudioSetCustomFields(t *testing.T) { + studioIdx := studioIdxWithScene + + testSetCustomFields(t, "Studio", db.Studio, studioIDs[studioIdx], getStudioCustomFields(studioIdx)) +} + +func TestSceneSetCustomFields(t *testing.T) { + sceneIdx := sceneIdxWithPerformer + + testSetCustomFields(t, "Scene", db.Scene, sceneIDs[sceneIdx], getSceneCustomFields(sceneIdx)) +} diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 197602ecd..4a950b724 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 78 +var appSchemaVersion uint = 79 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/79_scene_custom_fields.up.sql b/pkg/sqlite/migrations/79_scene_custom_fields.up.sql new file mode 100644 index 000000000..a56b34e3a --- /dev/null +++ b/pkg/sqlite/migrations/79_scene_custom_fields.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE `scene_custom_fields` ( + `scene_id` integer NOT NULL, + `field` varchar(64) NOT NULL, + `value` BLOB NOT NULL, + PRIMARY KEY (`scene_id`, `field`), + foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE +); + +CREATE INDEX `index_scene_custom_fields_field_value` ON `scene_custom_fields` (`field`, `value`); \ No newline at end of file diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index d92800317..3049681b2 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -234,6 +234,7 @@ var ( type SceneStore struct { blobJoinQueryBuilder + customFieldsStore tableMgr *table oDateManager @@ -248,6 +249,10 @@ func NewSceneStore(r *storeRepository, blobStore *BlobStore) *SceneStore { blobStore: blobStore, joinTable: sceneTable, }, + customFieldsStore: customFieldsStore{ + table: scenesCustomFieldsTable, + fk: scenesCustomFieldsTable.Col(sceneIDColumn), + }, tableMgr: sceneTableMgr, viewDateManager: viewDateManager{scenesViewTableMgr}, diff --git a/pkg/sqlite/scene_filter.go b/pkg/sqlite/scene_filter.go index e42376950..a9eb6b0ae 100644 --- a/pkg/sqlite/scene_filter.go +++ b/pkg/sqlite/scene_filter.go @@ -179,6 +179,13 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler { ×tampCriterionHandler{sceneFilter.CreatedAt, "scenes.created_at", nil}, ×tampCriterionHandler{sceneFilter.UpdatedAt, "scenes.updated_at", nil}, + &customFieldsFilterHandler{ + table: scenesCustomFieldsTable.GetTable(), + fkCol: sceneIDColumn, + c: sceneFilter.CustomFields, + idCol: "scenes.id", + }, + &relatedFilterHandler{ relatedIDCol: "scenes_galleries.gallery_id", relatedRepo: galleryRepository.repository, diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index 6cdb62a5e..d386175c7 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -4826,6 +4826,253 @@ func TestSceneStore_SaveActivity(t *testing.T) { } } +func TestSceneQueryCustomFields(t *testing.T) { + tests := []struct { + name string + filter *models.SceneFilterType + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "equals", + &models.SceneFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierEquals, + Value: []any{getSceneStringValue(sceneIdxWithGallery, "custom")}, + }, + }, + }, + []int{sceneIdxWithGallery}, + nil, + false, + }, + { + "not equals", + &models.SceneFilterType{ + Title: &models.StringCriterionInput{ + Value: getSceneTitle(sceneIdxWithGallery), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotEquals, + Value: []any{getSceneStringValue(sceneIdxWithGallery, "custom")}, + }, + }, + }, + nil, + []int{sceneIdxWithGallery}, + false, + }, + { + "includes", + &models.SceneFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierIncludes, + Value: []any{getSceneStringValue(sceneIdxWithGallery, "custom")[9:]}, + }, + }, + }, + []int{sceneIdxWithGallery}, + nil, + false, + }, + { + "excludes", + &models.SceneFilterType{ + Title: &models.StringCriterionInput{ + Value: getSceneTitle(sceneIdxWithGallery), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierExcludes, + Value: []any{getSceneStringValue(sceneIdxWithGallery, "custom")[9:]}, + }, + }, + }, + nil, + []int{sceneIdxWithGallery}, + false, + }, + { + "regex", + &models.SceneFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierMatchesRegex, + Value: []any{".*17_custom"}, + }, + }, + }, + []int{sceneIdxWithTwoPerformerTag}, + nil, + false, + }, + { + "invalid regex", + &models.SceneFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierMatchesRegex, + Value: []any{"["}, + }, + }, + }, + nil, + nil, + true, + }, + { + "not matches regex", + &models.SceneFilterType{ + Title: &models.StringCriterionInput{ + Value: getSceneTitle(sceneIdxWithTwoPerformerTag), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotMatchesRegex, + Value: []any{".*17_custom"}, + }, + }, + }, + nil, + []int{sceneIdxWithTwoPerformerTag}, + false, + }, + { + "invalid not matches regex", + &models.SceneFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotMatchesRegex, + Value: []any{"["}, + }, + }, + }, + nil, + nil, + true, + }, + { + "null", + &models.SceneFilterType{ + Title: &models.StringCriterionInput{ + Value: getSceneTitle(sceneIdxWithGallery), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "not existing", + Modifier: models.CriterionModifierIsNull, + }, + }, + }, + []int{sceneIdxWithGallery}, + nil, + false, + }, + { + "not null", + &models.SceneFilterType{ + Title: &models.StringCriterionInput{ + Value: getSceneTitle(sceneIdxWithGallery), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotNull, + }, + }, + }, + []int{sceneIdxWithGallery}, + nil, + false, + }, + { + "between", + &models.SceneFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "real", + Modifier: models.CriterionModifierBetween, + Value: []any{0.15, 0.25}, + }, + }, + }, + []int{sceneIdxWithPerformer}, + nil, + false, + }, + { + "not between", + &models.SceneFilterType{ + Title: &models.StringCriterionInput{ + Value: getSceneTitle(sceneIdxWithPerformer), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "real", + Modifier: models.CriterionModifierNotBetween, + Value: []any{0.15, 0.25}, + }, + }, + }, + nil, + []int{sceneIdxWithPerformer}, + false, + }, + } + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + result, err := db.Scene.Query(ctx, models.SceneQueryOptions{ + SceneFilter: tt.filter, + }) + if (err != nil) != tt.wantErr { + t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr) + } + + if err != nil { + return + } + + scenes, err := result.Resolve(ctx) + if err != nil { + t.Errorf("SceneStore.Query().Resolve() error = %v", err) + return + } + + ids := scenesToIDs(scenes) + include := indexesToIDs(sceneIDs, tt.includeIdxs) + exclude := indexesToIDs(sceneIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } +} + // TODO Count // TODO SizeCount diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index ffa60457e..91f9f127b 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1187,6 +1187,18 @@ func makeScene(i int) *models.Scene { } } +func getSceneCustomFields(index int) map[string]interface{} { + if index%5 == 0 { + return nil + } + + return map[string]interface{}{ + "string": getSceneStringValue(index, "custom"), + "int": int64(index % 5), + "real": float64(index) / 10, + } +} + func createScenes(ctx context.Context, n int) error { sqb := db.Scene fqb := db.File @@ -1204,6 +1216,10 @@ func createScenes(ctx context.Context, n int) error { return fmt.Errorf("Error creating scene %v+: %s", scene, err.Error()) } + if err := sqb.SetCustomFields(ctx, scene.ID, models.CustomFieldsInput{Full: getSceneCustomFields(i)}); err != nil { + return fmt.Errorf("Error setting custom fields for scene %d: %s", scene.ID, err.Error()) + } + sceneIDs = append(sceneIDs, scene.ID) } diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index f46190a30..53e62b166 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -27,6 +27,7 @@ var ( scenesStashIDsJoinTable = goqu.T("scene_stash_ids") scenesGroupsJoinTable = goqu.T(groupsScenesTable) scenesURLsJoinTable = goqu.T(scenesURLsTable) + scenesCustomFieldsTable = goqu.T("scene_custom_fields") sceneMarkersTagsJoinTable = goqu.T(sceneMarkersTagsTable) From 0164d7ad3132a050df3cf726d03b903c70ec9155 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:30:52 +1100 Subject: [PATCH 070/177] Fix marker form start time not being set when abLoop disabled --- ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx index cbb2ad4bb..a2bad2f8e 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx @@ -72,7 +72,7 @@ export const SceneMarkerForm: React.FC = ({ const end = opts?.end; const hasAbLoop = Number.isFinite(start); - if (hasAbLoop) { + if (opts?.enabled && hasAbLoop) { const current = Math.round(getPlayerPosition() ?? 0); const rawEnd = Number.isFinite(end) && (end as number) > 0 ? (end as number) : null; From b653e91fae2c3850e8c6e5790aabf5821d9ac28a Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 19 Feb 2026 08:09:06 +1100 Subject: [PATCH 071/177] Fix panic in IsFsPathCaseSensitive (#6589) * Add crashing unit test * Fix IsFsPathCaseSensitive to use runes --- pkg/fsutil/fs.go | 6 +++--- pkg/fsutil/fs_test.go | 44 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 pkg/fsutil/fs_test.go diff --git a/pkg/fsutil/fs.go b/pkg/fsutil/fs.go index 2b5c37f62..10666bb63 100644 --- a/pkg/fsutil/fs.go +++ b/pkg/fsutil/fs.go @@ -32,8 +32,8 @@ func IsFsPathCaseSensitive(path string) (bool, error) { return false, fmt.Errorf("could not case flip path %s", path) } - flipped := []byte(path) - for _, c := range []byte(fBase) { // replace base of path with the flipped one ( we need to flip the base or last dir part ) + flipped := []rune(path) + for _, c := range fBase { // replace base of path with the flipped one ( we need to flip the base or last dir part ) flipped[i] = c i++ } @@ -43,7 +43,7 @@ func IsFsPathCaseSensitive(path string) (bool, error) { return true, nil // fs of path should be case sensitive } - if fiCase.ModTime() == fi.ModTime() { // file path exists and is the same + if fiCase.ModTime().Equal(fi.ModTime()) { // file path exists and is the same return false, nil // fs of path is not case sensitive } return false, fmt.Errorf("can not determine case sensitivity of path %s", path) diff --git a/pkg/fsutil/fs_test.go b/pkg/fsutil/fs_test.go new file mode 100644 index 000000000..522e95fa6 --- /dev/null +++ b/pkg/fsutil/fs_test.go @@ -0,0 +1,44 @@ +package fsutil + +import ( + "os" + "path/filepath" + "testing" +) + +func TestIsFsPathCaseSensitive_UnicodeByteLength(t *testing.T) { + // Ⱥ (U+023A) is 2 bytes in UTF-8 + // Its lowercase ⱥ (U+2C65) is 3 bytes in UTF-8 + + dir := t.TempDir() + makeDir := func(path string) { + // Create the directory so os.Stat succeeds + if err := os.Mkdir(path, 0755); err != nil { + t.Fatal(err) + } + } + + path := filepath.Join(dir, "Ⱥtest") + makeDir(path) + + // ensure the test does not panic due to byte length differences in the case flipped path + _, err := IsFsPathCaseSensitive(path) + if err != nil { + t.Fatal(err) + } + + // no guarantee about case sensitivity of the fs running the tests, + // so we just want to ensure the function works and does not panic + // assert.True(t, r, "expected fs to be case sensitive") + + // test regular ASCII paths still work + path2 := filepath.Join(dir, "Test") + makeDir(path2) + + _, err = IsFsPathCaseSensitive(path2) + if err != nil { + t.Fatal(err) + } + + // assert.True(t, r, "expected fs to be case sensitive") +} From 8bc4107e54906d811358ed8026bbb1349e931571 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 19 Feb 2026 08:09:58 +1100 Subject: [PATCH 072/177] Skip directory after deleting it during generated files clean (#6590) --- internal/manager/task/clean_generated.go | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/internal/manager/task/clean_generated.go b/internal/manager/task/clean_generated.go index 902989046..a59bda6d1 100644 --- a/internal/manager/task/clean_generated.go +++ b/internal/manager/task/clean_generated.go @@ -565,6 +565,7 @@ func (j *CleanGeneratedJob) cleanMarkerFiles(ctx context.Context, progress *job. j.setProgressFromFilename(sceneHash[0:2], progress) // check if the scene exists + var walkErr error if err := j.Repository.WithReadTxn(ctx, func(ctx context.Context) error { var err error scenes, err = j.getScenesWithHash(ctx, sceneHash) @@ -575,15 +576,18 @@ func (j *CleanGeneratedJob) cleanMarkerFiles(ctx context.Context, progress *job. if len(scenes) == 0 { j.logDelete("deleting unused marker directory: %s", sceneHash) j.deleteDir(path) - } else { - // get the markers now - for _, scene := range scenes { - thisMarkers, err := j.Repository.SceneMarker.FindBySceneID(ctx, scene.ID) - if err != nil { - return fmt.Errorf("error getting markers for scene: %v", err) - } - markers = append(markers, thisMarkers...) + // #5911 - we've just deleted the directory, so skip it in the walk to avoid errors + walkErr = fs.SkipDir + return nil + } + + // get the markers now + for _, scene := range scenes { + thisMarkers, err := j.Repository.SceneMarker.FindBySceneID(ctx, scene.ID) + if err != nil { + return fmt.Errorf("error getting markers for scene: %v", err) } + markers = append(markers, thisMarkers...) } return nil @@ -591,7 +595,7 @@ func (j *CleanGeneratedJob) cleanMarkerFiles(ctx context.Context, progress *job. logger.Error(err.Error()) } - return nil + return walkErr } filename := info.Name() From 3dc86239d2c7ff9425f00d5aa2ec8aac2acda79b Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:05:17 -0600 Subject: [PATCH 073/177] Feature Request: Add organized flag to studios (#6303) --- graphql/schema/types/filters.graphql | 2 ++ graphql/schema/types/studio.graphql | 4 +++ internal/api/resolver_mutation_studio.go | 3 ++ internal/manager/task_stash_box_tag.go | 6 ++++ pkg/models/jsonschema/studio.go | 1 + pkg/models/model_studio.go | 2 ++ pkg/models/studio.go | 4 +++ pkg/sqlite/database.go | 2 +- .../migrations/80_studio_organized.up.sql | 1 + pkg/sqlite/studio.go | 4 +++ pkg/sqlite/studio_filter.go | 1 + pkg/sqlite/studio_test.go | 7 +++++ pkg/studio/export.go | 1 + pkg/studio/export_test.go | 3 ++ pkg/studio/import.go | 1 + pkg/studio/import_test.go | 1 + ui/v2.5/graphql/data/studio-slim.graphql | 3 ++ ui/v2.5/graphql/data/studio.graphql | 1 + .../components/Studios/EditStudiosDialog.tsx | 16 +++++++++- ui/v2.5/src/components/Studios/StudioCard.tsx | 29 +++++++++++++++++-- .../Studios/StudioDetails/Studio.tsx | 28 ++++++++++++++++++ ui/v2.5/src/docs/en/Manual/AutoTagging.md | 2 ++ ui/v2.5/src/models/list-filter/studios.ts | 1 + 23 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 pkg/sqlite/migrations/80_studio_organized.up.sql diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 7633457ce..c0b47f7cf 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -502,6 +502,8 @@ input StudioFilterType { child_count: IntCriterionInput "Filter by autotag ignore value" ignore_auto_tag: Boolean + "Filter by organized" + organized: Boolean "Filter by related scenes that meet this criteria" scenes_filter: SceneFilterType "Filter by related images that meet this criteria" diff --git a/graphql/schema/types/studio.graphql b/graphql/schema/types/studio.graphql index 3e991ce96..51a87bf4f 100644 --- a/graphql/schema/types/studio.graphql +++ b/graphql/schema/types/studio.graphql @@ -8,6 +8,7 @@ type Studio { aliases: [String!]! tags: [Tag!]! ignore_auto_tag: Boolean! + organized: Boolean! image_path: String # Resolver scene_count(depth: Int): Int! # Resolver @@ -46,6 +47,7 @@ input StudioCreateInput { aliases: [String!] tag_ids: [ID!] ignore_auto_tag: Boolean + organized: Boolean custom_fields: Map } @@ -67,6 +69,7 @@ input StudioUpdateInput { aliases: [String!] tag_ids: [ID!] ignore_auto_tag: Boolean + organized: Boolean custom_fields: CustomFieldsInput } @@ -82,6 +85,7 @@ input BulkStudioUpdateInput { details: String tag_ids: BulkUpdateIds ignore_auto_tag: Boolean + organized: Boolean } input StudioDestroyInput { diff --git a/internal/api/resolver_mutation_studio.go b/internal/api/resolver_mutation_studio.go index e3e1c6395..c7af918a1 100644 --- a/internal/api/resolver_mutation_studio.go +++ b/internal/api/resolver_mutation_studio.go @@ -38,6 +38,7 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio newStudio.Favorite = translator.bool(input.Favorite) newStudio.Details = translator.string(input.Details) newStudio.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag) + newStudio.Organized = translator.bool(input.Organized) newStudio.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.Aliases), newStudio.Name)) newStudio.StashIDs = models.NewRelatedStashIDs(models.StashIDInputs(input.StashIds).ToStashIDs()) @@ -120,6 +121,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio updatedStudio.Rating = translator.optionalInt(input.Rating100, "rating100") updatedStudio.Favorite = translator.optionalBool(input.Favorite, "favorite") updatedStudio.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") + updatedStudio.Organized = translator.optionalBool(input.Organized, "organized") updatedStudio.Aliases = translator.updateStrings(input.Aliases, "aliases") updatedStudio.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids") @@ -261,6 +263,7 @@ func (r *mutationResolver) BulkStudioUpdate(ctx context.Context, input BulkStudi partial.Rating = translator.optionalInt(input.Rating100, "rating100") partial.Details = translator.optionalString(input.Details, "details") partial.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") + partial.Organized = translator.optionalBool(input.Organized, "organized") partial.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids") if err != nil { diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index 37859ba61..4848b46ad 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -275,6 +275,12 @@ func (t *stashBoxBatchStudioTagTask) getName() string { } func (t *stashBoxBatchStudioTagTask) Start(ctx context.Context) { + // Skip organized studios + if t.studio != nil && t.studio.Organized { + logger.Infof("Skipping organized studio %s", t.studio.Name) + return + } + studio, err := t.findStashBoxStudio(ctx) if err != nil { logger.Errorf("Error fetching studio data from stash-box: %v", err) diff --git a/pkg/models/jsonschema/studio.go b/pkg/models/jsonschema/studio.go index 7684b4317..12a797c13 100644 --- a/pkg/models/jsonschema/studio.go +++ b/pkg/models/jsonschema/studio.go @@ -24,6 +24,7 @@ type Studio struct { StashIDs []models.StashID `json:"stash_ids,omitempty"` Tags []string `json:"tags,omitempty"` IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"` + Organized bool `json:"organized,omitempty"` CustomFields map[string]interface{} `json:"custom_fields,omitempty"` diff --git a/pkg/models/model_studio.go b/pkg/models/model_studio.go index ee6fae2d2..ec81aac0e 100644 --- a/pkg/models/model_studio.go +++ b/pkg/models/model_studio.go @@ -16,6 +16,7 @@ type Studio struct { Favorite bool `json:"favorite"` Details string `json:"details"` IgnoreAutoTag bool `json:"ignore_auto_tag"` + Organized bool `json:"organized"` Aliases RelatedStrings `json:"aliases"` URLs RelatedStrings `json:"urls"` @@ -62,6 +63,7 @@ type StudioPartial struct { CreatedAt OptionalTime UpdatedAt OptionalTime IgnoreAutoTag OptionalBool + Organized OptionalBool Aliases *UpdateStrings URLs *UpdateStrings diff --git a/pkg/models/studio.go b/pkg/models/studio.go index 5d1def1bc..7ad8719ac 100644 --- a/pkg/models/studio.go +++ b/pkg/models/studio.go @@ -38,6 +38,8 @@ type StudioFilterType struct { ChildCount *IntCriterionInput `json:"child_count"` // Filter by autotag ignore value IgnoreAutoTag *bool `json:"ignore_auto_tag"` + // Filter by organized + Organized *bool `json:"organized"` // Filter by related scenes that meet this criteria ScenesFilter *SceneFilterType `json:"scenes_filter"` // Filter by related images that meet this criteria @@ -69,6 +71,7 @@ type StudioCreateInput struct { Aliases []string `json:"aliases"` TagIds []string `json:"tag_ids"` IgnoreAutoTag *bool `json:"ignore_auto_tag"` + Organized *bool `json:"organized"` CustomFields map[string]interface{} `json:"custom_fields"` } @@ -88,6 +91,7 @@ type StudioUpdateInput struct { Aliases []string `json:"aliases"` TagIds []string `json:"tag_ids"` IgnoreAutoTag *bool `json:"ignore_auto_tag"` + Organized *bool `json:"organized"` CustomFields CustomFieldsInput `json:"custom_fields"` } diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 4a950b724..5b67e5602 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 79 +var appSchemaVersion uint = 80 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/80_studio_organized.up.sql b/pkg/sqlite/migrations/80_studio_organized.up.sql new file mode 100644 index 000000000..3aa9c4656 --- /dev/null +++ b/pkg/sqlite/migrations/80_studio_organized.up.sql @@ -0,0 +1 @@ +ALTER TABLE `studios` ADD COLUMN `organized` boolean not null default '0'; \ No newline at end of file diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index 949929c8d..a866a94ab 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -44,6 +44,7 @@ type studioRow struct { Favorite bool `db:"favorite"` Details zero.String `db:"details"` IgnoreAutoTag bool `db:"ignore_auto_tag"` + Organized bool `db:"organized"` // not used in resolutions or updates ImageBlob zero.String `db:"image_blob"` @@ -59,6 +60,7 @@ func (r *studioRow) fromStudio(o models.Studio) { r.Favorite = o.Favorite r.Details = zero.StringFrom(o.Details) r.IgnoreAutoTag = o.IgnoreAutoTag + r.Organized = o.Organized } func (r *studioRow) resolve() *models.Studio { @@ -72,6 +74,7 @@ func (r *studioRow) resolve() *models.Studio { Favorite: r.Favorite, Details: r.Details.String, IgnoreAutoTag: r.IgnoreAutoTag, + Organized: r.Organized, } return ret @@ -90,6 +93,7 @@ func (r *studioRowRecord) fromPartial(o models.StudioPartial) { r.setBool("favorite", o.Favorite) r.setNullString("details", o.Details) r.setBool("ignore_auto_tag", o.IgnoreAutoTag) + r.setBool("organized", o.Organized) } type studioRepositoryType struct { diff --git a/pkg/sqlite/studio_filter.go b/pkg/sqlite/studio_filter.go index 889bd4c74..cfe3c59b6 100644 --- a/pkg/sqlite/studio_filter.go +++ b/pkg/sqlite/studio_filter.go @@ -59,6 +59,7 @@ func (qb *studioFilterHandler) criterionHandler() criterionHandler { intCriterionHandler(studioFilter.Rating100, studioTable+".rating", nil), boolCriterionHandler(studioFilter.Favorite, studioTable+".favorite", nil), boolCriterionHandler(studioFilter.IgnoreAutoTag, studioTable+".ignore_auto_tag", nil), + boolCriterionHandler(studioFilter.Organized, studioTable+".organized", nil), criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if studioFilter.StashID != nil { diff --git a/pkg/sqlite/studio_test.go b/pkg/sqlite/studio_test.go index 968f43413..eebc677c3 100644 --- a/pkg/sqlite/studio_test.go +++ b/pkg/sqlite/studio_test.go @@ -81,6 +81,7 @@ func Test_StudioStore_Create(t *testing.T) { rating = 3 aliases = []string{"alias1", "alias2"} ignoreAutoTag = true + organized = true favorite = true endpoint1 = "endpoint1" endpoint2 = "endpoint2" @@ -105,6 +106,7 @@ func Test_StudioStore_Create(t *testing.T) { Rating: &rating, Details: details, IgnoreAutoTag: ignoreAutoTag, + Organized: organized, TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithStudio], tagIDs[tagIdx1WithDupName]}), Aliases: models.NewRelatedStrings(aliases), StashIDs: models.NewRelatedStashIDs([]models.StashID{ @@ -206,6 +208,7 @@ func Test_StudioStore_Update(t *testing.T) { rating = 3 aliases = []string{"aliasX", "aliasY"} ignoreAutoTag = true + organized = true favorite = true endpoint1 = "endpoint1" endpoint2 = "endpoint2" @@ -231,6 +234,7 @@ func Test_StudioStore_Update(t *testing.T) { Rating: &rating, Details: details, IgnoreAutoTag: ignoreAutoTag, + Organized: organized, Aliases: models.NewRelatedStrings(aliases), TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithStudio]}), StashIDs: models.NewRelatedStashIDs([]models.StashID{ @@ -380,6 +384,7 @@ func Test_StudioStore_UpdatePartial(t *testing.T) { aliases = []string{"aliasX", "aliasY"} rating = 3 ignoreAutoTag = true + organized = true favorite = true endpoint1 = "endpoint1" endpoint2 = "endpoint2" @@ -413,6 +418,7 @@ func Test_StudioStore_UpdatePartial(t *testing.T) { Rating: models.NewOptionalInt(rating), Details: models.NewOptionalString(details), IgnoreAutoTag: models.NewOptionalBool(ignoreAutoTag), + Organized: models.NewOptionalBool(organized), TagIDs: &models.UpdateIDs{ IDs: []int{tagIDs[tagIdx1WithStudio], tagIDs[tagIdx1WithDupName]}, Mode: models.RelationshipUpdateModeSet, @@ -444,6 +450,7 @@ func Test_StudioStore_UpdatePartial(t *testing.T) { Rating: &rating, Details: details, IgnoreAutoTag: ignoreAutoTag, + Organized: organized, TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithStudio]}), StashIDs: models.NewRelatedStashIDs([]models.StashID{ { diff --git a/pkg/studio/export.go b/pkg/studio/export.go index c3a50668f..206791da6 100644 --- a/pkg/studio/export.go +++ b/pkg/studio/export.go @@ -27,6 +27,7 @@ func ToJSON(ctx context.Context, reader FinderImageStashIDGetter, studio *models Details: studio.Details, Favorite: studio.Favorite, IgnoreAutoTag: studio.IgnoreAutoTag, + Organized: studio.Organized, CreatedAt: json.JSONTime{Time: studio.CreatedAt}, UpdatedAt: json.JSONTime{Time: studio.UpdatedAt}, } diff --git a/pkg/studio/export_test.go b/pkg/studio/export_test.go index e41e6f36c..dce75ba9a 100644 --- a/pkg/studio/export_test.go +++ b/pkg/studio/export_test.go @@ -32,6 +32,7 @@ var ( details = "details" parentStudioName = "parentStudio" autoTagIgnored = true + studioOrganized = true emptyCustomFields = make(map[string]interface{}) customFields = map[string]interface{}{ "customField1": "customValue1", @@ -73,6 +74,7 @@ func createFullStudio(id int, parentID int) models.Studio { UpdatedAt: updateTime, Rating: &rating, IgnoreAutoTag: autoTagIgnored, + Organized: studioOrganized, Aliases: models.NewRelatedStrings(aliases), TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs(stashIDs), @@ -115,6 +117,7 @@ func createFullJSONStudio(parentStudio, image string, aliases []string, customFi Aliases: aliases, StashIDs: stashIDs, IgnoreAutoTag: autoTagIgnored, + Organized: studioOrganized, CustomFields: customFields, } } diff --git a/pkg/studio/import.go b/pkg/studio/import.go index d9e52100c..264e2566a 100644 --- a/pkg/studio/import.go +++ b/pkg/studio/import.go @@ -233,6 +233,7 @@ func studioJSONtoStudio(studioJSON jsonschema.Studio) models.Studio { Details: studioJSON.Details, Favorite: studioJSON.Favorite, IgnoreAutoTag: studioJSON.IgnoreAutoTag, + Organized: studioJSON.Organized, CreatedAt: studioJSON.CreatedAt.GetTime(), UpdatedAt: studioJSON.UpdatedAt.GetTime(), diff --git a/pkg/studio/import_test.go b/pkg/studio/import_test.go index 4eb757293..c2bbd40f5 100644 --- a/pkg/studio/import_test.go +++ b/pkg/studio/import_test.go @@ -49,6 +49,7 @@ func TestImporterPreImport(t *testing.T) { Name: studioName, Image: invalidImage, IgnoreAutoTag: autoTagIgnored, + Organized: studioOrganized, }, } diff --git a/ui/v2.5/graphql/data/studio-slim.graphql b/ui/v2.5/graphql/data/studio-slim.graphql index c48f7d93e..4ca3c8b4d 100644 --- a/ui/v2.5/graphql/data/studio-slim.graphql +++ b/ui/v2.5/graphql/data/studio-slim.graphql @@ -17,5 +17,8 @@ fragment SlimStudioData on Studio { id name } + favorite + ignore_auto_tag + organized o_counter } diff --git a/ui/v2.5/graphql/data/studio.graphql b/ui/v2.5/graphql/data/studio.graphql index aabec7a9b..8347b4739 100644 --- a/ui/v2.5/graphql/data/studio.graphql +++ b/ui/v2.5/graphql/data/studio.graphql @@ -16,6 +16,7 @@ fragment StudioData on Studio { image_path } ignore_auto_tag + organized image_path scene_count scene_count_all: scene_count(depth: -1) diff --git a/ui/v2.5/src/components/Studios/EditStudiosDialog.tsx b/ui/v2.5/src/components/Studios/EditStudiosDialog.tsx index 293a8dfb3..1c34dfc36 100644 --- a/ui/v2.5/src/components/Studios/EditStudiosDialog.tsx +++ b/ui/v2.5/src/components/Studios/EditStudiosDialog.tsx @@ -23,7 +23,13 @@ interface IListOperationProps { onClose: (applied: boolean) => void; } -const studioFields = ["favorite", "rating100", "details", "ignore_auto_tag"]; +const studioFields = [ + "favorite", + "rating100", + "details", + "ignore_auto_tag", + "organized", +]; export const EditStudiosDialog: React.FC = ( props: IListOperationProps @@ -236,6 +242,14 @@ export const EditStudiosDialog: React.FC = ( checked={updateInput.ignore_auto_tag ?? undefined} /> + + + setUpdateField({ organized: checked })} + checked={updateInput.organized ?? undefined} + /> + ); diff --git a/ui/v2.5/src/components/Studios/StudioCard.tsx b/ui/v2.5/src/components/Studios/StudioCard.tsx index 87c9b9528..839489182 100644 --- a/ui/v2.5/src/components/Studios/StudioCard.tsx +++ b/ui/v2.5/src/components/Studios/StudioCard.tsx @@ -7,13 +7,13 @@ import { PatchComponent } from "src/patch"; import { HoverPopover } from "../Shared/HoverPopover"; import { Icon } from "../Shared/Icon"; import { TagLink } from "../Shared/TagLink"; -import { Button, ButtonGroup } from "react-bootstrap"; +import { Button, ButtonGroup, OverlayTrigger, Tooltip } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import { PopoverCountButton } from "../Shared/PopoverCountButton"; import { RatingBanner } from "../Shared/RatingBanner"; import { FavoriteIcon } from "../Shared/FavoriteIcon"; import { useStudioUpdate } from "src/core/StashService"; -import { faTag } from "@fortawesome/free-solid-svg-icons"; +import { faTag, faBox } from "@fortawesome/free-solid-svg-icons"; import { OCounterButton } from "../Shared/CountButton"; interface IProps { @@ -185,6 +185,27 @@ export const StudioCard: React.FC = PatchComponent( return ; } + function maybeRenderOrganized() { + if (studio.organized) { + return ( + + + + } + placement="bottom" + > +
+ +
+
+ ); + } + } + function maybeRenderPopoverButtonGroup() { if ( studio.scene_count || @@ -193,7 +214,8 @@ export const StudioCard: React.FC = PatchComponent( studio.group_count || studio.performer_count || studio.o_counter || - studio.tags.length > 0 + studio.tags.length > 0 || + studio.organized ) { return ( <> @@ -206,6 +228,7 @@ export const StudioCard: React.FC = PatchComponent( {maybeRenderPerformersPopoverButton()} {maybeRenderTagPopoverButton()} {maybeRenderOCounter()} + {maybeRenderOrganized()} ); diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index 2edc53fe1..0096851e2 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -49,6 +49,7 @@ import { AliasList } from "src/components/Shared/DetailsPage/AliasList"; import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage"; import { goBackOrReplace } from "src/utils/history"; import { OCounterButton } from "src/components/Shared/CountButton"; +import { OrganizedButton } from "src/components/Scenes/SceneDetails/OrganizedButton"; interface IProps { studio: GQL.StudioDataFragment; @@ -316,6 +317,28 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { } } + const [organizedLoading, setOrganizedLoading] = useState(false); + + async function onOrganizedClick() { + if (!studio.id) return; + + setOrganizedLoading(true); + try { + await updateStudio({ + variables: { + input: { + id: studio.id, + organized: !studio.organized, + }, + }, + }); + } catch (e) { + Toast.error(e); + } finally { + setOrganizedLoading(false); + } + } + // set up hotkeys useEffect(() => { Mousetrap.bind("e", () => toggleEditing()); @@ -467,6 +490,11 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { favorite={studio.favorite} onToggleFavorite={(v) => setFavorite(v)} /> + diff --git a/ui/v2.5/src/docs/en/Manual/AutoTagging.md b/ui/v2.5/src/docs/en/Manual/AutoTagging.md index ad08027f6..c3ef00971 100644 --- a/ui/v2.5/src/docs/en/Manual/AutoTagging.md +++ b/ui/v2.5/src/docs/en/Manual/AutoTagging.md @@ -37,6 +37,8 @@ This task is part of the advanced settings mode. Scenes, images, and galleries that have the Organized flag added to them will not be modified by Auto tag. You can also use Organized flag status as a filter. +Studios also support the Organized flag, however it is purely informational. It serves as a front-end indicator for the user to mark that a studio's collection is complete and does not affect Auto tag behavior. The Ignore Auto tag flag should be used to exclude a studio from Auto tag. + ### Ignore Auto tag flag Performers or Tags that have Ignore Auto tag flag added to them will be skipped by the Auto tag task. diff --git a/ui/v2.5/src/models/list-filter/studios.ts b/ui/v2.5/src/models/list-filter/studios.ts index 42ac1b4dc..a38540a47 100644 --- a/ui/v2.5/src/models/list-filter/studios.ts +++ b/ui/v2.5/src/models/list-filter/studios.ts @@ -53,6 +53,7 @@ const criterionOptions = [ TagsCriterionOption, RatingCriterionOption, createBooleanCriterionOption("ignore_auto_tag"), + createBooleanCriterionOption("organized"), createMandatoryNumberCriterionOption("tag_count"), createMandatoryNumberCriterionOption("scene_count"), createMandatoryNumberCriterionOption("image_count"), From c15e6a5b630f76afc139a4f901ae9208bada811c Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 20 Feb 2026 09:13:55 +1100 Subject: [PATCH 074/177] Include blobs in backup (#6586) * Optionally backup blobs into zip * Add backup dialog --- graphql/schema/types/metadata.graphql | 2 + internal/api/resolver_mutation_metadata.go | 3 +- internal/manager/backup.go | 185 ++++++++++++++++++ internal/manager/manager.go | 40 ---- .../Settings/Tasks/DataManagementTasks.tsx | 176 ++++++++++++++--- ui/v2.5/src/docs/en/Manual/Tasks.md | 16 ++ ui/v2.5/src/locales/en-GB.json | 12 +- 7 files changed, 364 insertions(+), 70 deletions(-) create mode 100644 internal/manager/backup.go diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index 0c0d59579..27cbb86fb 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -325,6 +325,8 @@ input ImportObjectsInput { input BackupDatabaseInput { download: Boolean + "If true, blob files will be included in the backup. This can significantly increase the size of the backup and the time it takes to create it, but allows for a complete backup of the system that can be restored without needing access to the original media files." + includeBlobs: Boolean } input AnonymiseDatabaseInput { diff --git a/internal/api/resolver_mutation_metadata.go b/internal/api/resolver_mutation_metadata.go index 8120e2d31..ea6496800 100644 --- a/internal/api/resolver_mutation_metadata.go +++ b/internal/api/resolver_mutation_metadata.go @@ -122,9 +122,10 @@ func (r *mutationResolver) MigrateHashNaming(ctx context.Context) (string, error func (r *mutationResolver) BackupDatabase(ctx context.Context, input BackupDatabaseInput) (*string, error) { // if download is true, then backup to temporary file and return a link download := input.Download != nil && *input.Download + includeBlobs := input.IncludeBlobs != nil && *input.IncludeBlobs mgr := manager.GetInstance() - backupPath, backupName, err := mgr.BackupDatabase(download) + backupPath, backupName, err := mgr.BackupDatabase(download, includeBlobs) if err != nil { logger.Errorf("Error backing up database: %v", err) return nil, err diff --git a/internal/manager/backup.go b/internal/manager/backup.go new file mode 100644 index 000000000..4a41b263b --- /dev/null +++ b/internal/manager/backup.go @@ -0,0 +1,185 @@ +package manager + +import ( + "archive/zip" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/stashapp/stash/internal/manager/config" + "github.com/stashapp/stash/pkg/fsutil" + "github.com/stashapp/stash/pkg/logger" +) + +type databaseBackupZip struct { + *zip.Writer +} + +func (z *databaseBackupZip) zipFileRename(fn, outDir, outFn string) error { + p := filepath.Join(outDir, outFn) + p = filepath.ToSlash(p) + + f, err := z.Create(p) + if err != nil { + return fmt.Errorf("error creating zip entry for %s: %v", fn, err) + } + + i, err := os.Open(fn) + if err != nil { + return fmt.Errorf("error opening %s: %v", fn, err) + } + + defer i.Close() + + if _, err := io.Copy(f, i); err != nil { + return fmt.Errorf("error writing %s to zip: %v", fn, err) + } + + return nil +} + +func (z *databaseBackupZip) zipFile(fn, outDir string) error { + return z.zipFileRename(fn, outDir, filepath.Base(fn)) +} + +func (s *Manager) BackupDatabase(download bool, includeBlobs bool) (string, string, error) { + var backupPath string + var backupName string + + // if we include blobs, then the output is a zip file + // if not, using the same backup logic as before, which creates a sqlite file + if !includeBlobs || s.Config.GetBlobsStorage() != config.BlobStorageTypeFilesystem { + return s.backupDatabaseOnly(download) + } + + // use tmp directory for the backup + backupDir := s.Paths.Generated.Tmp + if err := fsutil.EnsureDir(backupDir); err != nil { + return "", "", fmt.Errorf("could not create backup directory %v: %w", backupDir, err) + } + f, err := os.CreateTemp(backupDir, "backup*.sqlite") + if err != nil { + return "", "", err + } + + backupPath = f.Name() + backupName = s.Database.DatabaseBackupPath("") + f.Close() + + // delete the temp file so that the backup operation can create it + if err := os.Remove(backupPath); err != nil { + return "", "", fmt.Errorf("could not remove temporary backup file %v: %w", backupPath, err) + } + + if err := s.Database.Backup(backupPath); err != nil { + return "", "", err + } + + // create a zip file + zipFileDir := s.Paths.Generated.Downloads + if !download { + zipFileDir = s.Config.GetBackupDirectoryPathOrDefault() + if zipFileDir != "" { + if err := fsutil.EnsureDir(zipFileDir); err != nil { + return "", "", fmt.Errorf("could not create backup directory %v: %w", zipFileDir, err) + } + } + } + + zipFileName := backupName + ".zip" + zipFilePath := filepath.Join(zipFileDir, zipFileName) + + logger.Debugf("Preparing zip file for database backup at %v", zipFilePath) + + zf, err := os.Create(zipFilePath) + if err != nil { + return "", "", fmt.Errorf("could not create zip file %v: %w", zipFilePath, err) + } + defer zf.Close() + + z := databaseBackupZip{ + Writer: zip.NewWriter(zf), + } + + defer z.Close() + + // move the database file into the zip + dbFn := filepath.Base(s.Config.GetDatabasePath()) + if err := z.zipFileRename(backupPath, "", dbFn); err != nil { + return "", "", fmt.Errorf("could not add database backup to zip file: %w", err) + } + + if err := os.Remove(backupPath); err != nil { + return "", "", fmt.Errorf("could not remove temporary backup file %v: %w", backupPath, err) + } + + // walk the blobs directory and add files to the zip + blobsDir := s.Config.GetBlobsPath() + err = filepath.WalkDir(blobsDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + return nil + } + + // calculate out dir by removing the blobsDir prefix from the path + outDir := filepath.Join("blobs", strings.TrimPrefix(filepath.Dir(path), blobsDir)) + if err := z.zipFile(path, outDir); err != nil { + return fmt.Errorf("could not add blob %v to zip file: %w", path, err) + } + + return nil + }) + + if err != nil { + return "", "", fmt.Errorf("error walking blobs directory: %w", err) + } + + return zipFilePath, zipFileName, nil +} + +func (s *Manager) backupDatabaseOnly(download bool) (string, string, error) { + var backupPath string + var backupName string + + if download { + backupDir := s.Paths.Generated.Downloads + if err := fsutil.EnsureDir(backupDir); err != nil { + return "", "", fmt.Errorf("could not create backup directory %v: %w", backupDir, err) + } + f, err := os.CreateTemp(backupDir, "backup*.sqlite") + if err != nil { + return "", "", err + } + + backupPath = f.Name() + backupName = s.Database.DatabaseBackupPath("") + f.Close() + + // delete the temp file so that the backup operation can create it + if err := os.Remove(backupPath); err != nil { + return "", "", fmt.Errorf("could not remove temporary backup file %v: %w", backupPath, err) + } + } else { + backupDir := s.Config.GetBackupDirectoryPathOrDefault() + if backupDir != "" { + if err := fsutil.EnsureDir(backupDir); err != nil { + return "", "", fmt.Errorf("could not create backup directory %v: %w", backupDir, err) + } + } + backupPath = s.Database.DatabaseBackupPath(backupDir) + backupName = filepath.Base(backupPath) + } + + err := s.Database.Backup(backupPath) + if err != nil { + return "", "", err + } + + return backupPath, backupName, nil +} diff --git a/internal/manager/manager.go b/internal/manager/manager.go index f4f3fa636..d3b91ec29 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -313,46 +313,6 @@ func (s *Manager) validateFFmpeg() error { return nil } -func (s *Manager) BackupDatabase(download bool) (string, string, error) { - var backupPath string - var backupName string - if download { - backupDir := s.Paths.Generated.Downloads - if err := fsutil.EnsureDir(backupDir); err != nil { - return "", "", fmt.Errorf("could not create backup directory %v: %w", backupDir, err) - } - f, err := os.CreateTemp(backupDir, "backup*.sqlite") - if err != nil { - return "", "", err - } - - backupPath = f.Name() - backupName = s.Database.DatabaseBackupPath("") - f.Close() - - // delete the temp file so that the backup operation can create it - if err := os.Remove(backupPath); err != nil { - return "", "", fmt.Errorf("could not remove temporary backup file %v: %w", backupPath, err) - } - } else { - backupDir := s.Config.GetBackupDirectoryPathOrDefault() - if backupDir != "" { - if err := fsutil.EnsureDir(backupDir); err != nil { - return "", "", fmt.Errorf("could not create backup directory %v: %w", backupDir, err) - } - } - backupPath = s.Database.DatabaseBackupPath(backupDir) - backupName = filepath.Base(backupPath) - } - - err := s.Database.Backup(backupPath) - if err != nil { - return "", "", err - } - - return backupPath, backupName, nil -} - func (s *Manager) AnonymiseDatabase(download bool) (string, string, error) { var outPath string var outName string diff --git a/ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx b/ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx index c36e076f4..a6bda7c09 100644 --- a/ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx @@ -25,6 +25,8 @@ import { Icon } from "src/components/Shared/Icon"; import { useConfigurationContext } from "src/hooks/Config"; import { FolderSelect } from "src/components/Shared/FolderSelect/FolderSelect"; import { + faBoxArchive, + faExclamationTriangle, faMinus, faPlus, faQuestionCircle, @@ -153,6 +155,125 @@ const CleanOptions: React.FC = ({ ); }; +const BackupDialog: React.FC<{ + onClose: ( + confirmed?: boolean, + download?: boolean, + includeBlobs?: boolean + ) => void; +}> = ({ onClose }) => { + const intl = useIntl(); + const { configuration } = useConfigurationContext(); + + const includeBlobsDefault = + configuration?.general.blobsStorage === GQL.BlobsStorageType.Filesystem; + const backupDir = + configuration.general.backupDirectoryPath || + `<${intl.formatMessage({ + id: "config.general.backup_directory_path.heading", + })}>`; + + const [download, setDownload] = useState(false); + const [includeBlobs, setIncludeBlobs] = useState(includeBlobsDefault); + + let msg; + if (!includeBlobs) { + msg = intl.formatMessage( + { id: "config.tasks.backup_database.sqlite" }, + { + filename_format: ( + [origFilename].sqlite.[schemaVersion].[YYYYMMDD_HHMMSS] + ), + } + ); + } else { + msg = intl.formatMessage( + { id: "config.tasks.backup_database.zip" }, + { + filename_format: ( + + [origFilename].sqlite.[schemaVersion].[YYYYMMDD_HHMMSS].zip + + ), + } + ); + } + + const warning = + includeBlobs !== includeBlobsDefault ? ( +

+ + +

+ ) : null; + + const acceptID = download + ? "config.tasks.backup_database.download" + : "actions.backup"; + + return ( + onClose(true, download, includeBlobs), + }} + cancel={{ + onClick: () => onClose(), + variant: "secondary", + }} + > +
+ +
+ +
+ setDownload(false)} + label={intl.formatMessage( + { + id: "config.tasks.backup_database.to_directory", + }, + { + directory: {backupDir}, + } + )} + /> + + setDownload(true)} + label={intl.formatMessage({ + id: "config.tasks.backup_database.download", + })} + /> +
+ + + setIncludeBlobs(v)} + // if includeBlobsDefault is false, then blobs are in the database + disabled={!includeBlobsDefault} + /> + + +

{msg}

+ {warning} +
+
+ ); +}; + interface IDataManagementTasks { setIsBackupRunning: (v: boolean) => void; setIsAnonymiseRunning: (v: boolean) => void; @@ -167,6 +288,7 @@ export const DataManagementTasks: React.FC = ({ const [dialogOpen, setDialogOpenState] = useState({ importAlert: false, import: false, + backup: false, clean: false, cleanAlert: false, cleanGenerated: false, @@ -344,11 +466,12 @@ export const DataManagementTasks: React.FC = ({ } } - async function onBackup(download?: boolean) { + async function onBackup(download?: boolean, includeBlobs?: boolean) { try { setIsBackupRunning(true); const ret = await mutateBackupDatabase({ download, + includeBlobs, }); // download the result @@ -439,6 +562,17 @@ export const DataManagementTasks: React.FC = ({ }} /> )} + {dialogOpen.backup && ( + { + if (confirmed) { + onBackup(download, includeBlobs); + } + + setDialogOpen({ backup: false }); + }} + /> + )}
@@ -555,39 +689,25 @@ export const DataManagementTasks: React.FC = ({ - [origFilename].sqlite.[schemaVersion].[YYYYMMDD_HHMMSS] - - ), - } - )} + heading={ + <> + + + + + + } + subHeading={intl.formatMessage({ + id: "config.tasks.backup_database.description", + })} > - - - - diff --git a/ui/v2.5/src/docs/en/Manual/Tasks.md b/ui/v2.5/src/docs/en/Manual/Tasks.md index 5dd887cfe..4191afd24 100644 --- a/ui/v2.5/src/docs/en/Manual/Tasks.md +++ b/ui/v2.5/src/docs/en/Manual/Tasks.md @@ -85,3 +85,19 @@ The import and export tasks read and write JSON files to the configured metadata > **⚠️ Note:** The full import task wipes the current database completely before importing. See the [JSON Specification](/help/JSONSpec.md) page for details on the exported JSON format. + +## Backing up + +The backup task creates a backup of the stash database and (optionally) blob files. The backup can either be downloaded or output into the backup directory (under `Settings > Paths`) or the database directory if the backup directory is not configured. + +For a full backup, the database file and all blob files must be copied. The backup is stored as a zip file, with the database file at the root of the zip and the blob files in a `blobs` directory. + +> **⚠️ Note:** generated files are not included in the backup, so these will need to be regenerated when restoring with an empty system from backup. + +For database-only backups, only the database file is copied into the destination. This is useful for quick backups before performing risky operations, or for users who do not use filesystem blob storage. + +## Restoring from backup + +Restoring from backup is currently a manual process. The database backup zip file must be unzipped, and the database file and blob files (if applicable) copied into the database and blob directories respectively. Stash should then be restarted to load the restored database. + +> **⚠️ Note:** the filename for a database-only backup is not the same as the original database file, so the database file from the backup must be renamed to match the original database filename before copying it into the database directory. The original database filename can be found in `Settings > Paths > Database path`. \ No newline at end of file diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 595ff4c61..2aee4fe2b 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -499,7 +499,17 @@ "auto_tagging": "Auto tagging", "backing_up_database": "Backing up database", "backup_and_download": "Performs a backup of the database and downloads the resulting file.", - "backup_database": "Performs a backup of the database to the backups directory, with the filename format {filename_format}.", + "backup_database": { + "description": "Performs a backup of the database and blob files.", + "destination": "Destination", + "download": "Download backup", + "include_blobs": "Include blobs in backup", + "include_blobs_desc": "Disable to only backup the SQLite database file.", + "sqlite": "Backup file will be a copy of the SQLite database file, with the filename {filename_format}", + "to_directory": "To {directory}", + "warning_blobs": "Blob files will not be included in the backup. This means that to succesfully restore from the backup, the blob files must be present in the blob storage location.", + "zip": "SQLite database file and blob files will be zipped into a single file, with the filename {filename_format}" + }, "cleanup_desc": "Check for missing files and remove them from the database. This is a destructive action.", "clean_generated": { "blob_files": "Blob files", From 843806247d9f9162bb7775b668058d730bb4a71f Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 20 Feb 2026 09:14:25 +1100 Subject: [PATCH 075/177] Add group scene count filter (#6593) --- graphql/schema/types/filters.graphql | 2 ++ pkg/models/group.go | 2 ++ pkg/sqlite/group_filter.go | 11 ++++++++++ pkg/sqlite/group_test.go | 26 ++++++++++++++++++++++++ ui/v2.5/src/models/list-filter/groups.ts | 1 + 5 files changed, 42 insertions(+) diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index c0b47f7cf..075e40372 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -455,6 +455,8 @@ input GroupFilterType { containing_group_count: IntCriterionInput "Filter by number of sub-groups the group has" sub_group_count: IntCriterionInput + "Filter by number of scenes the group has" + scene_count: IntCriterionInput "Filter by related scenes that meet this criteria" scenes_filter: SceneFilterType diff --git a/pkg/models/group.go b/pkg/models/group.go index 6943b1055..ec550eea8 100644 --- a/pkg/models/group.go +++ b/pkg/models/group.go @@ -33,6 +33,8 @@ type GroupFilterType struct { ContainingGroupCount *IntCriterionInput `json:"containing_group_count"` // Filter by number of sub-groups the group has SubGroupCount *IntCriterionInput `json:"sub_group_count"` + // Filter by number of scenes the group has + SceneCount *IntCriterionInput `json:"scene_count"` // Filter by related scenes that meet this criteria ScenesFilter *SceneFilterType `json:"scenes_filter"` // Filter by related studios that meet this criteria diff --git a/pkg/sqlite/group_filter.go b/pkg/sqlite/group_filter.go index f29023785..f81783374 100644 --- a/pkg/sqlite/group_filter.go +++ b/pkg/sqlite/group_filter.go @@ -75,6 +75,7 @@ func (qb *groupFilterHandler) criterionHandler() criterionHandler { qb.tagsCriterionHandler(groupFilter.Tags), qb.tagCountCriterionHandler(groupFilter.TagCount), qb.groupOCounterCriterionHandler(groupFilter.OCounter), + qb.sceneCountCriterionHandler(groupFilter.SceneCount), &dateCriterionHandler{groupFilter.Date, "groups.date", nil}, groupHierarchyHandler.ParentsCriterionHandler(groupFilter.ContainingGroups), groupHierarchyHandler.ChildrenCriterionHandler(groupFilter.SubGroups), @@ -204,6 +205,16 @@ func (qb *groupFilterHandler) tagCountCriterionHandler(count *models.IntCriterio return h.handler(count) } +func (qb *groupFilterHandler) sceneCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: groupTable, + joinTable: groupsScenesTable, + primaryFK: groupIDColumn, + } + + return h.handler(count) +} + // used for sorting and filtering on group o-count var selectGroupOCountSQL = utils.StrFormat( "SELECT SUM(o_counter) "+ diff --git a/pkg/sqlite/group_test.go b/pkg/sqlite/group_test.go index d4a177e86..db293dd92 100644 --- a/pkg/sqlite/group_test.go +++ b/pkg/sqlite/group_test.go @@ -669,6 +669,32 @@ func TestGroupQuery(t *testing.T) { nil, false, }, + { + "scene count equals 1", + nil, + &models.GroupFilterType{ + SceneCount: &models.IntCriterionInput{ + Value: 1, + Modifier: models.CriterionModifierEquals, + }, + }, + []int{groupIdxWithScene}, + []int{groupIdxWithParentAndChild}, + false, + }, + { + "scene count less than 1", + nil, + &models.GroupFilterType{ + SceneCount: &models.IntCriterionInput{ + Value: 1, + Modifier: models.CriterionModifierLessThan, + }, + }, + []int{groupIdxWithParentAndChild}, + []int{groupIdxWithScene}, + false, + }, } for _, tt := range tests { diff --git a/ui/v2.5/src/models/list-filter/groups.ts b/ui/v2.5/src/models/list-filter/groups.ts index 5a263b272..ee0c90d73 100644 --- a/ui/v2.5/src/models/list-filter/groups.ts +++ b/ui/v2.5/src/models/list-filter/groups.ts @@ -63,6 +63,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("sub_group_count"), TagsCriterionOption, createMandatoryNumberCriterionOption("tag_count"), + createMandatoryNumberCriterionOption("scene_count"), createMandatoryTimestampCriterionOption("created_at"), createMandatoryTimestampCriterionOption("updated_at"), ]; From 076032ff8b69864480dd3434438ae9202f43bbc5 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:09:59 +1100 Subject: [PATCH 076/177] Custom sprite generation (#6588) * configurable minimum/maximum number of sprites * configurable sprite size --------- Co-authored-by: cacheflush --- graphql/schema/types/config.graphql | 22 ++++ internal/api/resolver_mutation_configure.go | 5 + internal/api/resolver_query_configuration.go | 5 + internal/manager/config/config.go | 65 ++++++++++ internal/manager/generator_sprite.go | 121 ++++++++++++++++-- internal/manager/task_generate_sprite.go | 12 +- pkg/ffmpeg/transcoder/screenshot.go | 7 + pkg/scene/generate/sprite.go | 58 +++++---- ui/v2.5/graphql/data/config.graphql | 5 + .../src/components/Scenes/PreviewScrubber.tsx | 2 + .../Settings/SettingsSystemPanel.tsx | 38 ++++++ ui/v2.5/src/docs/en/Manual/Configuration.md | 30 +++++ ui/v2.5/src/locales/en-GB.json | 11 ++ 13 files changed, 343 insertions(+), 38 deletions(-) diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 6990d9d95..5ab7fdfea 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -184,6 +184,18 @@ input ConfigGeneralInput { scraperPackageSources: [PackageSourceInput!] "Source of plugin packages" pluginPackageSources: [PackageSourceInput!] + + "Size of the longest dimension for each sprite in pixels" + spriteScreenshotSize: Int + + "True if sprite generation should use the sprite interval and min/max sprites settings instead of the default" + useCustomSpriteInterval: Boolean + "Time between two different scrubber sprites in seconds - only used if useCustomSpriteInterval is true" + spriteInterval: Float + "Minimum number of sprites to be generated - only used if useCustomSpriteInterval is true" + minimumSprites: Int + "Minimum number of sprites to be generated - only used if useCustomSpriteInterval is true" + maximumSprites: Int } type ConfigGeneralResult { @@ -287,6 +299,16 @@ type ConfigGeneralResult { logAccess: Boolean! "Maximum log size" logFileMaxSize: Int! + "True if sprite generation should use the sprite interval and min/max sprites settings instead of the default" + useCustomSpriteInterval: Boolean! + "Time between two different scrubber sprites in seconds - only used if useCustomSpriteInterval is true" + spriteInterval: Float! + "Minimum number of sprites to be generated - only used if useCustomSpriteInterval is true" + minimumSprites: Int! + "Maximum number of sprites to be generated - only used if useCustomSpriteInterval is true" + maximumSprites: Int! + "Size of the longest dimension for each sprite in pixels" + spriteScreenshotSize: Int! "Array of video file extensions" videoExtensions: [String!]! "Array of image file extensions" diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index 23b61c208..718d24998 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -287,6 +287,11 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen if input.PreviewPreset != nil { c.SetString(config.PreviewPreset, input.PreviewPreset.String()) } + r.setConfigBool(config.UseCustomSpriteInterval, input.UseCustomSpriteInterval) + r.setConfigFloat(config.SpriteInterval, input.SpriteInterval) + r.setConfigInt(config.MinimumSprites, input.MinimumSprites) + r.setConfigInt(config.MaximumSprites, input.MaximumSprites) + r.setConfigInt(config.SpriteScreenshotSize, input.SpriteScreenshotSize) r.setConfigBool(config.TranscodeHardwareAcceleration, input.TranscodeHardwareAcceleration) if input.MaxTranscodeSize != nil { diff --git a/internal/api/resolver_query_configuration.go b/internal/api/resolver_query_configuration.go index bc76212eb..cf2c0e3cc 100644 --- a/internal/api/resolver_query_configuration.go +++ b/internal/api/resolver_query_configuration.go @@ -96,6 +96,11 @@ func makeConfigGeneralResult() *ConfigGeneralResult { CalculateMd5: config.IsCalculateMD5(), VideoFileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(), ParallelTasks: config.GetParallelTasks(), + UseCustomSpriteInterval: config.GetUseCustomSpriteInterval(), + SpriteInterval: config.GetSpriteInterval(), + SpriteScreenshotSize: config.GetSpriteScreenshotSize(), + MinimumSprites: config.GetMinimumSprites(), + MaximumSprites: config.GetMaximumSprites(), PreviewAudio: config.GetPreviewAudio(), PreviewSegments: config.GetPreviewSegments(), PreviewSegmentDuration: config.GetPreviewSegmentDuration(), diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index bb99bdcfc..19e263810 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -83,6 +83,21 @@ const ( ParallelTasks = "parallel_tasks" parallelTasksDefault = 1 + UseCustomSpriteInterval = "use_custom_sprite_interval" + UseCustomSpriteIntervalDefault = false + + SpriteInterval = "sprite_interval" + SpriteIntervalDefault = 30 + + MinimumSprites = "minimum_sprites" + MinimumSpritesDefault = 10 + + MaximumSprites = "maximum_sprites" + MaximumSpritesDefault = 500 + + SpriteScreenshotSize = "sprite_screenshot_width" + spriteScreenshotSizeDefault = 160 + PreviewPreset = "preview_preset" TranscodeHardwareAcceleration = "ffmpeg.hardware_acceleration" @@ -975,6 +990,50 @@ func (i *Config) GetParallelTasksWithAutoDetection() int { return parallelTasks } +// GetUseCustomSpriteInterval returns true if the sprite minimum, maximum, and interval settings +// should be used instead of the default +func (i *Config) GetUseCustomSpriteInterval() bool { + value := i.getBool(UseCustomSpriteInterval) + return value +} + +// GetSpriteInterval returns the time (in seconds) to be between each scrubber sprite +// A value of 0 indicates that the sprite interval should be automatically determined +// based on the minimum sprite setting. +func (i *Config) GetSpriteInterval() float64 { + value := i.getFloat64(SpriteInterval) + return value +} + +// GetMinimumSprites returns the minimum number of sprites that have to be generated +// A value of 0 will be overridden with the default of 10. +func (i *Config) GetMinimumSprites() int { + value := i.getInt(MinimumSprites) + if value <= 0 { + return MinimumSpritesDefault + } + return value +} + +// GetMaximumSprites returns the maximum number of sprites that can be generated +// A value of 0 indicates no maximum. +func (i *Config) GetMaximumSprites() int { + value := i.getInt(MaximumSprites) + return value +} + +// GetSpriteScreenshotSize returns the required size of the screenshots to be taken +// during sprite generation in pixels. This will be the width for landscape scenes +// and the height for portrait scenes, with the other dimension being scaled to maintain +// the aspect ratio. If the value is less than or equal to 0, the default will be used. +func (i *Config) GetSpriteScreenshotSize() int { + value := i.getInt(SpriteScreenshotSize) + if value <= 0 { + return spriteScreenshotSizeDefault + } + return value +} + func (i *Config) GetPreviewAudio() bool { return i.getBool(PreviewAudio) } @@ -1861,6 +1920,12 @@ func (i *Config) setDefaultValues() { i.setDefault(PreviewAudio, previewAudioDefault) i.setDefault(SoundOnPreview, false) + i.setDefault(UseCustomSpriteInterval, UseCustomSpriteIntervalDefault) + i.setDefault(SpriteInterval, SpriteIntervalDefault) + i.setDefault(MinimumSprites, MinimumSpritesDefault) + i.setDefault(MaximumSprites, MaximumSpritesDefault) + i.setDefault(SpriteScreenshotSize, spriteScreenshotSizeDefault) + i.setDefault(ThemeColor, DefaultThemeColor) i.setDefault(WriteImageThumbnails, writeImageThumbnailsDefault) diff --git a/internal/manager/generator_sprite.go b/internal/manager/generator_sprite.go index c28d28674..dc56fde88 100644 --- a/internal/manager/generator_sprite.go +++ b/internal/manager/generator_sprite.go @@ -21,8 +21,7 @@ type SpriteGenerator struct { VideoChecksum string ImageOutputPath string VTTOutputPath string - Rows int - Columns int + Config SpriteGeneratorConfig SlowSeek bool // use alternate seek function, very slow! Overwrite bool @@ -30,13 +29,81 @@ type SpriteGenerator struct { g *generate.Generator } -func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageOutputPath string, vttOutputPath string, rows int, cols int) (*SpriteGenerator, error) { +// SpriteGeneratorConfig holds configuration for the SpriteGenerator +type SpriteGeneratorConfig struct { + // MinimumSprites is the minimum number of sprites to generate, even if the video duration is short + // SpriteInterval will be adjusted accordingly to ensure at least this many sprites are generated. + // A value of 0 means no minimum, and the generator will use the provided SpriteInterval or + // calculate it based on the video duration and MaximumSprites + MinimumSprites int + + // MaximumSprites is the maximum number of sprites to generate, even if the video duration is long + // SpriteInterval will be adjusted accordingly to ensure no more than this many sprites are generated + // A value of 0 means no maximum, and the generator will use the provided SpriteInterval or + // calculate it based on the video duration and MinimumSprites + MaximumSprites int + + // SpriteInterval is the default interval in seconds between each sprite. + // If MinimumSprites or MaximumSprites are set, this value will be adjusted accordingly + // to ensure the desired number of sprites are generated + // A value of 0 means the generator will calculate the interval based on the video duration and + // the provided MinimumSprites and MaximumSprites + SpriteInterval float64 + + // SpriteSize is the size in pixels of the longest dimension of each sprite image. + // The other dimension will be automatically calculated to maintain the aspect ratio of the video + SpriteSize int +} + +const ( + // DefaultSpriteAmount is the default number of sprites to generate if no configuration is provided + // This corresponds to the legacy behavior of the generator, which generates 81 sprites at equal + // intervals across the video duration + DefaultSpriteAmount = 81 + + // DefaultSpriteSize is the default size in pixels of the longest dimension of each sprite image + // if no configuration is provided. This corresponds to the legacy behavior of the generator. + DefaultSpriteSize = 160 +) + +var DefaultSpriteGeneratorConfig = SpriteGeneratorConfig{ + MinimumSprites: DefaultSpriteAmount, + MaximumSprites: DefaultSpriteAmount, + SpriteInterval: 0, + SpriteSize: DefaultSpriteSize, +} + +// NewSpriteGenerator creates a new SpriteGenerator for the given video file and configuration +// It calculates the appropriate sprite interval and count based on the video duration and the provided configuration +func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageOutputPath string, vttOutputPath string, config SpriteGeneratorConfig) (*SpriteGenerator, error) { exists, err := fsutil.FileExists(videoFile.Path) if !exists { return nil, err } + + if videoFile.VideoStreamDuration <= 0 { + s := fmt.Sprintf("video %s: duration(%.3f)/frame count(%d) invalid, skipping sprite creation", videoFile.Path, videoFile.VideoStreamDuration, videoFile.FrameCount) + return nil, errors.New(s) + } + + config.SpriteInterval = calculateSpriteInterval(videoFile, config) + chunkCount := int(math.Ceil(videoFile.VideoStreamDuration / config.SpriteInterval)) + + // adjust the chunk count to the next highest perfect square, to ensure the sprite image + // is completely filled (no empty space in the grid) and the grid is as square as possible (minimizing the number of rows/columns) + gridSize := generate.GetSpriteGridSize(chunkCount) + newChunkCount := gridSize * gridSize + + if newChunkCount != chunkCount { + logger.Debugf("[generator] adjusting chunk count from %d to %d to fit a %dx%d grid", chunkCount, newChunkCount, gridSize, gridSize) + chunkCount = newChunkCount + } + + if config.SpriteSize <= 0 { + config.SpriteSize = DefaultSpriteSize + } + slowSeek := false - chunkCount := rows * cols // For files with small duration / low frame count try to seek using frame number intead of seconds if videoFile.VideoStreamDuration < 5 || (0 < videoFile.FrameCount && videoFile.FrameCount <= int64(chunkCount)) { // some files can have FrameCount == 0, only use SlowSeek if duration < 5 @@ -71,9 +138,8 @@ func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageO VideoChecksum: videoChecksum, ImageOutputPath: imageOutputPath, VTTOutputPath: vttOutputPath, - Rows: rows, + Config: config, SlowSeek: slowSeek, - Columns: cols, g: &generate.Generator{ Encoder: instance.FFMpeg, FFMpegConfig: instance.Config, @@ -83,6 +149,40 @@ func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageO }, nil } +func calculateSpriteInterval(videoFile ffmpeg.VideoFile, config SpriteGeneratorConfig) float64 { + // If a custom sprite interval is provided, start with that + spriteInterval := config.SpriteInterval + + // If no custom interval is provided, calculate the interval based on the + // video duration and minimum sprite count + if spriteInterval <= 0 { + minSprites := config.MinimumSprites + if minSprites <= 0 { + panic("invalid configuration: MinimumSprites must be greater than 0 if SpriteInterval is not set") + } + + logger.Debugf("[generator] calculating sprite interval for video duration %.3fs with minimum sprites %d", videoFile.VideoStreamDuration, minSprites) + return videoFile.VideoStreamDuration / float64(minSprites) + } + + // Calculate the number of sprites that would be generated with the provided interval + spriteCount := int(math.Ceil(videoFile.VideoStreamDuration / spriteInterval)) + + // If the calculated sprite count is greater than the maximum, adjust the interval to meet the maximum + if config.MaximumSprites > 0 && spriteCount > int(config.MaximumSprites) { + spriteInterval = videoFile.VideoStreamDuration / float64(config.MaximumSprites) + logger.Debugf("[generator] provided sprite interval %.1fs results in %d sprites, which exceeds the maximum of %d, adjusting interval to %.1fs", config.SpriteInterval, spriteCount, config.MaximumSprites, spriteInterval) + } + + // If the calculated sprite count is less than the minimum, adjust the interval to meet the minimum + if config.MinimumSprites > 0 && spriteCount < int(config.MinimumSprites) { + spriteInterval = videoFile.VideoStreamDuration / float64(config.MinimumSprites) + logger.Debugf("[generator] provided sprite interval %.1fs results in %d sprites, which is less than the minimum of %d, adjusting interval to %.1fs", config.SpriteInterval, spriteCount, config.MinimumSprites, spriteInterval) + } + + return spriteInterval +} + func (g *SpriteGenerator) Generate() error { if err := g.generateSpriteImage(); err != nil { return err @@ -100,6 +200,8 @@ func (g *SpriteGenerator) generateSpriteImage() error { var images []image.Image + isPortrait := g.Info.VideoFile.Height > g.Info.VideoFile.Width + if !g.SlowSeek { logger.Infof("[generator] generating sprite image for %s", g.Info.VideoFile.Path) // generate `ChunkCount` thumbnails @@ -107,8 +209,7 @@ func (g *SpriteGenerator) generateSpriteImage() error { for i := 0; i < g.Info.ChunkCount; i++ { time := float64(i) * stepSize - - img, err := g.g.SpriteScreenshot(context.TODO(), g.Info.VideoFile.Path, time) + img, err := g.g.SpriteScreenshot(context.TODO(), g.Info.VideoFile.Path, time, g.Config.SpriteSize, isPortrait) if err != nil { return err } @@ -126,7 +227,7 @@ func (g *SpriteGenerator) generateSpriteImage() error { return errors.New("invalid frame number conversion") } - img, err := g.g.SpriteScreenshotSlow(context.TODO(), g.Info.VideoFile.Path, int(frame)) + img, err := g.g.SpriteScreenshotSlow(context.TODO(), g.Info.VideoFile.Path, int(frame), g.Config.SpriteSize) if err != nil { return err } @@ -158,7 +259,7 @@ func (g *SpriteGenerator) generateSpriteVTT() error { stepSize /= g.Info.FrameRate } - return g.g.SpriteVTT(context.TODO(), g.VTTOutputPath, g.ImageOutputPath, stepSize) + return g.g.SpriteVTT(context.TODO(), g.VTTOutputPath, g.ImageOutputPath, stepSize, g.Info.ChunkCount) } func (g *SpriteGenerator) imageExists() bool { diff --git a/internal/manager/task_generate_sprite.go b/internal/manager/task_generate_sprite.go index 0275830ab..c173147cd 100644 --- a/internal/manager/task_generate_sprite.go +++ b/internal/manager/task_generate_sprite.go @@ -34,7 +34,17 @@ func (t *GenerateSpriteTask) Start(ctx context.Context) { sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm) imagePath := instance.Paths.Scene.GetSpriteImageFilePath(sceneHash) vttPath := instance.Paths.Scene.GetSpriteVttFilePath(sceneHash) - generator, err := NewSpriteGenerator(*videoFile, sceneHash, imagePath, vttPath, 9, 9) + + cfg := DefaultSpriteGeneratorConfig + cfg.SpriteSize = instance.Config.GetSpriteScreenshotSize() + + if instance.Config.GetUseCustomSpriteInterval() { + cfg.MinimumSprites = instance.Config.GetMinimumSprites() + cfg.MaximumSprites = instance.Config.GetMaximumSprites() + cfg.SpriteInterval = instance.Config.GetSpriteInterval() + } + + generator, err := NewSpriteGenerator(*videoFile, sceneHash, imagePath, vttPath, cfg) if err != nil { logger.Errorf("error creating sprite generator: %s", err.Error()) diff --git a/pkg/ffmpeg/transcoder/screenshot.go b/pkg/ffmpeg/transcoder/screenshot.go index c3343d594..c65f23941 100644 --- a/pkg/ffmpeg/transcoder/screenshot.go +++ b/pkg/ffmpeg/transcoder/screenshot.go @@ -9,7 +9,11 @@ type ScreenshotOptions struct { // Quality is the quality scale. See https://ffmpeg.org/ffmpeg.html#Main-options Quality int + // Width is the width to scale the screenshot to. If 0, no scaling will be applied. Width int + // Height is the height to scale the screenshot to. If 0, no scaling will be applied. + // Not used if Width is set. + Height int // Verbosity is the logging verbosity. Defaults to LogLevelError if not set. Verbosity ffmpeg.LogLevel @@ -70,6 +74,9 @@ func ScreenshotTime(input string, t float64, options ScreenshotOptions) ffmpeg.A if options.Width > 0 { vf = vf.ScaleWidth(options.Width) args = args.VideoFilter(vf) + } else if options.Height > 0 { + vf = vf.ScaleHeight(options.Height) + args = args.VideoFilter(vf) } args = args.AppendArgs(options.OutputType) diff --git a/pkg/scene/generate/sprite.go b/pkg/scene/generate/sprite.go index c3b10f680..e0dea9659 100644 --- a/pkg/scene/generate/sprite.go +++ b/pkg/scene/generate/sprite.go @@ -18,22 +18,19 @@ import ( "github.com/stashapp/stash/pkg/utils" ) -const ( - spriteScreenshotWidth = 160 - - spriteRows = 9 - spriteCols = 9 - spriteChunks = spriteRows * spriteCols -) - -func (g Generator) SpriteScreenshot(ctx context.Context, input string, seconds float64) (image.Image, error) { +func (g Generator) SpriteScreenshot(ctx context.Context, input string, seconds float64, size int, isPortrait bool) (image.Image, error) { lockCtx := g.LockManager.ReadLock(ctx, input) defer lockCtx.Cancel() ssOptions := transcoder.ScreenshotOptions{ OutputPath: "-", OutputType: transcoder.ScreenshotOutputTypeBMP, - Width: spriteScreenshotWidth, + } + + if !isPortrait { + ssOptions.Width = size + } else { + ssOptions.Height = size } args := transcoder.ScreenshotTime(input, seconds, ssOptions) @@ -41,14 +38,14 @@ func (g Generator) SpriteScreenshot(ctx context.Context, input string, seconds f return g.generateImage(lockCtx, args) } -func (g Generator) SpriteScreenshotSlow(ctx context.Context, input string, frame int) (image.Image, error) { +func (g Generator) SpriteScreenshotSlow(ctx context.Context, input string, frame int, width int) (image.Image, error) { lockCtx := g.LockManager.ReadLock(ctx, input) defer lockCtx.Cancel() ssOptions := transcoder.ScreenshotOptions{ OutputPath: "-", OutputType: transcoder.ScreenshotOutputTypeBMP, - Width: spriteScreenshotWidth, + Width: width, } args := transcoder.ScreenshotFrame(input, frame, ssOptions) @@ -74,12 +71,13 @@ func (g Generator) CombineSpriteImages(images []image.Image) image.Image { // Combine all of the thumbnails into a sprite image width := images[0].Bounds().Size().X height := images[0].Bounds().Size().Y - canvasWidth := width * spriteCols - canvasHeight := height * spriteRows + gridSize := GetSpriteGridSize(len(images)) + canvasWidth := width * gridSize + canvasHeight := height * gridSize montage := imaging.New(canvasWidth, canvasHeight, color.NRGBA{}) for index := 0; index < len(images); index++ { - x := width * (index % spriteCols) - y := height * int(math.Floor(float64(index)/float64(spriteRows))) + x := width * (index % gridSize) + y := height * int(math.Floor(float64(index)/float64(gridSize))) img := images[index] montage = imaging.Paste(montage, img, image.Pt(x, y)) } @@ -87,14 +85,19 @@ func (g Generator) CombineSpriteImages(images []image.Image) image.Image { return montage } -func (g Generator) SpriteVTT(ctx context.Context, output string, spritePath string, stepSize float64) error { - lockCtx := g.LockManager.ReadLock(ctx, spritePath) - defer lockCtx.Cancel() - - return g.generateFile(lockCtx, g.ScenePaths, vttPattern, output, g.spriteVTT(spritePath, stepSize)) +// GetSpriteGridSize return the required size of a grid, where the number of images in width +// equals the number of images in height, to hold 'imageCount' images +func GetSpriteGridSize(imageCount int) int { + return int(math.Ceil(math.Sqrt(float64(imageCount)))) } -func (g Generator) spriteVTT(spritePath string, stepSize float64) generateFn { +func (g Generator) SpriteVTT(ctx context.Context, output string, spritePath string, stepSize float64, spriteChunks int) error { + lockCtx := g.LockManager.ReadLock(ctx, spritePath) + defer lockCtx.Cancel() + return g.generateFile(lockCtx, g.ScenePaths, vttPattern, output, g.spriteVTT(spritePath, stepSize, spriteChunks)) +} + +func (g Generator) spriteVTT(spritePath string, stepSize float64, spriteChunks int) generateFn { return func(lockCtx *fsutil.LockContext, tmpFn string) error { spriteImage, err := os.Open(spritePath) if err != nil { @@ -106,16 +109,17 @@ func (g Generator) spriteVTT(spritePath string, stepSize float64) generateFn { if err != nil { return err } - width := image.Width / spriteCols - height := image.Height / spriteRows + + gridSize := GetSpriteGridSize(spriteChunks) + width := image.Width / gridSize + height := image.Height / gridSize vttLines := []string{"WEBVTT", ""} for index := 0; index < spriteChunks; index++ { - x := width * (index % spriteCols) - y := height * int(math.Floor(float64(index)/float64(spriteRows))) + x := width * (index % gridSize) + y := height * int(math.Floor(float64(index)/float64(gridSize))) startTime := utils.GetVTTTime(float64(index) * stepSize) endTime := utils.GetVTTTime(float64(index+1) * stepSize) - vttLines = append(vttLines, startTime+" --> "+endTime) vttLines = append(vttLines, fmt.Sprintf("%s#xywh=%d,%d,%d,%d", spriteImageName, x, y, width, height)) vttLines = append(vttLines, "") diff --git a/ui/v2.5/graphql/data/config.graphql b/ui/v2.5/graphql/data/config.graphql index ca1f6a47c..ba8215fe3 100644 --- a/ui/v2.5/graphql/data/config.graphql +++ b/ui/v2.5/graphql/data/config.graphql @@ -39,6 +39,11 @@ fragment ConfigGeneralData on ConfigGeneralResult { logLevel logAccess logFileMaxSize + useCustomSpriteInterval + spriteInterval + minimumSprites + maximumSprites + spriteScreenshotSize createGalleriesFromFolders galleryCoverRegex videoExtensions diff --git a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx index 143daca4f..8ecb6e557 100644 --- a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx +++ b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx @@ -67,6 +67,8 @@ export const PreviewScrubber: React.FC = ({ const clientRect = imageParent.getBoundingClientRect(); const scale = scaleToFit(sprite, clientRect); + const spriteSheet = new Image(); + spriteSheet.src = sprite.url; setStyle({ backgroundPosition: `${-sprite.x}px ${-sprite.y}px`, diff --git a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx index 34fb634b2..446ad09a1 100644 --- a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx @@ -427,6 +427,44 @@ export const SettingsConfigurationPanel: React.FC = () => { /> + + saveGeneral({ spriteScreenshotSize: v })} + /> + saveGeneral({ useCustomSpriteInterval: v })} + /> + saveGeneral({ spriteInterval: v })} + /> + saveGeneral({ minimumSprites: v })} + /> + saveGeneral({ maximumSprites: v })} + /> + + **⚠️ Note:** If this is set too high it will decrease overall performance and causes failures (out of memory). +## Sprite generation + +### Sprite size + +Fixed size of a generated sprite, being the longest dimension in pixels. +Setting this to `0` will fallback to the default of `160`. +Althought it is possible to set this value to anything bigger than `0` it is recommended to set it to `160` at least. + +### Use custom sprite generation + +If this setting is disabled, the settings below will be ignored and the default sprite generation settings are used. + +### Sprite interval + +This represents the time in seconds between each sprite to be generated. This value will be adjusted if necessary to fit within the bounds of the `Minimum Sprites` and `Maximum Sprites` settings. + +Setting this to `0` means that the sprite interval will be calculated based on the value of the `Minimum Sprites` field. + +### Minimum sprites + +The minimal number of distinct sprites that will be generated for a scene. `Sprite interval` will be adjusted if necessary. +Setting this to `0` will fallback to the default of `10` + +### Maximum sprites + +The maximum number of distinct sprites that will be generated for a scene. `Sprite interval` will be adjusted if necessary. +Setting this to `0` indicates there is no maximum. + +> **⚠️ Note:** The number of generated sprites is adjusted upwards to the next perfect square to ensure the sprite image is completely filled (no empty space in the grid) and the grid is as square as possible (minimizing the number of rows/columns). This means that if you set a minimum of 10 sprites, 16 will actually be generated, and if you set a maximum of 15 sprites, 16 will actually be generated. + ## Hardware accelerated live transcoding Hardware accelerated live transcoding can be enabled by setting the `FFmpeg hardware encoding` setting. Stash outputs the supported hardware encoders to the log file on startup at the Info log level. If a given hardware encoder is not supported, it's error message is logged to the Debug log level for debugging purposes. diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 2aee4fe2b..4bfd4322d 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -440,7 +440,18 @@ "heading": "Scrapers path" }, "scraping": "Scraping", + "sprite_generation_head": "Sprite generation", + "sprite_interval_desc": "Time between each generated sprite in seconds.", + "sprite_interval_head": "Sprite interval", + "sprite_maximum_desc": "Maximum number of sprites to be generated for a scene. Set to 0 to disable the limit.", + "sprite_maximum_head": "Maximum sprites", + "sprite_minimum_desc": "Minimum number of sprites to be generated for a scene", + "sprite_minimum_head": "Minimum sprites", + "sprite_screenshot_size_desc": "Desired size of each sprite in pixels.", + "sprite_screenshot_size_head": "Sprite size", "sqlite_location": "File location for the SQLite database (requires restart). WARNING: storing the database on a different system to where the Stash server is run from (i.e. over the network) is unsupported!", + "use_custom_sprite_interval_head": "Use custom sprite interval", + "use_custom_sprite_interval_desc": "Enable the custom sprite interval according to the settings below.", "video_ext_desc": "Comma-delimited list of file extensions that will be identified as videos.", "video_ext_head": "Video extensions", "video_head": "Video" From 47dcdd439cea335d80025c56e2ccc9f415a41a2c Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 23 Feb 2026 07:39:28 +1100 Subject: [PATCH 077/177] Backend support for gallery custom fields (#6592) --- graphql/schema/types/filters.graphql | 2 + graphql/schema/types/gallery.graphql | 7 + internal/api/loaders/dataloaders.go | 22 +- internal/api/resolver_model_gallery.go | 13 + internal/api/resolver_mutation_gallery.go | 17 +- internal/autotag/integration_test.go | 5 +- internal/manager/task_export.go | 7 + pkg/gallery/import.go | 20 +- pkg/gallery/scan.go | 7 +- pkg/image/scan.go | 15 +- pkg/models/gallery.go | 5 + pkg/models/jsonschema/gallery.go | 33 +-- pkg/models/mocks/GalleryReaderWriter.go | 74 +++++- pkg/models/model_gallery.go | 16 ++ pkg/models/repository_gallery.go | 7 +- pkg/sqlite/anonymise.go | 4 + pkg/sqlite/custom_fields_test.go | 6 + pkg/sqlite/database.go | 2 +- pkg/sqlite/gallery.go | 33 ++- pkg/sqlite/gallery_filter.go | 7 + pkg/sqlite/gallery_test.go | 248 +++++++++++++++++- .../81_gallery_custom_fields.up.sql | 9 + pkg/sqlite/setup_test.go | 18 +- pkg/sqlite/tables.go | 1 + 24 files changed, 528 insertions(+), 50 deletions(-) create mode 100644 pkg/sqlite/migrations/81_gallery_custom_fields.up.sql diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 075e40372..d683329b6 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -596,6 +596,8 @@ input GalleryFilterType { files_filter: FileFilterType "Filter by related folders that meet this criteria" folders_filter: FolderFilterType + + custom_fields: [CustomFieldCriterionInput!] } input TagFilterType { diff --git a/graphql/schema/types/gallery.graphql b/graphql/schema/types/gallery.graphql index f456157a7..e28c3802b 100644 --- a/graphql/schema/types/gallery.graphql +++ b/graphql/schema/types/gallery.graphql @@ -32,6 +32,7 @@ type Gallery { cover: Image paths: GalleryPathsType! # Resolver + custom_fields: Map! image(index: Int!): Image! } @@ -50,6 +51,8 @@ input GalleryCreateInput { studio_id: ID tag_ids: [ID!] performer_ids: [ID!] + + custom_fields: Map } input GalleryUpdateInput { @@ -71,6 +74,8 @@ input GalleryUpdateInput { performer_ids: [ID!] primary_file_id: ID + + custom_fields: CustomFieldsInput } input BulkGalleryUpdateInput { @@ -89,6 +94,8 @@ input BulkGalleryUpdateInput { studio_id: ID tag_ids: BulkUpdateIds performer_ids: BulkUpdateIds + + custom_fields: CustomFieldsInput } input GalleryDestroyInput { diff --git a/internal/api/loaders/dataloaders.go b/internal/api/loaders/dataloaders.go index 520714432..e7293ad1c 100644 --- a/internal/api/loaders/dataloaders.go +++ b/internal/api/loaders/dataloaders.go @@ -54,8 +54,9 @@ type Loaders struct { ImageFiles *ImageFileIDsLoader GalleryFiles *GalleryFileIDsLoader - GalleryByID *GalleryLoader - ImageByID *ImageLoader + GalleryByID *GalleryLoader + GalleryCustomFields *CustomFieldsLoader + ImageByID *ImageLoader PerformerByID *PerformerLoader PerformerCustomFields *CustomFieldsLoader @@ -88,6 +89,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler { maxBatch: maxBatch, fetch: m.fetchGalleries(ctx), }, + GalleryCustomFields: &CustomFieldsLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchGalleryCustomFields(ctx), + }, ImageByID: &ImageLoader{ wait: wait, maxBatch: maxBatch, @@ -319,6 +325,18 @@ func (m Middleware) fetchTagCustomFields(ctx context.Context) func(keys []int) ( } } +func (m Middleware) fetchGalleryCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) { + return func(keys []int) (ret []models.CustomFieldMap, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Gallery.GetCustomFieldsBulk(ctx, keys) + return err + }) + + return ret, toErrorSlice(err) + } +} + func (m Middleware) fetchGroups(ctx context.Context) func(keys []int) ([]*models.Group, []error) { return func(keys []int) (ret []*models.Group, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { diff --git a/internal/api/resolver_model_gallery.go b/internal/api/resolver_model_gallery.go index 9dc68b4c4..773a831d8 100644 --- a/internal/api/resolver_model_gallery.go +++ b/internal/api/resolver_model_gallery.go @@ -216,3 +216,16 @@ func (r *galleryResolver) Image(ctx context.Context, obj *models.Gallery, index return } + +func (r *galleryResolver) CustomFields(ctx context.Context, obj *models.Gallery) (map[string]interface{}, error) { + m, err := loaders.From(ctx).GalleryCustomFields.Load(obj.ID) + if err != nil { + return nil, err + } + + if m == nil { + return make(map[string]interface{}), nil + } + + return m, nil +} diff --git a/internal/api/resolver_mutation_gallery.go b/internal/api/resolver_mutation_gallery.go index e7f853922..2cd80b1ff 100644 --- a/internal/api/resolver_mutation_gallery.go +++ b/internal/api/resolver_mutation_gallery.go @@ -42,7 +42,10 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreat } // Populate a new gallery from the input - newGallery := models.NewGallery() + newGallery := models.CreateGalleryInput{ + Gallery: &models.Gallery{}, + } + *newGallery.Gallery = models.NewGallery() newGallery.Title = strings.TrimSpace(input.Title) newGallery.Code = translator.string(input.Code) @@ -81,10 +84,12 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreat newGallery.URLs = models.NewRelatedStrings([]string{strings.TrimSpace(*input.URL)}) } + newGallery.CustomFields = convertMapJSONNumbers(input.CustomFields) + // Start the transaction and save the gallery if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Gallery - if err := qb.Create(ctx, &newGallery, nil); err != nil { + if err := qb.Create(ctx, &newGallery); err != nil { return err } @@ -241,6 +246,10 @@ func (r *mutationResolver) galleryUpdate(ctx context.Context, input models.Galle return nil, fmt.Errorf("converting scene ids: %w", err) } + if input.CustomFields != nil { + updatedGallery.CustomFields = handleUpdateCustomFields(*input.CustomFields) + } + // gallery scene is set from the scene only gallery, err := qb.UpdatePartial(ctx, galleryID, updatedGallery) @@ -293,6 +302,10 @@ func (r *mutationResolver) BulkGalleryUpdate(ctx context.Context, input BulkGall return nil, fmt.Errorf("converting scene ids: %w", err) } + if input.CustomFields != nil { + updatedGallery.CustomFields = handleUpdateCustomFields(*input.CustomFields) + } + ret := []*models.Gallery{} // Start the transaction and save the galleries diff --git a/internal/autotag/integration_test.go b/internal/autotag/integration_test.go index 27cce014e..9745d623e 100644 --- a/internal/autotag/integration_test.go +++ b/internal/autotag/integration_test.go @@ -468,7 +468,10 @@ func makeGallery(expectedResult bool) *models.Gallery { } func createGallery(ctx context.Context, w models.GalleryWriter, o *models.Gallery, f *models.BaseFile) error { - err := w.Create(ctx, o, []models.FileID{f.ID}) + err := w.Create(ctx, &models.CreateGalleryInput{ + Gallery: o, + FileIDs: []models.FileID{f.ID}, + }) if err != nil { return fmt.Errorf("Failed to create gallery with path '%s': %s", f.Path, err.Error()) } diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go index 5f2897670..30adf626b 100644 --- a/internal/manager/task_export.go +++ b/internal/manager/task_export.go @@ -779,6 +779,7 @@ func (t *ExportTask) exportGallery(ctx context.Context, wg *sync.WaitGroup, jobC studioReader := r.Studio performerReader := r.Performer tagReader := r.Tag + galleryReader := r.Gallery galleryChapterReader := r.GalleryChapter for g := range jobChan { @@ -847,6 +848,12 @@ func (t *ExportTask) exportGallery(ctx context.Context, wg *sync.WaitGroup, jobC newGalleryJSON.Tags = tag.GetNames(tags) + newGalleryJSON.CustomFields, err = galleryReader.GetCustomFields(ctx, g.ID) + if err != nil { + logger.Errorf("[galleries] <%s> error getting gallery custom fields: %v", g.DisplayName(), err) + continue + } + if t.includeDependencies { if g.StudioID != nil { t.studios.IDs = sliceutil.AppendUnique(t.studios.IDs, *g.StudioID) diff --git a/pkg/gallery/import.go b/pkg/gallery/import.go index 22f3e6c44..e33297bdb 100644 --- a/pkg/gallery/import.go +++ b/pkg/gallery/import.go @@ -28,8 +28,9 @@ type Importer struct { Input jsonschema.Gallery MissingRefBehaviour models.ImportMissingRefEnum - ID int - gallery models.Gallery + ID int + gallery models.Gallery + customFields map[string]interface{} } func (i *Importer) PreImport(ctx context.Context) error { @@ -51,6 +52,8 @@ func (i *Importer) PreImport(ctx context.Context) error { return err } + i.customFields = i.Input.CustomFields + return nil } @@ -356,7 +359,11 @@ func (i *Importer) Create(ctx context.Context) (*int, error) { for _, f := range i.gallery.Files.List() { fileIDs = append(fileIDs, f.Base().ID) } - err := i.ReaderWriter.Create(ctx, &i.gallery, fileIDs) + err := i.ReaderWriter.Create(ctx, &models.CreateGalleryInput{ + Gallery: &i.gallery, + FileIDs: fileIDs, + CustomFields: i.customFields, + }) if err != nil { return nil, fmt.Errorf("error creating gallery: %v", err) } @@ -368,7 +375,12 @@ func (i *Importer) Create(ctx context.Context) (*int, error) { func (i *Importer) Update(ctx context.Context, id int) error { gallery := i.gallery gallery.ID = id - err := i.ReaderWriter.Update(ctx, &gallery) + err := i.ReaderWriter.Update(ctx, &models.UpdateGalleryInput{ + Gallery: &gallery, + CustomFields: models.CustomFieldsInput{ + Full: i.customFields, + }, + }) if err != nil { return fmt.Errorf("error updating existing gallery: %v", err) } diff --git a/pkg/gallery/scan.go b/pkg/gallery/scan.go index 9d0313b17..2064355cd 100644 --- a/pkg/gallery/scan.go +++ b/pkg/gallery/scan.go @@ -17,7 +17,7 @@ type ScanCreatorUpdater interface { FindByFingerprints(ctx context.Context, fp []models.Fingerprint) ([]*models.Gallery, error) GetFiles(ctx context.Context, relatedID int) ([]models.File, error) - Create(ctx context.Context, newGallery *models.Gallery, fileIDs []models.FileID) error + models.GalleryCreator UpdatePartial(ctx context.Context, id int, updatedGallery models.GalleryPartial) (*models.Gallery, error) AddFileID(ctx context.Context, id int, fileID models.FileID) error } @@ -80,7 +80,10 @@ func (h *ScanHandler) Handle(ctx context.Context, f models.File, oldFile models. logger.Infof("%s doesn't exist. Creating new gallery...", f.Base().Path) - if err := h.CreatorUpdater.Create(ctx, &newGallery, []models.FileID{baseFile.ID}); err != nil { + if err := h.CreatorUpdater.Create(ctx, &models.CreateGalleryInput{ + Gallery: &newGallery, + FileIDs: []models.FileID{baseFile.ID}, + }); err != nil { return fmt.Errorf("creating new gallery: %w", err) } diff --git a/pkg/image/scan.go b/pkg/image/scan.go index a6002057f..67f4b334c 100644 --- a/pkg/image/scan.go +++ b/pkg/image/scan.go @@ -35,7 +35,7 @@ type ScanCreatorUpdater interface { type GalleryFinderCreator interface { FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Gallery, error) FindByFolderID(ctx context.Context, folderID models.FolderID) ([]*models.Gallery, error) - Create(ctx context.Context, newObject *models.Gallery, fileIDs []models.FileID) error + models.GalleryCreator UpdatePartial(ctx context.Context, id int, updatedGallery models.GalleryPartial) (*models.Gallery, error) } @@ -252,9 +252,13 @@ func (h *ScanHandler) getOrCreateFolderBasedGallery(ctx context.Context, f model newGallery := models.NewGallery() newGallery.FolderID = &folderID + input := models.CreateGalleryInput{ + Gallery: &newGallery, + } + logger.Infof("Creating folder-based gallery for %s", filepath.Dir(f.Base().Path)) - if err := h.GalleryFinder.Create(ctx, &newGallery, nil); err != nil { + if err := h.GalleryFinder.Create(ctx, &input); err != nil { return nil, fmt.Errorf("creating folder based gallery: %w", err) } @@ -308,7 +312,12 @@ func (h *ScanHandler) getOrCreateZipBasedGallery(ctx context.Context, zipFile mo logger.Infof("%s doesn't exist. Creating new gallery...", zipFile.Base().Path) - if err := h.GalleryFinder.Create(ctx, &newGallery, []models.FileID{zipFile.Base().ID}); err != nil { + input := models.CreateGalleryInput{ + Gallery: &newGallery, + FileIDs: []models.FileID{zipFile.Base().ID}, + } + + if err := h.GalleryFinder.Create(ctx, &input); err != nil { return nil, fmt.Errorf("creating zip-based gallery: %w", err) } diff --git a/pkg/models/gallery.go b/pkg/models/gallery.go index dfc776afe..8f335020a 100644 --- a/pkg/models/gallery.go +++ b/pkg/models/gallery.go @@ -67,6 +67,9 @@ type GalleryFilterType struct { CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at UpdatedAt *TimestampCriterionInput `json:"updated_at"` + + // Filter by custom fields + CustomFields []CustomFieldCriterionInput `json:"custom_fields"` } type GalleryUpdateInput struct { @@ -86,6 +89,8 @@ type GalleryUpdateInput struct { PerformerIds []string `json:"performer_ids"` PrimaryFileID *string `json:"primary_file_id"` + CustomFields *CustomFieldsInput `json:"custom_fields"` + // deprecated URL *string `json:"url"` } diff --git a/pkg/models/jsonschema/gallery.go b/pkg/models/jsonschema/gallery.go index 7323e37ba..5fb6e16ab 100644 --- a/pkg/models/jsonschema/gallery.go +++ b/pkg/models/jsonschema/gallery.go @@ -18,22 +18,23 @@ type GalleryChapter struct { } type Gallery struct { - ZipFiles []string `json:"zip_files,omitempty"` - FolderPath string `json:"folder_path,omitempty"` - Title string `json:"title,omitempty"` - Code string `json:"code,omitempty"` - URLs []string `json:"urls,omitempty"` - Date string `json:"date,omitempty"` - Details string `json:"details,omitempty"` - Photographer string `json:"photographer,omitempty"` - Rating int `json:"rating,omitempty"` - Organized bool `json:"organized,omitempty"` - Chapters []GalleryChapter `json:"chapters,omitempty"` - Studio string `json:"studio,omitempty"` - Performers []string `json:"performers,omitempty"` - Tags []string `json:"tags,omitempty"` - CreatedAt json.JSONTime `json:"created_at,omitempty"` - UpdatedAt json.JSONTime `json:"updated_at,omitempty"` + ZipFiles []string `json:"zip_files,omitempty"` + FolderPath string `json:"folder_path,omitempty"` + Title string `json:"title,omitempty"` + Code string `json:"code,omitempty"` + URLs []string `json:"urls,omitempty"` + Date string `json:"date,omitempty"` + Details string `json:"details,omitempty"` + Photographer string `json:"photographer,omitempty"` + Rating int `json:"rating,omitempty"` + Organized bool `json:"organized,omitempty"` + Chapters []GalleryChapter `json:"chapters,omitempty"` + Studio string `json:"studio,omitempty"` + Performers []string `json:"performers,omitempty"` + Tags []string `json:"tags,omitempty"` + CreatedAt json.JSONTime `json:"created_at,omitempty"` + UpdatedAt json.JSONTime `json:"updated_at,omitempty"` + CustomFields map[string]interface{} `json:"custom_fields,omitempty"` // deprecated - for import only URL string `json:"url,omitempty"` diff --git a/pkg/models/mocks/GalleryReaderWriter.go b/pkg/models/mocks/GalleryReaderWriter.go index f07f8a7d9..f20d9f76e 100644 --- a/pkg/models/mocks/GalleryReaderWriter.go +++ b/pkg/models/mocks/GalleryReaderWriter.go @@ -114,13 +114,13 @@ func (_m *GalleryReaderWriter) CountByFileID(ctx context.Context, fileID models. return r0, r1 } -// Create provides a mock function with given fields: ctx, newGallery, fileIDs -func (_m *GalleryReaderWriter) Create(ctx context.Context, newGallery *models.Gallery, fileIDs []models.FileID) error { - ret := _m.Called(ctx, newGallery, fileIDs) +// Create provides a mock function with given fields: ctx, newGallery +func (_m *GalleryReaderWriter) Create(ctx context.Context, newGallery *models.CreateGalleryInput) error { + ret := _m.Called(ctx, newGallery) var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *models.Gallery, []models.FileID) error); ok { - r0 = rf(ctx, newGallery, fileIDs) + if rf, ok := ret.Get(0).(func(context.Context, *models.CreateGalleryInput) error); ok { + r0 = rf(ctx, newGallery) } else { r0 = ret.Error(0) } @@ -395,6 +395,52 @@ func (_m *GalleryReaderWriter) FindUserGalleryByTitle(ctx context.Context, title return r0, r1 } +// GetCustomFields provides a mock function with given fields: ctx, id +func (_m *GalleryReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) { + ret := _m.Called(ctx, id) + + var r0 map[string]interface{} + if rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]interface{}) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetCustomFieldsBulk provides a mock function with given fields: ctx, ids +func (_m *GalleryReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) { + ret := _m.Called(ctx, ids) + + var r0 []models.CustomFieldMap + if rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok { + r0 = rf(ctx, ids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.CustomFieldMap) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { + r1 = rf(ctx, ids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetFiles provides a mock function with given fields: ctx, relatedID func (_m *GalleryReaderWriter) GetFiles(ctx context.Context, relatedID int) ([]models.File, error) { ret := _m.Called(ctx, relatedID) @@ -656,12 +702,26 @@ func (_m *GalleryReaderWriter) SetCover(ctx context.Context, galleryID int, cove return r0 } +// SetCustomFields provides a mock function with given fields: ctx, id, fields +func (_m *GalleryReaderWriter) SetCustomFields(ctx context.Context, id int, fields models.CustomFieldsInput) error { + ret := _m.Called(ctx, id, fields) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, models.CustomFieldsInput) error); ok { + r0 = rf(ctx, id, fields) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Update provides a mock function with given fields: ctx, updatedGallery -func (_m *GalleryReaderWriter) Update(ctx context.Context, updatedGallery *models.Gallery) error { +func (_m *GalleryReaderWriter) Update(ctx context.Context, updatedGallery *models.UpdateGalleryInput) error { ret := _m.Called(ctx, updatedGallery) var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *models.Gallery) error); ok { + if rf, ok := ret.Get(0).(func(context.Context, *models.UpdateGalleryInput) error); ok { r0 = rf(ctx, updatedGallery) } else { r0 = ret.Error(0) diff --git a/pkg/models/model_gallery.go b/pkg/models/model_gallery.go index 4b6a3183d..bbdba46a6 100644 --- a/pkg/models/model_gallery.go +++ b/pkg/models/model_gallery.go @@ -46,6 +46,20 @@ func NewGallery() Gallery { } } +type CreateGalleryInput struct { + *Gallery + + FileIDs []FileID + CustomFields map[string]interface{} `json:"custom_fields"` +} + +type UpdateGalleryInput struct { + *Gallery + + FileIDs []FileID + CustomFields CustomFieldsInput `json:"custom_fields"` +} + // GalleryPartial represents part of a Gallery object. It is used to update // the database entry. Only non-nil fields will be updated. type GalleryPartial struct { @@ -70,6 +84,8 @@ type GalleryPartial struct { TagIDs *UpdateIDs PerformerIDs *UpdateIDs PrimaryFileID *FileID + + CustomFields CustomFieldsInput } func NewGalleryPartial() GalleryPartial { diff --git a/pkg/models/repository_gallery.go b/pkg/models/repository_gallery.go index 0cfb9964f..b8f1452f3 100644 --- a/pkg/models/repository_gallery.go +++ b/pkg/models/repository_gallery.go @@ -37,12 +37,12 @@ type GalleryCounter interface { // GalleryCreator provides methods to create galleries. type GalleryCreator interface { - Create(ctx context.Context, newGallery *Gallery, fileIDs []FileID) error + Create(ctx context.Context, newGallery *CreateGalleryInput) error } // GalleryUpdater provides methods to update galleries. type GalleryUpdater interface { - Update(ctx context.Context, updatedGallery *Gallery) error + Update(ctx context.Context, updatedGallery *UpdateGalleryInput) error UpdatePartial(ctx context.Context, id int, updatedGallery GalleryPartial) (*Gallery, error) UpdateImages(ctx context.Context, galleryID int, imageIDs []int) error } @@ -70,6 +70,7 @@ type GalleryReader interface { PerformerIDLoader TagIDLoader FileLoader + CustomFieldsReader All(ctx context.Context) ([]*Gallery, error) } @@ -80,6 +81,8 @@ type GalleryWriter interface { GalleryUpdater GalleryDestroyer + CustomFieldsWriter + AddFileID(ctx context.Context, id int, fileID FileID) error AddImages(ctx context.Context, galleryID int, imageIDs ...int) error RemoveImages(ctx context.Context, galleryID int, imageIDs ...int) error diff --git a/pkg/sqlite/anonymise.go b/pkg/sqlite/anonymise.go index e0a354980..6a5cd4da5 100644 --- a/pkg/sqlite/anonymise.go +++ b/pkg/sqlite/anonymise.go @@ -522,6 +522,10 @@ func (db *Anonymiser) anonymiseGalleries(ctx context.Context) error { return err } + if err := db.anonymiseCustomFields(ctx, goqu.T(galleriesCustomFieldsTable.GetTable()), "gallery_id"); err != nil { + return err + } + return nil } diff --git a/pkg/sqlite/custom_fields_test.go b/pkg/sqlite/custom_fields_test.go index a2c045851..ae4a276f7 100644 --- a/pkg/sqlite/custom_fields_test.go +++ b/pkg/sqlite/custom_fields_test.go @@ -240,3 +240,9 @@ func TestSceneSetCustomFields(t *testing.T) { testSetCustomFields(t, "Scene", db.Scene, sceneIDs[sceneIdx], getSceneCustomFields(sceneIdx)) } + +func TestGallerySetCustomFields(t *testing.T) { + galleryIdx := galleryIdxWithScene + + testSetCustomFields(t, "Gallery", db.Gallery, galleryIDs[galleryIdx], getGalleryCustomFields(galleryIdx)) +} diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 5b67e5602..d95832836 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 80 +var appSchemaVersion uint = 81 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 41729057b..305b1fe09 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -183,6 +183,8 @@ var ( ) type GalleryStore struct { + customFieldsStore + tableMgr *table fileStore *FileStore @@ -191,6 +193,10 @@ type GalleryStore struct { func NewGalleryStore(fileStore *FileStore, folderStore *FolderStore) *GalleryStore { return &GalleryStore{ + customFieldsStore: customFieldsStore{ + table: galleriesCustomFieldsTable, + fk: galleriesCustomFieldsTable.Col(galleryIDColumn), + }, tableMgr: galleryTableMgr, fileStore: fileStore, folderStore: folderStore, @@ -231,18 +237,18 @@ func (qb *GalleryStore) selectDataset() *goqu.SelectDataset { ) } -func (qb *GalleryStore) Create(ctx context.Context, newObject *models.Gallery, fileIDs []models.FileID) error { +func (qb *GalleryStore) Create(ctx context.Context, newObject *models.CreateGalleryInput) error { var r galleryRow - r.fromGallery(*newObject) + r.fromGallery(*newObject.Gallery) id, err := qb.tableMgr.insertID(ctx, r) if err != nil { return err } - if len(fileIDs) > 0 { + if len(newObject.FileIDs) > 0 { const firstPrimary = true - if err := galleriesFilesTableMgr.insertJoins(ctx, id, firstPrimary, fileIDs); err != nil { + if err := galleriesFilesTableMgr.insertJoins(ctx, id, firstPrimary, newObject.FileIDs); err != nil { return err } } @@ -269,19 +275,24 @@ func (qb *GalleryStore) Create(ctx context.Context, newObject *models.Gallery, f } } + const partial = false + if err := qb.setCustomFields(ctx, id, newObject.CustomFields, partial); err != nil { + return err + } + updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) } - *newObject = *updated + *newObject.Gallery = *updated return nil } -func (qb *GalleryStore) Update(ctx context.Context, updatedObject *models.Gallery) error { +func (qb *GalleryStore) Update(ctx context.Context, updatedObject *models.UpdateGalleryInput) error { var r galleryRow - r.fromGallery(*updatedObject) + r.fromGallery(*updatedObject.Gallery) if err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { return err @@ -319,6 +330,10 @@ func (qb *GalleryStore) Update(ctx context.Context, updatedObject *models.Galler } } + if err := qb.SetCustomFields(ctx, updatedObject.ID, updatedObject.CustomFields); err != nil { + return err + } + return nil } @@ -364,6 +379,10 @@ func (qb *GalleryStore) UpdatePartial(ctx context.Context, id int, partial model } } + if err := qb.SetCustomFields(ctx, id, partial.CustomFields); err != nil { + return nil, err + } + return qb.find(ctx, id) } diff --git a/pkg/sqlite/gallery_filter.go b/pkg/sqlite/gallery_filter.go index f05ff7b81..f920e442a 100644 --- a/pkg/sqlite/gallery_filter.go +++ b/pkg/sqlite/gallery_filter.go @@ -105,6 +105,13 @@ func (qb *galleryFilterHandler) criterionHandler() criterionHandler { ×tampCriterionHandler{filter.CreatedAt, "galleries.created_at", nil}, ×tampCriterionHandler{filter.UpdatedAt, "galleries.updated_at", nil}, + &customFieldsFilterHandler{ + table: galleriesCustomFieldsTable.GetTable(), + fkCol: galleryIDColumn, + c: filter.CustomFields, + idCol: "galleries.id", + }, + &relatedFilterHandler{ relatedIDCol: "scenes_galleries.scene_id", relatedRepo: sceneRepository.repository, diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index 06d7daf17..4156f129c 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -160,7 +160,10 @@ func Test_galleryQueryBuilder_Create(t *testing.T) { fileIDs = []models.FileID{s.Files.List()[0].Base().ID} } - if err := qb.Create(ctx, &s, fileIDs); (err != nil) != tt.wantErr { + if err := qb.Create(ctx, &models.CreateGalleryInput{ + Gallery: &s, + FileIDs: fileIDs, + }); (err != nil) != tt.wantErr { t.Errorf("galleryQueryBuilder.Create() error = %v, wantErr = %v", err, tt.wantErr) } @@ -360,7 +363,9 @@ func Test_galleryQueryBuilder_Update(t *testing.T) { copy := *tt.updatedObject - if err := qb.Update(ctx, tt.updatedObject); (err != nil) != tt.wantErr { + if err := qb.Update(ctx, &models.UpdateGalleryInput{ + Gallery: tt.updatedObject, + }); (err != nil) != tt.wantErr { t.Errorf("galleryQueryBuilder.Update() error = %v, wantErr %v", err, tt.wantErr) } @@ -3001,6 +3006,245 @@ func TestGallerySetAndResetCover(t *testing.T) { }) } +func TestGalleryQueryCustomFields(t *testing.T) { + tests := []struct { + name string + filter *models.GalleryFilterType + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "equals", + &models.GalleryFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierEquals, + Value: []any{getGalleryStringValue(galleryIdxWithImage, "custom")}, + }, + }, + }, + []int{galleryIdxWithImage}, + nil, + false, + }, + { + "not equals", + &models.GalleryFilterType{ + Title: &models.StringCriterionInput{ + Value: getGalleryStringValue(galleryIdxWithImage, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotEquals, + Value: []any{getGalleryStringValue(galleryIdxWithImage, "custom")}, + }, + }, + }, + nil, + []int{galleryIdxWithImage}, + false, + }, + { + "includes", + &models.GalleryFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierIncludes, + Value: []any{getGalleryStringValue(galleryIdxWithImage, "custom")[9:]}, + }, + }, + }, + []int{galleryIdxWithImage}, + nil, + false, + }, + { + "excludes", + &models.GalleryFilterType{ + Title: &models.StringCriterionInput{ + Value: getGalleryStringValue(galleryIdxWithImage, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierExcludes, + Value: []any{getGalleryStringValue(galleryIdxWithImage, "custom")[9:]}, + }, + }, + }, + nil, + []int{galleryIdxWithImage}, + false, + }, + { + "regex", + &models.GalleryFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierMatchesRegex, + Value: []any{".*17_custom"}, + }, + }, + }, + []int{galleryIdxWithPerformerTag}, + nil, + false, + }, + { + "invalid regex", + &models.GalleryFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierMatchesRegex, + Value: []any{"["}, + }, + }, + }, + nil, + nil, + true, + }, + { + "not matches regex", + &models.GalleryFilterType{ + Title: &models.StringCriterionInput{ + Value: getGalleryStringValue(galleryIdxWithPerformerTag, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotMatchesRegex, + Value: []any{".*17_custom"}, + }, + }, + }, + nil, + []int{galleryIdxWithPerformerTag}, + false, + }, + { + "invalid not matches regex", + &models.GalleryFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotMatchesRegex, + Value: []any{"["}, + }, + }, + }, + nil, + nil, + true, + }, + { + "null", + &models.GalleryFilterType{ + Title: &models.StringCriterionInput{ + Value: getGalleryStringValue(galleryIdxWithImage, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "not existing", + Modifier: models.CriterionModifierIsNull, + }, + }, + }, + []int{galleryIdxWithImage}, + nil, + false, + }, + { + "not null", + &models.GalleryFilterType{ + Title: &models.StringCriterionInput{ + Value: getGalleryStringValue(galleryIdxWithImage, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotNull, + }, + }, + }, + []int{galleryIdxWithImage}, + nil, + false, + }, + { + "between", + &models.GalleryFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "real", + Modifier: models.CriterionModifierBetween, + Value: []any{0.15, 0.25}, + }, + }, + }, + []int{galleryIdxWithImage}, + nil, + false, + }, + { + "not between", + &models.GalleryFilterType{ + Title: &models.StringCriterionInput{ + Value: getGalleryStringValue(galleryIdxWithImage, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "real", + Modifier: models.CriterionModifierNotBetween, + Value: []any{0.15, 0.25}, + }, + }, + }, + nil, + []int{galleryIdxWithImage}, + false, + }, + } + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + galleries, _, err := db.Gallery.Query(ctx, tt.filter, nil) + if (err != nil) != tt.wantErr { + t.Errorf("GalleryStore.Query() error = %v, wantErr %v", err, tt.wantErr) + } + + if err != nil { + return + } + + ids := galleriesToIDs(galleries) + include := indexesToIDs(galleryIDs, tt.includeIdxs) + exclude := indexesToIDs(galleryIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } +} + // TODO Count // TODO All // TODO Query diff --git a/pkg/sqlite/migrations/81_gallery_custom_fields.up.sql b/pkg/sqlite/migrations/81_gallery_custom_fields.up.sql new file mode 100644 index 000000000..89a6e4c05 --- /dev/null +++ b/pkg/sqlite/migrations/81_gallery_custom_fields.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE `gallery_custom_fields` ( + `gallery_id` integer NOT NULL, + `field` varchar(64) NOT NULL, + `value` BLOB NOT NULL, + PRIMARY KEY (`gallery_id`, `field`), + foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE +); + +CREATE INDEX `index_gallery_custom_fields_field_value` ON `gallery_custom_fields` (`field`, `value`); diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 91f9f127b..675b3f417 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1389,6 +1389,18 @@ func makeGallery(i int, includeScenes bool) *models.Gallery { return ret } +func getGalleryCustomFields(index int) map[string]interface{} { + if index%5 == 0 { + return nil + } + + return map[string]interface{}{ + "string": getGalleryStringValue(index, "custom"), + "int": int64(index % 5), + "real": float64(index) / 10, + } +} + func createGalleries(ctx context.Context, n int) error { gqb := db.Gallery fqb := db.File @@ -1410,7 +1422,11 @@ func createGalleries(ctx context.Context, n int) error { const includeScenes = false gallery := makeGallery(i, includeScenes) - err := gqb.Create(ctx, gallery, fileIDs) + err := gqb.Create(ctx, &models.CreateGalleryInput{ + Gallery: gallery, + FileIDs: fileIDs, + CustomFields: getGalleryCustomFields(i), + }) if err != nil { return fmt.Errorf("Error creating gallery %v+: %s", gallery, err.Error()) diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 53e62b166..7867054ba 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -20,6 +20,7 @@ var ( performersGalleriesJoinTable = goqu.T(performersGalleriesTable) galleriesScenesJoinTable = goqu.T(galleriesScenesTable) galleriesURLsJoinTable = goqu.T(galleriesURLsTable) + galleriesCustomFieldsTable = goqu.T("gallery_custom_fields") scenesFilesJoinTable = goqu.T(scenesFilesTable) scenesTagsJoinTable = goqu.T(scenesTagsTable) From ca5178f05ebd5f702e5c20e660a3c4c062ed0335 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:53:12 +1100 Subject: [PATCH 078/177] Backend support for Group custom fields (#6596) --- graphql/schema/types/filters.graphql | 3 + graphql/schema/types/group.graphql | 7 + internal/api/loaders/dataloaders.go | 28 +- internal/api/resolver_model_movie.go | 13 + internal/api/resolver_mutation_group.go | 57 ++-- internal/manager/repository.go | 2 +- pkg/group/create.go | 24 +- pkg/group/export.go | 60 ++-- pkg/group/export_test.go | 62 +++- pkg/group/import.go | 9 + pkg/group/import_test.go | 21 +- pkg/group/service.go | 1 + pkg/models/group.go | 2 + pkg/models/jsonschema/group.go | 2 + pkg/models/mocks/GroupReaderWriter.go | 60 ++++ pkg/models/model_group.go | 10 + pkg/models/repository_group.go | 2 + pkg/sqlite/anonymise.go | 4 + pkg/sqlite/custom_fields_test.go | 8 +- pkg/sqlite/database.go | 2 +- pkg/sqlite/gallery_test.go | 73 ++++ pkg/sqlite/group.go | 9 + pkg/sqlite/group_filter.go | 7 + pkg/sqlite/group_test.go | 312 ++++++++++++++++++ .../migrations/82_group_custom_fields.up.sql | 9 + pkg/sqlite/setup_test.go | 19 ++ pkg/sqlite/tables.go | 1 + 27 files changed, 725 insertions(+), 82 deletions(-) create mode 100644 pkg/sqlite/migrations/82_group_custom_fields.up.sql diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index d683329b6..4162f0af3 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -462,6 +462,9 @@ input GroupFilterType { scenes_filter: SceneFilterType "Filter by related studios that meet this criteria" studios_filter: StudioFilterType + + "Filter by custom fields" + custom_fields: [CustomFieldCriterionInput!] } input StudioFilterType { diff --git a/graphql/schema/types/group.graphql b/graphql/schema/types/group.graphql index a46932054..a1c878923 100644 --- a/graphql/schema/types/group.graphql +++ b/graphql/schema/types/group.graphql @@ -31,6 +31,7 @@ type Group { sub_group_count(depth: Int): Int! # Resolver scenes: [Scene!]! o_counter: Int # Resolver + custom_fields: Map! } input GroupDescriptionInput { @@ -59,6 +60,8 @@ input GroupCreateInput { front_image: String "This should be a URL or a base64 encoded data URL" back_image: String + + custom_fields: Map } input GroupUpdateInput { @@ -82,6 +85,8 @@ input GroupUpdateInput { front_image: String "This should be a URL or a base64 encoded data URL" back_image: String + + custom_fields: CustomFieldsInput } input BulkUpdateGroupDescriptionsInput { @@ -101,6 +106,8 @@ input BulkGroupUpdateInput { containing_groups: BulkUpdateGroupDescriptionsInput sub_groups: BulkUpdateGroupDescriptionsInput + + custom_fields: CustomFieldsInput } input GroupDestroyInput { diff --git a/internal/api/loaders/dataloaders.go b/internal/api/loaders/dataloaders.go index e7293ad1c..ff8a87ab0 100644 --- a/internal/api/loaders/dataloaders.go +++ b/internal/api/loaders/dataloaders.go @@ -64,11 +64,12 @@ type Loaders struct { StudioByID *StudioLoader StudioCustomFields *CustomFieldsLoader - TagByID *TagLoader - TagCustomFields *CustomFieldsLoader - GroupByID *GroupLoader - FileByID *FileLoader - FolderByID *FolderLoader + TagByID *TagLoader + TagCustomFields *CustomFieldsLoader + GroupByID *GroupLoader + GroupCustomFields *CustomFieldsLoader + FileByID *FileLoader + FolderByID *FolderLoader } type Middleware struct { @@ -139,6 +140,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler { maxBatch: maxBatch, fetch: m.fetchGroups(ctx), }, + GroupCustomFields: &CustomFieldsLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchGroupCustomFields(ctx), + }, FileByID: &FileLoader{ wait: wait, maxBatch: maxBatch, @@ -325,6 +331,18 @@ func (m Middleware) fetchTagCustomFields(ctx context.Context) func(keys []int) ( } } +func (m Middleware) fetchGroupCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) { + return func(keys []int) (ret []models.CustomFieldMap, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Group.GetCustomFieldsBulk(ctx, keys) + return err + }) + + return ret, toErrorSlice(err) + } +} + func (m Middleware) fetchGalleryCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) { return func(keys []int) (ret []models.CustomFieldMap, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { diff --git a/internal/api/resolver_model_movie.go b/internal/api/resolver_model_movie.go index 317123c6e..287d5d51a 100644 --- a/internal/api/resolver_model_movie.go +++ b/internal/api/resolver_model_movie.go @@ -215,3 +215,16 @@ func (r *groupResolver) OCounter(ctx context.Context, obj *models.Group) (ret *i } return &count, nil } + +func (r *groupResolver) CustomFields(ctx context.Context, obj *models.Group) (map[string]interface{}, error) { + m, err := loaders.From(ctx).GroupCustomFields.Load(obj.ID) + if err != nil { + return nil, err + } + + if m == nil { + return make(map[string]interface{}), nil + } + + return m, nil +} diff --git a/internal/api/resolver_mutation_group.go b/internal/api/resolver_mutation_group.go index 14dc817b9..dff5a6c1e 100644 --- a/internal/api/resolver_mutation_group.go +++ b/internal/api/resolver_mutation_group.go @@ -14,13 +14,17 @@ import ( "github.com/stashapp/stash/pkg/utils" ) -func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*models.Group, error) { +func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*models.CreateGroupInput, error) { translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } // Populate a new group from the input - newGroup := models.NewGroup() + newGroupInput := &models.CreateGroupInput{ + Group: &models.Group{}, + } + *newGroupInput.Group = models.NewGroup() + newGroup := newGroupInput.Group newGroup.Name = strings.TrimSpace(input.Name) newGroup.Aliases = translator.string(input.Aliases) @@ -59,28 +63,19 @@ func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*mo newGroup.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls)) } - return &newGroup, nil -} - -func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInput) (*models.Group, error) { - newGroup, err := groupFromGroupCreateInput(ctx, input) - if err != nil { - return nil, err - } + newGroupInput.CustomFields = convertMapJSONNumbers(input.CustomFields) // Process the base 64 encoded image string - var frontimageData []byte if input.FrontImage != nil { - frontimageData, err = utils.ProcessImageInput(ctx, *input.FrontImage) + newGroupInput.FrontImageData, err = utils.ProcessImageInput(ctx, *input.FrontImage) if err != nil { return nil, fmt.Errorf("processing front image: %w", err) } } // Process the base 64 encoded image string - var backimageData []byte if input.BackImage != nil { - backimageData, err = utils.ProcessImageInput(ctx, *input.BackImage) + newGroupInput.BackImageData, err = utils.ProcessImageInput(ctx, *input.BackImage) if err != nil { return nil, fmt.Errorf("processing back image: %w", err) } @@ -88,13 +83,22 @@ func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInp // HACK: if back image is being set, set the front image to the default. // This is because we can't have a null front image with a non-null back image. - if len(frontimageData) == 0 && len(backimageData) != 0 { - frontimageData = static.ReadAll(static.DefaultGroupImage) + if len(newGroupInput.FrontImageData) == 0 && len(newGroupInput.BackImageData) != 0 { + newGroupInput.FrontImageData = static.ReadAll(static.DefaultGroupImage) + } + + return newGroupInput, nil +} + +func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInput) (*models.Group, error) { + createGroupInput, err := groupFromGroupCreateInput(ctx, input) + if err != nil { + return nil, err } // Start the transaction and save the group if err := r.withTxn(ctx, func(ctx context.Context) error { - if err = r.groupService.Create(ctx, newGroup, frontimageData, backimageData); err != nil { + if err = r.groupService.Create(ctx, createGroupInput); err != nil { return err } @@ -104,9 +108,9 @@ func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInp } // for backwards compatibility - run both movie and group hooks - r.hookExecutor.ExecutePostHooks(ctx, newGroup.ID, hook.GroupCreatePost, input, nil) - r.hookExecutor.ExecutePostHooks(ctx, newGroup.ID, hook.MovieCreatePost, input, nil) - return r.getGroup(ctx, newGroup.ID) + r.hookExecutor.ExecutePostHooks(ctx, createGroupInput.Group.ID, hook.GroupCreatePost, input, nil) + r.hookExecutor.ExecutePostHooks(ctx, createGroupInput.Group.ID, hook.MovieCreatePost, input, nil) + return r.getGroup(ctx, createGroupInput.Group.ID) } func groupPartialFromGroupUpdateInput(translator changesetTranslator, input GroupUpdateInput) (ret models.GroupPartial, err error) { @@ -150,6 +154,12 @@ func groupPartialFromGroupUpdateInput(translator changesetTranslator, input Grou } updatedGroup.URLs = translator.updateStrings(input.Urls, "urls") + if input.CustomFields != nil { + updatedGroup.CustomFields = *input.CustomFields + // convert json.Numbers to int/float + updatedGroup.CustomFields.Full = convertMapJSONNumbers(updatedGroup.CustomFields.Full) + updatedGroup.CustomFields.Partial = convertMapJSONNumbers(updatedGroup.CustomFields.Partial) + } return updatedGroup, nil } @@ -246,6 +256,13 @@ func groupPartialFromBulkGroupUpdateInput(translator changesetTranslator, input updatedGroup.URLs = translator.optionalURLsBulk(input.Urls, nil) + if input.CustomFields != nil { + updatedGroup.CustomFields = *input.CustomFields + // convert json.Numbers to int/float + updatedGroup.CustomFields.Full = convertMapJSONNumbers(updatedGroup.CustomFields.Full) + updatedGroup.CustomFields.Partial = convertMapJSONNumbers(updatedGroup.CustomFields.Partial) + } + return updatedGroup, nil } diff --git a/internal/manager/repository.go b/internal/manager/repository.go index afbf0b963..65514ed1d 100644 --- a/internal/manager/repository.go +++ b/internal/manager/repository.go @@ -39,7 +39,7 @@ type GalleryService interface { } type GroupService interface { - Create(ctx context.Context, group *models.Group, frontimageData []byte, backimageData []byte) error + Create(ctx context.Context, input *models.CreateGroupInput) error UpdatePartial(ctx context.Context, id int, updatedGroup models.GroupPartial, frontImage group.ImageInput, backImage group.ImageInput) (*models.Group, error) AddSubGroups(ctx context.Context, groupID int, subGroups []models.GroupIDDescription, insertIndex *int) error diff --git a/pkg/group/create.go b/pkg/group/create.go index 56d6b7a4e..9cc578b23 100644 --- a/pkg/group/create.go +++ b/pkg/group/create.go @@ -12,27 +12,37 @@ var ( ErrHierarchyLoop = errors.New("a group cannot be contained by one of its subgroups") ) -func (s *Service) Create(ctx context.Context, group *models.Group, frontimageData []byte, backimageData []byte) error { +func (s *Service) Create(ctx context.Context, input *models.CreateGroupInput) error { r := s.Repository + group := input.Group if err := s.validateCreate(ctx, group); err != nil { return err } - err := r.Create(ctx, group) + err := r.Create(ctx, input.Group) if err != nil { return err } - // update image table - if len(frontimageData) > 0 { - if err := r.UpdateFrontImage(ctx, group.ID, frontimageData); err != nil { + // set custom fields + if len(input.CustomFields) > 0 { + if err := r.SetCustomFields(ctx, group.ID, models.CustomFieldsInput{ + Full: input.CustomFields, + }); err != nil { return err } } - if len(backimageData) > 0 { - if err := r.UpdateBackImage(ctx, group.ID, backimageData); err != nil { + // update image table + if len(input.FrontImageData) > 0 { + if err := r.UpdateFrontImage(ctx, group.ID, input.FrontImageData); err != nil { + return err + } + } + + if len(input.BackImageData) > 0 { + if err := r.UpdateBackImage(ctx, group.ID, input.BackImageData); err != nil { return err } } diff --git a/pkg/group/export.go b/pkg/group/export.go index 418ce7bed..0a56fbdbb 100644 --- a/pkg/group/export.go +++ b/pkg/group/export.go @@ -11,61 +11,67 @@ import ( "github.com/stashapp/stash/pkg/utils" ) -type ImageGetter interface { - GetFrontImage(ctx context.Context, movieID int) ([]byte, error) - GetBackImage(ctx context.Context, movieID int) ([]byte, error) +type GroupExportReader interface { + GetFrontImage(ctx context.Context, groupID int) ([]byte, error) + GetBackImage(ctx context.Context, groupID int) ([]byte, error) + GetCustomFields(ctx context.Context, groupID int) (map[string]interface{}, error) } -// ToJSON converts a Movie into its JSON equivalent. -func ToJSON(ctx context.Context, reader ImageGetter, studioReader models.StudioGetter, movie *models.Group) (*jsonschema.Group, error) { - newMovieJSON := jsonschema.Group{ - Name: movie.Name, - Aliases: movie.Aliases, - Director: movie.Director, - Synopsis: movie.Synopsis, - URLs: movie.URLs.List(), - CreatedAt: json.JSONTime{Time: movie.CreatedAt}, - UpdatedAt: json.JSONTime{Time: movie.UpdatedAt}, +// ToJSON converts a Group into its JSON equivalent. +func ToJSON(ctx context.Context, reader GroupExportReader, studioReader models.StudioGetter, group *models.Group) (*jsonschema.Group, error) { + newGroupJSON := jsonschema.Group{ + Name: group.Name, + Aliases: group.Aliases, + Director: group.Director, + Synopsis: group.Synopsis, + URLs: group.URLs.List(), + CreatedAt: json.JSONTime{Time: group.CreatedAt}, + UpdatedAt: json.JSONTime{Time: group.UpdatedAt}, } - if movie.Date != nil { - newMovieJSON.Date = movie.Date.String() + if group.Date != nil { + newGroupJSON.Date = group.Date.String() } - if movie.Rating != nil { - newMovieJSON.Rating = *movie.Rating + if group.Rating != nil { + newGroupJSON.Rating = *group.Rating } - if movie.Duration != nil { - newMovieJSON.Duration = *movie.Duration + if group.Duration != nil { + newGroupJSON.Duration = *group.Duration } - if movie.StudioID != nil { - studio, err := studioReader.Find(ctx, *movie.StudioID) + if group.StudioID != nil { + studio, err := studioReader.Find(ctx, *group.StudioID) if err != nil { return nil, fmt.Errorf("error getting movie studio: %v", err) } if studio != nil { - newMovieJSON.Studio = studio.Name + newGroupJSON.Studio = studio.Name } } - frontImage, err := reader.GetFrontImage(ctx, movie.ID) + frontImage, err := reader.GetFrontImage(ctx, group.ID) if err != nil { logger.Errorf("Error getting movie front image: %v", err) } if len(frontImage) > 0 { - newMovieJSON.FrontImage = utils.GetBase64StringFromData(frontImage) + newGroupJSON.FrontImage = utils.GetBase64StringFromData(frontImage) } - backImage, err := reader.GetBackImage(ctx, movie.ID) + backImage, err := reader.GetBackImage(ctx, group.ID) if err != nil { logger.Errorf("Error getting movie back image: %v", err) } if len(backImage) > 0 { - newMovieJSON.BackImage = utils.GetBase64StringFromData(backImage) + newGroupJSON.BackImage = utils.GetBase64StringFromData(backImage) } - return &newMovieJSON, nil + newGroupJSON.CustomFields, err = reader.GetCustomFields(ctx, group.ID) + if err != nil { + return nil, fmt.Errorf("getting group custom fields: %v", err) + } + + return &newGroupJSON, nil } diff --git a/pkg/group/export_test.go b/pkg/group/export_test.go index 5f8d9f7dc..bff50de5e 100644 --- a/pkg/group/export_test.go +++ b/pkg/group/export_test.go @@ -8,24 +8,26 @@ import ( "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "testing" "time" ) const ( - movieID = 1 - emptyID = 2 - errFrontImageID = 3 - errBackImageID = 4 - errStudioMovieID = 5 - missingStudioMovieID = 6 + movieID = iota + 1 + emptyID + errFrontImageID + errBackImageID + errStudioMovieID + missingStudioMovieID + errCustomFieldsID ) const ( - studioID = 1 - missingStudioID = 2 - errStudioID = 3 + studioID = iota + 1 + missingStudioID + errStudioID ) const movieName = "testMovie" @@ -51,6 +53,11 @@ const ( var ( frontImageBytes = []byte("frontImageBytes") backImageBytes = []byte("backImageBytes") + + emptyCustomFields = make(map[string]interface{}) + customFields = map[string]interface{}{ + "customField1": "customValue1", + } ) var movieStudio models.Studio = models.Studio{ @@ -88,7 +95,7 @@ func createEmptyMovie(id int) models.Group { } } -func createFullJSONMovie(studio, frontImage, backImage string) *jsonschema.Group { +func createFullJSONMovie(studio, frontImage, backImage string, customFields map[string]interface{}) *jsonschema.Group { return &jsonschema.Group{ Name: movieName, Aliases: movieAliases, @@ -107,6 +114,7 @@ func createFullJSONMovie(studio, frontImage, backImage string) *jsonschema.Group UpdatedAt: json.JSONTime{ Time: updateTime, }, + CustomFields: customFields, } } @@ -119,13 +127,15 @@ func createEmptyJSONMovie() *jsonschema.Group { UpdatedAt: json.JSONTime{ Time: updateTime, }, + CustomFields: emptyCustomFields, } } type testScenario struct { - movie models.Group - expected *jsonschema.Group - err bool + movie models.Group + customFields map[string]interface{} + expected *jsonschema.Group + err bool } var scenarios []testScenario @@ -134,36 +144,48 @@ func initTestTable() { scenarios = []testScenario{ { createFullMovie(movieID, studioID), - createFullJSONMovie(studioName, frontImage, backImage), + customFields, + createFullJSONMovie(studioName, frontImage, backImage, customFields), false, }, { createEmptyMovie(emptyID), + emptyCustomFields, createEmptyJSONMovie(), false, }, { createFullMovie(errFrontImageID, studioID), - createFullJSONMovie(studioName, "", backImage), + emptyCustomFields, + createFullJSONMovie(studioName, "", backImage, emptyCustomFields), // failure to get front image should not cause error false, }, { createFullMovie(errBackImageID, studioID), - createFullJSONMovie(studioName, frontImage, ""), + emptyCustomFields, + createFullJSONMovie(studioName, frontImage, "", emptyCustomFields), // failure to get back image should not cause error false, }, { createFullMovie(errStudioMovieID, errStudioID), + emptyCustomFields, nil, true, }, { createFullMovie(missingStudioMovieID, missingStudioID), - createFullJSONMovie("", frontImage, backImage), + emptyCustomFields, + createFullJSONMovie("", frontImage, backImage, emptyCustomFields), false, }, + { + createFullMovie(errCustomFieldsID, studioID), + customFields, + nil, + true, + }, } } @@ -179,6 +201,7 @@ func TestToJSON(t *testing.T) { db.Group.On("GetFrontImage", testCtx, emptyID).Return(nil, nil).Once().Maybe() db.Group.On("GetFrontImage", testCtx, errFrontImageID).Return(nil, imageErr).Once() db.Group.On("GetFrontImage", testCtx, errBackImageID).Return(frontImageBytes, nil).Once() + db.Group.On("GetFrontImage", testCtx, errCustomFieldsID).Return(nil, nil).Once() db.Group.On("GetBackImage", testCtx, movieID).Return(backImageBytes, nil).Once() db.Group.On("GetBackImage", testCtx, missingStudioMovieID).Return(backImageBytes, nil).Once() @@ -186,6 +209,11 @@ func TestToJSON(t *testing.T) { db.Group.On("GetBackImage", testCtx, errBackImageID).Return(nil, imageErr).Once() db.Group.On("GetBackImage", testCtx, errFrontImageID).Return(backImageBytes, nil).Maybe() db.Group.On("GetBackImage", testCtx, errStudioMovieID).Return(backImageBytes, nil).Maybe() + db.Group.On("GetBackImage", testCtx, errCustomFieldsID).Return(nil, nil).Once() + + db.Group.On("GetCustomFields", testCtx, movieID).Return(customFields, nil).Once() + db.Group.On("GetCustomFields", testCtx, errCustomFieldsID).Return(nil, errors.New("error getting custom fields")).Once() + db.Group.On("GetCustomFields", testCtx, mock.Anything).Return(emptyCustomFields, nil).Times(4) studioErr := errors.New("error getting studio") diff --git a/pkg/group/import.go b/pkg/group/import.go index d7acad47c..1a332bac2 100644 --- a/pkg/group/import.go +++ b/pkg/group/import.go @@ -14,6 +14,7 @@ import ( type ImporterReaderWriter interface { models.GroupCreatorUpdater + models.CustomFieldsWriter FindByName(ctx context.Context, name string, nocase bool) (*models.Group, error) } @@ -233,6 +234,14 @@ func (i *Importer) PostImport(ctx context.Context, id int) error { } } + if len(i.Input.CustomFields) > 0 { + if err := i.ReaderWriter.SetCustomFields(ctx, id, models.CustomFieldsInput{ + Full: i.Input.CustomFields, + }); err != nil { + return fmt.Errorf("error setting custom fields: %v", err) + } + } + if len(i.frontImageData) > 0 { if err := i.ReaderWriter.UpdateFrontImage(ctx, id, i.frontImageData); err != nil { return fmt.Errorf("error setting group front image: %v", err) diff --git a/pkg/group/import_test.go b/pkg/group/import_test.go index 387ceb87e..006c91327 100644 --- a/pkg/group/import_test.go +++ b/pkg/group/import_test.go @@ -259,17 +259,29 @@ func TestImporterPostImport(t *testing.T) { db := mocks.NewDatabase() i := Importer{ - ReaderWriter: db.Group, - StudioWriter: db.Studio, + ReaderWriter: db.Group, + StudioWriter: db.Studio, + Input: jsonschema.Group{ + CustomFields: customFields, + }, frontImageData: frontImageBytes, backImageData: backImageBytes, } updateMovieImageErr := errors.New("UpdateImages error") + customFieldsErr := errors.New("SetCustomFields error") + + customFieldsInput := models.CustomFieldsInput{ + Full: customFields, + } db.Group.On("UpdateFrontImage", testCtx, movieID, frontImageBytes).Return(nil).Once() - db.Group.On("UpdateBackImage", testCtx, movieID, backImageBytes).Return(nil).Once() db.Group.On("UpdateFrontImage", testCtx, errImageID, frontImageBytes).Return(updateMovieImageErr).Once() + db.Group.On("UpdateBackImage", testCtx, movieID, backImageBytes).Return(nil).Once() + + db.Group.On("SetCustomFields", testCtx, movieID, customFieldsInput).Return(nil).Once() + db.Group.On("SetCustomFields", testCtx, errImageID, customFieldsInput).Return(nil).Once() + db.Group.On("SetCustomFields", testCtx, errCustomFieldsID, customFieldsInput).Return(customFieldsErr).Once() err := i.PostImport(testCtx, movieID) assert.Nil(t, err) @@ -277,6 +289,9 @@ func TestImporterPostImport(t *testing.T) { err = i.PostImport(testCtx, errImageID) assert.NotNil(t, err) + err = i.PostImport(testCtx, errCustomFieldsID) + assert.NotNil(t, err) + db.AssertExpectations(t) } diff --git a/pkg/group/service.go b/pkg/group/service.go index ff6e03541..37094665a 100644 --- a/pkg/group/service.go +++ b/pkg/group/service.go @@ -10,6 +10,7 @@ type CreatorUpdater interface { models.GroupGetter models.GroupCreator models.GroupUpdater + models.CustomFieldsWriter models.ContainingGroupLoader models.SubGroupLoader diff --git a/pkg/models/group.go b/pkg/models/group.go index ec550eea8..396384b51 100644 --- a/pkg/models/group.go +++ b/pkg/models/group.go @@ -43,4 +43,6 @@ type GroupFilterType struct { CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at UpdatedAt *TimestampCriterionInput `json:"updated_at"` + // Filter by custom fields + CustomFields []CustomFieldCriterionInput `json:"custom_fields"` } diff --git a/pkg/models/jsonschema/group.go b/pkg/models/jsonschema/group.go index b284dab6e..357ac70bc 100644 --- a/pkg/models/jsonschema/group.go +++ b/pkg/models/jsonschema/group.go @@ -33,6 +33,8 @@ type Group struct { CreatedAt json.JSONTime `json:"created_at,omitempty"` UpdatedAt json.JSONTime `json:"updated_at,omitempty"` + CustomFields map[string]interface{} `json:"custom_fields,omitempty"` + // deprecated - for import only URL string `json:"url,omitempty"` } diff --git a/pkg/models/mocks/GroupReaderWriter.go b/pkg/models/mocks/GroupReaderWriter.go index dc745d094..ac9e513f4 100644 --- a/pkg/models/mocks/GroupReaderWriter.go +++ b/pkg/models/mocks/GroupReaderWriter.go @@ -312,6 +312,52 @@ func (_m *GroupReaderWriter) GetContainingGroupDescriptions(ctx context.Context, return r0, r1 } +// GetCustomFields provides a mock function with given fields: ctx, id +func (_m *GroupReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) { + ret := _m.Called(ctx, id) + + var r0 map[string]interface{} + if rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]interface{}) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetCustomFieldsBulk provides a mock function with given fields: ctx, ids +func (_m *GroupReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) { + ret := _m.Called(ctx, ids) + + var r0 []models.CustomFieldMap + if rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok { + r0 = rf(ctx, ids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.CustomFieldMap) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { + r1 = rf(ctx, ids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetFrontImage provides a mock function with given fields: ctx, groupID func (_m *GroupReaderWriter) GetFrontImage(ctx context.Context, groupID int) ([]byte, error) { ret := _m.Called(ctx, groupID) @@ -497,6 +543,20 @@ func (_m *GroupReaderWriter) QueryCount(ctx context.Context, groupFilter *models return r0, r1 } +// SetCustomFields provides a mock function with given fields: ctx, id, fields +func (_m *GroupReaderWriter) SetCustomFields(ctx context.Context, id int, fields models.CustomFieldsInput) error { + ret := _m.Called(ctx, id, fields) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, models.CustomFieldsInput) error); ok { + r0 = rf(ctx, id, fields) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Update provides a mock function with given fields: ctx, updatedGroup func (_m *GroupReaderWriter) Update(ctx context.Context, updatedGroup *models.Group) error { ret := _m.Called(ctx, updatedGroup) diff --git a/pkg/models/model_group.go b/pkg/models/model_group.go index 82c71996a..5bfb42c44 100644 --- a/pkg/models/model_group.go +++ b/pkg/models/model_group.go @@ -34,6 +34,14 @@ func NewGroup() Group { } } +type CreateGroupInput struct { + *Group + + CustomFields map[string]interface{} `json:"custom_fields"` + FrontImageData []byte + BackImageData []byte +} + func (m *Group) LoadURLs(ctx context.Context, l URLLoader) error { return m.URLs.load(func() ([]string, error) { return l.GetURLs(ctx, m.ID) @@ -74,6 +82,8 @@ type GroupPartial struct { SubGroups *UpdateGroupDescriptions CreatedAt OptionalTime UpdatedAt OptionalTime + + CustomFields CustomFieldsInput } func NewGroupPartial() GroupPartial { diff --git a/pkg/models/repository_group.go b/pkg/models/repository_group.go index 704390d77..d7f74de64 100644 --- a/pkg/models/repository_group.go +++ b/pkg/models/repository_group.go @@ -68,6 +68,7 @@ type GroupReader interface { TagIDLoader ContainingGroupLoader SubGroupLoader + CustomFieldsReader All(ctx context.Context) ([]*Group, error) GetFrontImage(ctx context.Context, groupID int) ([]byte, error) @@ -81,6 +82,7 @@ type GroupWriter interface { GroupCreator GroupUpdater GroupDestroyer + CustomFieldsWriter } // GroupReaderWriter provides all group methods. diff --git a/pkg/sqlite/anonymise.go b/pkg/sqlite/anonymise.go index 6a5cd4da5..ace306169 100644 --- a/pkg/sqlite/anonymise.go +++ b/pkg/sqlite/anonymise.go @@ -964,6 +964,10 @@ func (db *Anonymiser) anonymiseGroups(ctx context.Context) error { return err } + if err := db.anonymiseCustomFields(ctx, goqu.T(groupsCustomFieldsTable.GetTable()), "group_id"); err != nil { + return err + } + return nil } diff --git a/pkg/sqlite/custom_fields_test.go b/pkg/sqlite/custom_fields_test.go index ae4a276f7..2f7ecd7dc 100644 --- a/pkg/sqlite/custom_fields_test.go +++ b/pkg/sqlite/custom_fields_test.go @@ -242,7 +242,13 @@ func TestSceneSetCustomFields(t *testing.T) { } func TestGallerySetCustomFields(t *testing.T) { - galleryIdx := galleryIdxWithScene + galleryIdx := galleryIdxWithChapters testSetCustomFields(t, "Gallery", db.Gallery, galleryIDs[galleryIdx], getGalleryCustomFields(galleryIdx)) } + +func TestGroupSetCustomFields(t *testing.T) { + groupIdx := groupIdxWithScene + + testSetCustomFields(t, "Group", db.Group, groupIDs[groupIdx], getGroupCustomFields(groupIdx)) +} diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index d95832836..000b91c4d 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 81 +var appSchemaVersion uint = 82 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index 4156f129c..9bd0da47f 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -831,6 +831,79 @@ func Test_galleryQueryBuilder_UpdatePartialRelationships(t *testing.T) { } } +func Test_GalleryStore_UpdatePartialCustomFields(t *testing.T) { + tests := []struct { + name string + id int + partial models.GalleryPartial + expected map[string]interface{} // nil to use the partial + }{ + { + "set custom fields", + galleryIDs[galleryIdx1WithImage], + models.GalleryPartial{ + CustomFields: models.CustomFieldsInput{ + Full: testCustomFields, + }, + }, + nil, + }, + { + "clear custom fields", + galleryIDs[galleryIdx1WithImage], + models.GalleryPartial{ + CustomFields: models.CustomFieldsInput{ + Full: map[string]interface{}{}, + }, + }, + nil, + }, + { + "partial custom fields", + galleryIDs[galleryIdxWithTwoTags], + models.GalleryPartial{ + CustomFields: models.CustomFieldsInput{ + Partial: map[string]interface{}{ + "string": "bbb", + "new_field": "new", + }, + }, + }, + map[string]interface{}{ + "int": int64(2), + "real": 1.2, + "string": "bbb", + "new_field": "new", + }, + }, + } + for _, tt := range tests { + qb := db.Gallery + + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + _, err := qb.UpdatePartial(ctx, tt.id, tt.partial) + if err != nil { + t.Errorf("GalleryStore.UpdatePartial() error = %v", err) + return + } + + // ensure custom fields are correct + cf, err := qb.GetCustomFields(ctx, tt.id) + if err != nil { + t.Errorf("GalleryStore.GetCustomFields() error = %v", err) + return + } + if tt.expected == nil { + assert.Equal(tt.partial.CustomFields.Full, cf) + } else { + assert.Equal(tt.expected, cf) + } + }) + } +} + func Test_galleryQueryBuilder_Destroy(t *testing.T) { tests := []struct { name string diff --git a/pkg/sqlite/group.go b/pkg/sqlite/group.go index b216335b8..13a6905a5 100644 --- a/pkg/sqlite/group.go +++ b/pkg/sqlite/group.go @@ -131,6 +131,7 @@ var ( type GroupStore struct { blobJoinQueryBuilder + customFieldsStore tagRelationshipStore groupRelationshipStore @@ -143,6 +144,10 @@ func NewGroupStore(blobStore *BlobStore) *GroupStore { blobStore: blobStore, joinTable: groupTable, }, + customFieldsStore: customFieldsStore{ + table: groupsCustomFieldsTable, + fk: groupsCustomFieldsTable.Col(groupIDColumn), + }, tagRelationshipStore: tagRelationshipStore{ idRelationshipStore: idRelationshipStore{ joinTable: groupsTagsTableMgr, @@ -235,6 +240,10 @@ func (qb *GroupStore) UpdatePartial(ctx context.Context, id int, partial models. return nil, err } + if err := qb.SetCustomFields(ctx, id, partial.CustomFields); err != nil { + return nil, err + } + return qb.find(ctx, id) } diff --git a/pkg/sqlite/group_filter.go b/pkg/sqlite/group_filter.go index f81783374..4f3f7b41a 100644 --- a/pkg/sqlite/group_filter.go +++ b/pkg/sqlite/group_filter.go @@ -84,6 +84,13 @@ func (qb *groupFilterHandler) criterionHandler() criterionHandler { ×tampCriterionHandler{groupFilter.CreatedAt, "groups.created_at", nil}, ×tampCriterionHandler{groupFilter.UpdatedAt, "groups.updated_at", nil}, + &customFieldsFilterHandler{ + table: groupsCustomFieldsTable.GetTable(), + fkCol: groupIDColumn, + c: groupFilter.CustomFields, + idCol: "groups.id", + }, + &relatedFilterHandler{ relatedIDCol: "groups_scenes.scene_id", relatedRepo: sceneRepository.repository, diff --git a/pkg/sqlite/group_test.go b/pkg/sqlite/group_test.go index db293dd92..22b551e02 100644 --- a/pkg/sqlite/group_test.go +++ b/pkg/sqlite/group_test.go @@ -566,6 +566,79 @@ func Test_groupQueryBuilder_UpdatePartial(t *testing.T) { } } +func Test_GroupStore_UpdatePartialCustomFields(t *testing.T) { + tests := []struct { + name string + id int + partial models.GroupPartial + expected map[string]interface{} // nil to use the partial + }{ + { + "set custom fields", + groupIDs[groupIdxWithChild], + models.GroupPartial{ + CustomFields: models.CustomFieldsInput{ + Full: testCustomFields, + }, + }, + nil, + }, + { + "clear custom fields", + groupIDs[groupIdxWithChild], + models.GroupPartial{ + CustomFields: models.CustomFieldsInput{ + Full: map[string]interface{}{}, + }, + }, + nil, + }, + { + "partial custom fields", + groupIDs[groupIdxWithTwoTags], + models.GroupPartial{ + CustomFields: models.CustomFieldsInput{ + Partial: map[string]interface{}{ + "string": "bbb", + "new_field": "new", + }, + }, + }, + map[string]interface{}{ + "int": int64(3), + "real": 0.3, + "string": "bbb", + "new_field": "new", + }, + }, + } + for _, tt := range tests { + qb := db.Group + + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + _, err := qb.UpdatePartial(ctx, tt.id, tt.partial) + if err != nil { + t.Errorf("GroupStore.UpdatePartial() error = %v", err) + return + } + + // ensure custom fields are correct + cf, err := qb.GetCustomFields(ctx, tt.id) + if err != nil { + t.Errorf("GroupStore.GetCustomFields() error = %v", err) + return + } + if tt.expected == nil { + assert.Equal(tt.partial.CustomFields.Full, cf) + } else { + assert.Equal(tt.expected, cf) + } + }) + } +} + func TestGroupFindByName(t *testing.T) { withTxn(func(ctx context.Context) error { mqb := db.Group @@ -1917,6 +1990,245 @@ func TestGroupFindSubGroupIDs(t *testing.T) { } } +func TestGroupQueryCustomFields(t *testing.T) { + tests := []struct { + name string + filter *models.GroupFilterType + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "equals", + &models.GroupFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierEquals, + Value: []any{getGroupStringValue(groupIdxWithChild, "custom")}, + }, + }, + }, + []int{groupIdxWithChild}, + nil, + false, + }, + { + "not equals", + &models.GroupFilterType{ + Name: &models.StringCriterionInput{ + Value: getGroupStringValue(groupIdxWithChild, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotEquals, + Value: []any{getGroupStringValue(groupIdxWithChild, "custom")}, + }, + }, + }, + nil, + []int{groupIdxWithChild}, + false, + }, + { + "includes", + &models.GroupFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierIncludes, + Value: []any{getGroupStringValue(groupIdxWithChild, "custom")[9:]}, + }, + }, + }, + []int{groupIdxWithChild}, + nil, + false, + }, + { + "excludes", + &models.GroupFilterType{ + Name: &models.StringCriterionInput{ + Value: getGroupStringValue(groupIdxWithChild, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierExcludes, + Value: []any{getGroupStringValue(groupIdxWithChild, "custom")[9:]}, + }, + }, + }, + nil, + []int{groupIdxWithChild}, + false, + }, + { + "regex", + &models.GroupFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierMatchesRegex, + Value: []any{".*11_custom"}, + }, + }, + }, + []int{groupIdxWithChildWithScene}, + nil, + false, + }, + { + "invalid regex", + &models.GroupFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierMatchesRegex, + Value: []any{"["}, + }, + }, + }, + nil, + nil, + true, + }, + { + "not matches regex", + &models.GroupFilterType{ + Name: &models.StringCriterionInput{ + Value: getGroupStringValue(groupIdxWithChildWithScene, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotMatchesRegex, + Value: []any{".*11_custom"}, + }, + }, + }, + nil, + []int{groupIdxWithChildWithScene}, + false, + }, + { + "invalid not matches regex", + &models.GroupFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotMatchesRegex, + Value: []any{"["}, + }, + }, + }, + nil, + nil, + true, + }, + { + "null", + &models.GroupFilterType{ + Name: &models.StringCriterionInput{ + Value: getGroupStringValue(groupIdxWithGrandParent, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "not existing", + Modifier: models.CriterionModifierIsNull, + }, + }, + }, + []int{groupIdxWithGrandParent}, + nil, + false, + }, + { + "not null", + &models.GroupFilterType{ + Name: &models.StringCriterionInput{ + Value: getGroupStringValue(groupIdxWithGrandParent, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotNull, + }, + }, + }, + []int{groupIdxWithGrandParent}, + nil, + false, + }, + { + "between", + &models.GroupFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "real", + Modifier: models.CriterionModifierBetween, + Value: []any{0.15, 0.25}, + }, + }, + }, + []int{groupIdxWithTag}, + nil, + false, + }, + { + "not between", + &models.GroupFilterType{ + Name: &models.StringCriterionInput{ + Value: getGroupStringValue(groupIdxWithTag, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "real", + Modifier: models.CriterionModifierNotBetween, + Value: []any{0.15, 0.25}, + }, + }, + }, + nil, + []int{groupIdxWithTag}, + false, + }, + } + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + groups, _, err := db.Group.Query(ctx, tt.filter, nil) + if (err != nil) != tt.wantErr { + t.Errorf("GroupStore.Query() error = %v, wantErr %v", err, tt.wantErr) + } + + if err != nil { + return + } + + ids := groupsToIDs(groups) + include := indexesToIDs(groupIDs, tt.includeIdxs) + exclude := indexesToIDs(groupIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } +} + // TODO Update // TODO Destroy - ensure image is destroyed // TODO Find diff --git a/pkg/sqlite/migrations/82_group_custom_fields.up.sql b/pkg/sqlite/migrations/82_group_custom_fields.up.sql new file mode 100644 index 000000000..c1f287fec --- /dev/null +++ b/pkg/sqlite/migrations/82_group_custom_fields.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE `group_custom_fields` ( + `group_id` integer NOT NULL, + `field` varchar(64) NOT NULL, + `value` BLOB NOT NULL, + PRIMARY KEY (`group_id`, `field`), + foreign key(`group_id`) references `groups`(`id`) on delete CASCADE +); + +CREATE INDEX `index_group_custom_fields_field_value` ON `group_custom_fields` (`field`, `value`); diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 675b3f417..0078f1a67 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1457,6 +1457,18 @@ func getGroupEmptyString(index int, field string) string { return v.String } +func getGroupCustomFields(index int) map[string]interface{} { + if index%5 == 0 { + return nil + } + + return map[string]interface{}{ + "string": getGroupStringValue(index, "custom"), + "int": int64(index % 5), + "real": float64(index) / 10, + } +} + // createGroups creates n groups with plain Name and o groups with camel cased NaMe included func createGroups(ctx context.Context, mqb models.GroupReaderWriter, n int, o int) error { const namePlain = "Name" @@ -1489,6 +1501,13 @@ func createGroups(ctx context.Context, mqb models.GroupReaderWriter, n int, o in return fmt.Errorf("Error creating group [%d] %v+: %s", i, group, err.Error()) } + customFields := getGroupCustomFields(i) + if customFields != nil { + if err := mqb.SetCustomFields(ctx, group.ID, models.CustomFieldsInput{Full: customFields}); err != nil { + return fmt.Errorf("Error setting custom fields for group %d: %s", group.ID, err.Error()) + } + } + groupIDs = append(groupIDs, group.ID) groupNames = append(groupNames, group.Name) } diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 7867054ba..6c898048d 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -47,6 +47,7 @@ var ( groupsURLsJoinTable = goqu.T(groupURLsTable) groupsTagsJoinTable = goqu.T(groupsTagsTable) groupRelationsJoinTable = goqu.T(groupRelationsTable) + groupsCustomFieldsTable = goqu.T("group_custom_fields") tagsAliasesJoinTable = goqu.T(tagAliasesTable) tagRelationsJoinTable = goqu.T(tagRelationsTable) From 9a1b1fb7187eb6f2fdaffd6df777a75ff470b7ea Mon Sep 17 00:00:00 2001 From: 1509x Date: Sun, 22 Feb 2026 20:51:35 -0500 Subject: [PATCH 079/177] [Feature] Reveal file in system file manager from file info panel (#6587) * Add reveal in file manager button to file info panel Adds a folder icon button next to the path field in the Scene, Image, and Gallery file info panels. Clicking it calls a new GraphQL mutation that opens the file's enclosing directory in the system file manager (Finder on macOS, Explorer on Windows, xdg-open on Linux). Also fixes the existing revealInFileManager implementations which were constructing exec.Command but never calling Run(), making them no-ops: - darwin: add Run() to open -R - windows: add Run() and fix flag from \select to /select, - linux: implement with xdg-open on the parent directory - desktop.go: use os.Stat instead of FileExists so folders work too * Disallow reveal operation if request not from loopback --------- Co-authored-by: 1509x <1509x@users.noreply.github.com> Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/schema/schema.graphql | 4 ++ internal/api/authentication.go | 2 + internal/api/resolver_mutation_file.go | 71 +++++++++++++++++++ internal/desktop/desktop.go | 15 ++-- internal/desktop/desktop_platform_darwin.go | 11 ++- internal/desktop/desktop_platform_nixes.go | 13 +++- internal/desktop/desktop_platform_windows.go | 9 ++- pkg/session/local.go | 44 ++++++++++++ pkg/session/session.go | 1 + ui/v2.5/graphql/mutations/file.graphql | 8 +++ .../GalleryDetails/GalleryFileInfoPanel.tsx | 18 +++-- .../ImageDetails/ImageFileInfoPanel.tsx | 13 ++-- .../SceneDetails/SceneFileInfoPanel.tsx | 13 ++-- .../Shared/RevealInFilesystemButton.tsx | 48 +++++++++++++ ui/v2.5/src/components/Shared/styles.scss | 5 ++ ui/v2.5/src/core/StashService.ts | 12 ++++ ui/v2.5/src/docs/en/Manual/Browsing.md | 6 ++ ui/v2.5/src/index.scss | 1 + ui/v2.5/src/locales/en-GB.json | 1 + 19 files changed, 263 insertions(+), 32 deletions(-) create mode 100644 pkg/session/local.go create mode 100644 ui/v2.5/src/components/Shared/RevealInFilesystemButton.tsx diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 7fda85b24..996afefe7 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -426,6 +426,10 @@ type Mutation { destroyFiles(ids: [ID!]!): Boolean! fileSetFingerprints(input: FileSetFingerprintsInput!): Boolean! + "Reveal the file in the system file manager" + revealFileInFileManager(id: ID!): Boolean! + "Reveal the folder in the system file manager" + revealFolderInFileManager(id: ID!): Boolean! # Saved filters saveFilter(input: SaveFilterInput!): SavedFilter! diff --git a/internal/api/authentication.go b/internal/api/authentication.go index 6ad7117a1..be399d222 100644 --- a/internal/api/authentication.go +++ b/internal/api/authentication.go @@ -40,6 +40,8 @@ func authenticateHandler() func(http.Handler) http.Handler { return } + r = session.SetLocalRequest(r) + userID, err := manager.GetInstance().SessionStore.Authenticate(w, r) if err != nil { if !errors.Is(err, session.ErrUnauthorized) { diff --git a/internal/api/resolver_mutation_file.go b/internal/api/resolver_mutation_file.go index afbefe554..f6279ad16 100644 --- a/internal/api/resolver_mutation_file.go +++ b/internal/api/resolver_mutation_file.go @@ -5,10 +5,13 @@ import ( "fmt" "strconv" + "github.com/stashapp/stash/internal/desktop" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/fsutil" + "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/session" "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) @@ -326,3 +329,71 @@ func (r *mutationResolver) FileSetFingerprints(ctx context.Context, input FileSe return true, nil } + +func (r *mutationResolver) RevealFileInFileManager(ctx context.Context, id string) (bool, error) { + // disallow if request did not come from localhost + if !session.IsLocalRequest(ctx) { + logger.Warnf("Attempt to reveal file in file manager from non-local request") + return false, fmt.Errorf("access denied") + } + + fileIDInt, err := strconv.Atoi(id) + if err != nil { + return false, fmt.Errorf("converting id: %w", err) + } + + var filePath string + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + files, err := r.repository.File.Find(ctx, models.FileID(fileIDInt)) + if err != nil { + return fmt.Errorf("finding file: %w", err) + } + if len(files) == 0 { + return fmt.Errorf("file with id %d not found", fileIDInt) + } + filePath = files[0].Base().Path + return nil + }); err != nil { + return false, err + } + + if err := desktop.RevealInFileManager(filePath); err != nil { + return false, err + } + + return true, nil +} + +func (r *mutationResolver) RevealFolderInFileManager(ctx context.Context, id string) (bool, error) { + // disallow if request did not come from localhost + if !session.IsLocalRequest(ctx) { + logger.Warnf("Attempt to reveal folder in file manager from non-local request") + return false, fmt.Errorf("access denied") + } + + folderIDInt, err := strconv.Atoi(id) + if err != nil { + return false, fmt.Errorf("converting id: %w", err) + } + + var folderPath string + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + folder, err := r.repository.Folder.Find(ctx, models.FolderID(folderIDInt)) + if err != nil { + return fmt.Errorf("finding folder: %w", err) + } + if folder == nil { + return fmt.Errorf("folder with id %d not found", folderIDInt) + } + folderPath = folder.Path + return nil + }); err != nil { + return false, err + } + + if err := desktop.RevealInFileManager(folderPath); err != nil { + return false, err + } + + return true, nil +} diff --git a/internal/desktop/desktop.go b/internal/desktop/desktop.go index 06d400793..f1ca9bc92 100644 --- a/internal/desktop/desktop.go +++ b/internal/desktop/desktop.go @@ -2,6 +2,7 @@ package desktop import ( + "fmt" "os" "path" "path/filepath" @@ -155,15 +156,17 @@ func getIconPath() string { return path.Join(config.GetInstance().GetConfigPath(), "icon.png") } -func RevealInFileManager(path string) { - exists, err := fsutil.FileExists(path) +func RevealInFileManager(path string) error { + info, err := os.Stat(path) if err != nil { - logger.Errorf("Error checking file: %s", err) - return + return fmt.Errorf("error checking path: %w", err) } - if exists && IsDesktop() { - revealInFileManager(path) + + absPath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("error getting absolute path: %w", err) } + return revealInFileManager(absPath, info) } func getServerURL(path string) string { diff --git a/internal/desktop/desktop_platform_darwin.go b/internal/desktop/desktop_platform_darwin.go index 593e9516f..732009007 100644 --- a/internal/desktop/desktop_platform_darwin.go +++ b/internal/desktop/desktop_platform_darwin.go @@ -4,9 +4,11 @@ package desktop import ( + "fmt" + "os" "os/exec" - "github.com/kermieisinthehouse/gosx-notifier" + gosxnotifier "github.com/kermieisinthehouse/gosx-notifier" "github.com/stashapp/stash/pkg/logger" ) @@ -32,8 +34,11 @@ func sendNotification(notificationTitle string, notificationText string) { } } -func revealInFileManager(path string) { - exec.Command(`open`, `-R`, path) +func revealInFileManager(path string, _ os.FileInfo) error { + if err := exec.Command(`open`, `-R`, path).Run(); err != nil { + return fmt.Errorf("error revealing path in Finder: %w", err) + } + return nil } func isDoubleClickLaunched() bool { diff --git a/internal/desktop/desktop_platform_nixes.go b/internal/desktop/desktop_platform_nixes.go index 69c780d3c..f5ab13384 100644 --- a/internal/desktop/desktop_platform_nixes.go +++ b/internal/desktop/desktop_platform_nixes.go @@ -4,8 +4,10 @@ package desktop import ( + "fmt" "os" "os/exec" + "path/filepath" "strings" "github.com/stashapp/stash/pkg/logger" @@ -33,8 +35,15 @@ func sendNotification(notificationTitle string, notificationText string) { } } -func revealInFileManager(path string) { - +func revealInFileManager(path string, info os.FileInfo) error { + dir := path + if !info.IsDir() { + dir = filepath.Dir(path) + } + if err := exec.Command("xdg-open", dir).Run(); err != nil { + return fmt.Errorf("error opening directory in file manager: %w", err) + } + return nil } func isDoubleClickLaunched() bool { diff --git a/internal/desktop/desktop_platform_windows.go b/internal/desktop/desktop_platform_windows.go index ecb4060e6..48feabed5 100644 --- a/internal/desktop/desktop_platform_windows.go +++ b/internal/desktop/desktop_platform_windows.go @@ -4,6 +4,7 @@ package desktop import ( + "os" "os/exec" "syscall" "unsafe" @@ -83,6 +84,10 @@ func sendNotification(notificationTitle string, notificationText string) { } } -func revealInFileManager(path string) { - exec.Command(`explorer`, `\select`, path) +func revealInFileManager(path string, _ os.FileInfo) error { + c := exec.Command(`explorer`, `/select,`, path) + logger.Debugf("Running: %s", c.String()) + // explorer seems to return an error code even when it works, so ignore the error + _ = c.Run() + return nil } diff --git a/pkg/session/local.go b/pkg/session/local.go new file mode 100644 index 000000000..519328496 --- /dev/null +++ b/pkg/session/local.go @@ -0,0 +1,44 @@ +package session + +import ( + "context" + "net" + "net/http" + + "github.com/stashapp/stash/pkg/logger" +) + +// SetLocalRequest checks if the request is from localhost and sets the context value accordingly. +// It returns the modified request with the updated context, or the original request if it did +// not come from localhost or if there was an error parsing the remote address. +func SetLocalRequest(r *http.Request) *http.Request { + // determine if request is from localhost + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + logger.Errorf("Error parsing remote address: %v", err) + return r + } + + ip := net.ParseIP(host) + if ip == nil { + logger.Errorf("Error parsing IP address: %s", host) + return r + } + + if ip.IsLoopback() { + ctx := context.WithValue(r.Context(), contextLocalRequest, true) + r = r.WithContext(ctx) + } + + return r +} + +// IsLocalRequest returns true if the request is from localhost, as determined by the context value set by SetLocalRequest. +// If the context value is not set, it returns false. +func IsLocalRequest(ctx context.Context) bool { + val := ctx.Value(contextLocalRequest) + if val == nil { + return false + } + return val.(bool) +} diff --git a/pkg/session/session.go b/pkg/session/session.go index 66cb39e09..3e4c2eea1 100644 --- a/pkg/session/session.go +++ b/pkg/session/session.go @@ -15,6 +15,7 @@ type key int const ( contextUser key = iota contextVisitedPlugins + contextLocalRequest ) const ( diff --git a/ui/v2.5/graphql/mutations/file.graphql b/ui/v2.5/graphql/mutations/file.graphql index 254a55126..fe920d308 100644 --- a/ui/v2.5/graphql/mutations/file.graphql +++ b/ui/v2.5/graphql/mutations/file.graphql @@ -1,3 +1,11 @@ mutation DeleteFiles($ids: [ID!]!) { deleteFiles(ids: $ids) } + +mutation RevealFileInFileManager($id: ID!) { + revealFileInFileManager(id: $id) +} + +mutation RevealFolderInFileManager($id: ID!) { + revealFolderInFileManager(id: $id) +} diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx index 63fedd400..e97146b91 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx @@ -3,11 +3,12 @@ import { Accordion, Button, Card } from "react-bootstrap"; import { FormattedMessage, FormattedTime } from "react-intl"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { DeleteFilesDialog } from "src/components/Shared/DeleteFilesDialog"; +import { RevealInFilesystemButton } from "src/components/Shared/RevealInFilesystemButton"; import * as GQL from "src/core/generated-graphql"; import { mutateGallerySetPrimaryFile } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import TextUtils from "src/utils/text"; -import { TextField, URLField, URLsField } from "src/utils/field"; +import { TextField, URLsField } from "src/utils/field"; interface IFileInfoPanelProps { folder?: Pick; @@ -38,12 +39,15 @@ const FileInfoPanel: React.FC = ( )} - + + + + + + {props.file && ( = ( truncate internal /> - + + + + + + diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx index 63490a2ee..6be55925e 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx @@ -9,6 +9,7 @@ import { import { useHistory } from "react-router-dom"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { DeleteFilesDialog } from "src/components/Shared/DeleteFilesDialog"; +import { RevealInFilesystemButton } from "src/components/Shared/RevealInFilesystemButton"; import { ReassignFilesDialog } from "src/components/Shared/ReassignFilesDialog"; import * as GQL from "src/core/generated-graphql"; import { mutateSceneSetPrimaryFile } from "src/core/StashService"; @@ -70,12 +71,12 @@ const FileInfoPanel: React.FC = ( truncate internal /> - + + + + + + diff --git a/ui/v2.5/src/components/Shared/RevealInFilesystemButton.tsx b/ui/v2.5/src/components/Shared/RevealInFilesystemButton.tsx new file mode 100644 index 000000000..ecc03f9f7 --- /dev/null +++ b/ui/v2.5/src/components/Shared/RevealInFilesystemButton.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { Button } from "react-bootstrap"; +import { faFolderOpen } from "@fortawesome/free-solid-svg-icons"; +import { useIntl } from "react-intl"; +import { Icon } from "./Icon"; +import { + mutateRevealFileInFileManager, + mutateRevealFolderInFileManager, +} from "src/core/StashService"; +import { getPlatformURL } from "src/core/createClient"; + +interface IRevealInFilesystemButtonProps { + fileId?: string; + folderId?: string; +} + +function isLocalhost(): boolean { + const { hostname } = getPlatformURL(); + return ( + hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" + ); +} + +export const RevealInFilesystemButton: React.FC< + IRevealInFilesystemButtonProps +> = ({ fileId, folderId }) => { + const intl = useIntl(); + + if (!isLocalhost()) return null; + + function onClick() { + if (folderId) { + mutateRevealFolderInFileManager(folderId); + } else if (fileId) { + mutateRevealFileInFileManager(fileId); + } + } + + return ( + + ); +}; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index f72bbbeea..32b222832 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -1204,3 +1204,8 @@ input[type="range"].double-range-slider-max { overflow-y: auto; } } + +.reveal-in-filesystem-button { + margin-left: 0.25rem; + padding: 0 0.25rem; +} diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 58b1aae42..d276806fc 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -2248,6 +2248,18 @@ export const mutateDeleteFiles = (ids: string[]) => }, }); +export const mutateRevealFileInFileManager = (id: string) => + client.mutate({ + mutation: GQL.RevealFileInFileManagerDocument, + variables: { id }, + }); + +export const mutateRevealFolderInFileManager = (id: string) => + client.mutate({ + mutation: GQL.RevealFolderInFileManagerDocument, + variables: { id }, + }); + /// Scrapers export const useListSceneScrapers = () => GQL.useListSceneScrapersQuery(); diff --git a/ui/v2.5/src/docs/en/Manual/Browsing.md b/ui/v2.5/src/docs/en/Manual/Browsing.md index 69277146e..6b6681253 100644 --- a/ui/v2.5/src/docs/en/Manual/Browsing.md +++ b/ui/v2.5/src/docs/en/Manual/Browsing.md @@ -50,3 +50,9 @@ Saved filters are sorted alphabetically by title with capitalized titles sorted ### Default filter The default filter for the top-level pages may be set to the current filter by clicking the `Set as default` button in the saved filter menu. + +## Reveal file in file manager + +The `Reveal in file manager` action is available for file-based scenes, galleries and images in the `File Info` tab. This action will open the file manager to the location of the file on disk. The file will be selected if supported by the file manager. + +This button will only be available when accessing stash from a local loopback address (e.g. `localhost` or `127.0.0.1`), and will not be shown when accessing stash from a remote address. \ No newline at end of file diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index cadd1ad2f..3d9478194 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -84,6 +84,7 @@ code, } dd { + overflow: hidden; white-space: pre-line; } diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 4bfd4322d..957bf2837 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -98,6 +98,7 @@ "remove_from_containing_group": "Remove from Group", "remove_from_gallery": "Remove from Gallery", "rename_gen_files": "Rename generated files", + "reveal_in_file_manager": "Reveal in File Manager", "rescan": "Rescan", "reset_play_duration": "Reset play duration", "reset_resume_time": "Reset resume time", From aff6db15009e83192b9e2ef82107eb6b29073bd7 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:51:36 +1100 Subject: [PATCH 080/177] Fix scene player scrubber when custom sprite size used (#6597) --- .../ScenePlayer/ScenePlayerScrubber.tsx | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx index 93e45a7e7..196bc9bd0 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx @@ -28,6 +28,10 @@ interface ISceneSpriteItem { time: string; } +const scrubberViewportHeight = 120; +const scrubberTagsHeight = 30; +const scrubberSpriteHeight = scrubberViewportHeight - scrubberTagsHeight; + export const ScenePlayerScrubber: React.FC = ({ file, scene, @@ -86,16 +90,36 @@ export const ScenePlayerScrubber: React.FC = ({ const [spriteItems, setSpriteItems] = useState(); useEffect(() => { - if (!spriteInfo) return; + if (!spriteInfo || spriteInfo.length === 0) return; let totalWidth = 0; + + // calculate total width/height of scrubber image so we can scale it + const maxX = Math.max(...spriteInfo.map((sprite) => sprite.x + sprite.w)); + const maxY = Math.max(...spriteInfo.map((sprite) => sprite.y + sprite.h)); + const spriteWidth = spriteInfo[0].w; + const spriteHeight = spriteInfo[0].h; + const scale = scrubberSpriteHeight / spriteHeight; + + const w = spriteWidth * scale; + const h = scrubberSpriteHeight; + + const sizeX = maxX * scale; + const sizeY = maxY * scale; + + // scale sprite dimensions to fit scrubber height, and calculate background position for each sprite const newSprites = spriteInfo?.map((sprite, index) => { - totalWidth += sprite.w; - const left = sprite.w * index; + totalWidth += w; + const left = w * index; + + const spriteX = sprite.x * scale; + const spriteY = sprite.y * scale; + const style = { - width: `${sprite.w}px`, - height: `${sprite.h}px`, - backgroundPosition: `${-sprite.x}px ${-sprite.y}px`, + width: `${w}px`, + height: `${h}px`, + backgroundPosition: `${-spriteX}px ${-spriteY}px`, backgroundImage: `url(${sprite.url})`, + backgroundSize: `${sizeX}px ${sizeY}px`, left: `${left}px`, }; const start = TextUtils.secondsToTimestamp(sprite.start); From 86abe7b24c79fd82fe7eb4543e11e1cace8a5182 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 24 Feb 2026 07:41:40 +1100 Subject: [PATCH 081/177] Backend support for image custom fields (#6598) * Initialise maps in bulk get custom fields to fix graphql validation error --- graphql/schema/types/filters.graphql | 2 + graphql/schema/types/image.graphql | 3 + internal/api/loaders/dataloaders.go | 18 + internal/api/resolver_model_image.go | 9 + internal/api/resolver_mutation_image.go | 14 + internal/autotag/integration_test.go | 5 +- internal/manager/task_export.go | 8 +- pkg/image/export.go | 15 +- pkg/image/export_test.go | 26 +- pkg/image/import.go | 13 +- pkg/image/import_test.go | 4 +- pkg/image/scan.go | 7 +- pkg/models/image.go | 39 +- pkg/models/jsonschema/image.go | 25 +- pkg/models/mocks/ImageReaderWriter.go | 70 ++- pkg/models/model_image.go | 8 + pkg/models/repository_image.go | 4 +- pkg/sqlite/custom_fields.go | 4 + pkg/sqlite/custom_fields_test.go | 6 + pkg/sqlite/database.go | 2 +- pkg/sqlite/image.go | 26 +- pkg/sqlite/image_filter.go | 7 + pkg/sqlite/image_test.go | 437 +++++++++++++++--- .../migrations/83_image_custom_fields.up.sql | 9 + pkg/sqlite/setup_test.go | 18 +- pkg/sqlite/tables.go | 1 + 26 files changed, 669 insertions(+), 111 deletions(-) create mode 100644 pkg/sqlite/migrations/83_image_custom_fields.up.sql diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 4162f0af3..907e597f4 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -765,6 +765,8 @@ input ImageFilterType { tags_filter: TagFilterType "Filter by related files that meet this criteria" files_filter: FileFilterType + "Filter by custom fields" + custom_fields: [CustomFieldCriterionInput!] } input FileFilterType { diff --git a/graphql/schema/types/image.graphql b/graphql/schema/types/image.graphql index b7ec1a9f5..ccc414542 100644 --- a/graphql/schema/types/image.graphql +++ b/graphql/schema/types/image.graphql @@ -21,6 +21,7 @@ type Image { studio: Studio tags: [Tag!]! performers: [Performer!]! + custom_fields: Map! } type ImageFileType { @@ -56,6 +57,7 @@ input ImageUpdateInput { gallery_ids: [ID!] primary_file_id: ID + custom_fields: CustomFieldsInput } input BulkImageUpdateInput { @@ -76,6 +78,7 @@ input BulkImageUpdateInput { performer_ids: BulkUpdateIds tag_ids: BulkUpdateIds gallery_ids: BulkUpdateIds + custom_fields: CustomFieldsInput } input ImageDestroyInput { diff --git a/internal/api/loaders/dataloaders.go b/internal/api/loaders/dataloaders.go index ff8a87ab0..dac8ba6b8 100644 --- a/internal/api/loaders/dataloaders.go +++ b/internal/api/loaders/dataloaders.go @@ -57,6 +57,7 @@ type Loaders struct { GalleryByID *GalleryLoader GalleryCustomFields *CustomFieldsLoader ImageByID *ImageLoader + ImageCustomFields *CustomFieldsLoader PerformerByID *PerformerLoader PerformerCustomFields *CustomFieldsLoader @@ -100,6 +101,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler { maxBatch: maxBatch, fetch: m.fetchImages(ctx), }, + ImageCustomFields: &CustomFieldsLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchImageCustomFields(ctx), + }, PerformerByID: &PerformerLoader{ wait: wait, maxBatch: maxBatch, @@ -249,6 +255,18 @@ func (m Middleware) fetchImages(ctx context.Context) func(keys []int) ([]*models } } +func (m Middleware) fetchImageCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) { + return func(keys []int) (ret []models.CustomFieldMap, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Image.GetCustomFieldsBulk(ctx, keys) + return err + }) + + return ret, toErrorSlice(err) + } +} + func (m Middleware) fetchGalleries(ctx context.Context) func(keys []int) ([]*models.Gallery, []error) { return func(keys []int) (ret []*models.Gallery, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { diff --git a/internal/api/resolver_model_image.go b/internal/api/resolver_model_image.go index 0886bea40..4a95ae1f4 100644 --- a/internal/api/resolver_model_image.go +++ b/internal/api/resolver_model_image.go @@ -161,3 +161,12 @@ func (r *imageResolver) Urls(ctx context.Context, obj *models.Image) ([]string, return obj.URLs.List(), nil } + +func (r *imageResolver) CustomFields(ctx context.Context, obj *models.Image) (map[string]interface{}, error) { + customFields, err := loaders.From(ctx).ImageCustomFields.Load(obj.ID) + if err != nil { + return nil, err + } + + return customFields, nil +} diff --git a/internal/api/resolver_mutation_image.go b/internal/api/resolver_mutation_image.go index 230d48358..cc03c5286 100644 --- a/internal/api/resolver_mutation_image.go +++ b/internal/api/resolver_mutation_image.go @@ -177,6 +177,13 @@ func (r *mutationResolver) imageUpdate(ctx context.Context, input models.ImageUp return nil, fmt.Errorf("converting tag ids: %w", err) } + if input.CustomFields != nil { + updatedImage.CustomFields = *input.CustomFields + // convert json.Numbers to int/float + updatedImage.CustomFields.Full = convertMapJSONNumbers(updatedImage.CustomFields.Full) + updatedImage.CustomFields.Partial = convertMapJSONNumbers(updatedImage.CustomFields.Partial) + } + qb := r.repository.Image image, err := qb.UpdatePartial(ctx, imageID, updatedImage) if err != nil { @@ -237,6 +244,13 @@ func (r *mutationResolver) BulkImageUpdate(ctx context.Context, input BulkImageU return nil, fmt.Errorf("converting tag ids: %w", err) } + if input.CustomFields != nil { + updatedImage.CustomFields = *input.CustomFields + // convert json.Numbers to int/float + updatedImage.CustomFields.Full = convertMapJSONNumbers(updatedImage.CustomFields.Full) + updatedImage.CustomFields.Partial = convertMapJSONNumbers(updatedImage.CustomFields.Partial) + } + // Start the transaction and save the images if err := r.withTxn(ctx, func(ctx context.Context) error { var updatedGalleryIDs []int diff --git a/internal/autotag/integration_test.go b/internal/autotag/integration_test.go index 9745d623e..f537ecfe7 100644 --- a/internal/autotag/integration_test.go +++ b/internal/autotag/integration_test.go @@ -365,7 +365,10 @@ func makeImage(expectedResult bool) *models.Image { } func createImage(ctx context.Context, w models.ImageWriter, o *models.Image, f *models.ImageFile) error { - err := w.Create(ctx, o, []models.FileID{f.ID}) + err := w.Create(ctx, &models.CreateImageInput{ + Image: o, + FileIDs: []models.FileID{f.ID}, + }) if err != nil { return fmt.Errorf("Failed to create image with path '%s': %s", f.Path, err.Error()) diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go index 30adf626b..01bab9430 100644 --- a/internal/manager/task_export.go +++ b/internal/manager/task_export.go @@ -651,6 +651,7 @@ func (t *ExportTask) exportImage(ctx context.Context, wg *sync.WaitGroup, jobCha galleryReader := r.Gallery performerReader := r.Performer tagReader := r.Tag + imageReader := r.Image for s := range jobChan { imageHash := s.Checksum @@ -665,14 +666,17 @@ func (t *ExportTask) exportImage(ctx context.Context, wg *sync.WaitGroup, jobCha continue } - newImageJSON := image.ToBasicJSON(s) + newImageJSON, err := image.ToBasicJSON(ctx, imageReader, s) + if err != nil { + logger.Errorf("[images] <%s> error converting image to JSON: %v", imageHash, err) + continue + } // export files for _, f := range s.Files.List() { t.exportFile(f) } - var err error newImageJSON.Studio, err = image.GetStudioName(ctx, studioReader, s) if err != nil { logger.Errorf("[images] <%s> error getting image studio name: %v", imageHash, err) diff --git a/pkg/image/export.go b/pkg/image/export.go index fdba6165c..eb5d5da27 100644 --- a/pkg/image/export.go +++ b/pkg/image/export.go @@ -2,16 +2,21 @@ package image import ( "context" + "fmt" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" ) +type ExportReader interface { + models.CustomFieldsReader +} + // ToBasicJSON converts a image object into its JSON object equivalent. It // does not convert the relationships to other objects, with the exception // of cover image. -func ToBasicJSON(image *models.Image) *jsonschema.Image { +func ToBasicJSON(ctx context.Context, reader ExportReader, image *models.Image) (*jsonschema.Image, error) { newImageJSON := jsonschema.Image{ Title: image.Title, Code: image.Code, @@ -33,11 +38,17 @@ func ToBasicJSON(image *models.Image) *jsonschema.Image { newImageJSON.Organized = image.Organized newImageJSON.OCounter = image.OCounter + var err error + newImageJSON.CustomFields, err = reader.GetCustomFields(ctx, image.ID) + if err != nil { + return nil, fmt.Errorf("getting image custom fields: %v", err) + } + for _, f := range image.Files.List() { newImageJSON.Files = append(newImageJSON.Files, f.Base().Path) } - return &newImageJSON + return &newImageJSON, nil } // GetStudioName returns the name of the provided image's studio. It returns an diff --git a/pkg/image/export_test.go b/pkg/image/export_test.go index 6adaf1d33..d0d36afbb 100644 --- a/pkg/image/export_test.go +++ b/pkg/image/export_test.go @@ -29,6 +29,10 @@ var ( dateObj, _ = models.ParseDate(date) organized = true ocounter = 2 + + customFields = map[string]interface{}{ + "customField1": "customValue1", + } ) const ( @@ -60,7 +64,7 @@ func createFullImage(id int) models.Image { } } -func createFullJSONImage() *jsonschema.Image { +func createFullJSONImage(customFields map[string]interface{}) *jsonschema.Image { return &jsonschema.Image{ Title: title, OCounter: ocounter, @@ -75,28 +79,40 @@ func createFullJSONImage() *jsonschema.Image { UpdatedAt: json.JSONTime{ Time: updateTime, }, + CustomFields: customFields, } } type basicTestScenario struct { - input models.Image - expected *jsonschema.Image + input models.Image + customFields map[string]interface{} + expected *jsonschema.Image } var scenarios = []basicTestScenario{ { createFullImage(imageID), - createFullJSONImage(), + customFields, + createFullJSONImage(customFields), }, } func TestToJSON(t *testing.T) { + db := mocks.NewDatabase() + db.Image.On("GetCustomFields", testCtx, imageID).Return(customFields, nil).Once() + for i, s := range scenarios { image := s.input - json := ToBasicJSON(&image) + json, err := ToBasicJSON(testCtx, db.Image, &image) + if err != nil { + t.Errorf("[%d] unexpected error: %s", i, err.Error()) + continue + } assert.Equal(t, s.expected, json, "[%d]", i) } + + db.AssertExpectations(t) } func createStudioImage(studioID int) models.Image { diff --git a/pkg/image/import.go b/pkg/image/import.go index c7ef7f00c..d8dfa987f 100644 --- a/pkg/image/import.go +++ b/pkg/image/import.go @@ -31,8 +31,9 @@ type Importer struct { Input jsonschema.Image MissingRefBehaviour models.ImportMissingRefEnum - ID int - image models.Image + ID int + image models.Image + customFields map[string]interface{} } func (i *Importer) PreImport(ctx context.Context) error { @@ -58,6 +59,8 @@ func (i *Importer) PreImport(ctx context.Context) error { return err } + i.customFields = i.Input.CustomFields + return nil } @@ -344,7 +347,11 @@ func (i *Importer) Create(ctx context.Context) (*int, error) { fileIDs = append(fileIDs, f.Base().ID) } - err := i.ReaderWriter.Create(ctx, &i.image, fileIDs) + err := i.ReaderWriter.Create(ctx, &models.CreateImageInput{ + Image: &i.image, + FileIDs: fileIDs, + CustomFields: i.customFields, + }) if err != nil { return nil, fmt.Errorf("error creating image: %v", err) } diff --git a/pkg/image/import_test.go b/pkg/image/import_test.go index 5d01d4b97..a693c4568 100644 --- a/pkg/image/import_test.go +++ b/pkg/image/import_test.go @@ -45,7 +45,8 @@ func TestImporterPreImportWithStudio(t *testing.T) { i := Importer{ StudioWriter: db.Studio, Input: jsonschema.Image{ - Studio: existingStudioName, + Studio: existingStudioName, + CustomFields: customFields, }, } @@ -57,6 +58,7 @@ func TestImporterPreImportWithStudio(t *testing.T) { err := i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, existingStudioID, *i.image.StudioID) + assert.Equal(t, customFields, i.customFields) i.Input.Studio = existingStudioErr err = i.PreImport(testCtx) diff --git a/pkg/image/scan.go b/pkg/image/scan.go index 67f4b334c..317e3605f 100644 --- a/pkg/image/scan.go +++ b/pkg/image/scan.go @@ -27,7 +27,7 @@ type ScanCreatorUpdater interface { GetFiles(ctx context.Context, relatedID int) ([]models.File, error) GetGalleryIDs(ctx context.Context, relatedID int) ([]int, error) - Create(ctx context.Context, newImage *models.Image, fileIDs []models.FileID) error + Create(ctx context.Context, newImage *models.CreateImageInput) error UpdatePartial(ctx context.Context, id int, updatedImage models.ImagePartial) (*models.Image, error) AddFileID(ctx context.Context, id int, fileID models.FileID) error } @@ -124,7 +124,10 @@ func (h *ScanHandler) Handle(ctx context.Context, f models.File, oldFile models. logger.Infof("Adding %s to gallery %s", f.Base().Path, g.Path) } - if err := h.CreatorUpdater.Create(ctx, &newImage, []models.FileID{imageFile.ID}); err != nil { + if err := h.CreatorUpdater.Create(ctx, &models.CreateImageInput{ + Image: &newImage, + FileIDs: []models.FileID{imageFile.ID}, + }); err != nil { return fmt.Errorf("creating new image: %w", err) } diff --git a/pkg/models/image.go b/pkg/models/image.go index 84be79360..b99267e8c 100644 --- a/pkg/models/image.go +++ b/pkg/models/image.go @@ -1,6 +1,8 @@ package models -import "context" +import ( + "context" +) type ImageFilterType struct { OperatorFilter[ImageFilterType] @@ -65,25 +67,28 @@ type ImageFilterType struct { CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at UpdatedAt *TimestampCriterionInput `json:"updated_at"` + // Filter by custom fields + CustomFields []CustomFieldCriterionInput `json:"custom_fields"` } type ImageUpdateInput struct { - ClientMutationID *string `json:"clientMutationId"` - ID string `json:"id"` - Title *string `json:"title"` - Code *string `json:"code"` - Urls []string `json:"urls"` - Date *string `json:"date"` - Details *string `json:"details"` - Photographer *string `json:"photographer"` - Rating100 *int `json:"rating100"` - Organized *bool `json:"organized"` - SceneIds []string `json:"scene_ids"` - StudioID *string `json:"studio_id"` - TagIds []string `json:"tag_ids"` - PerformerIds []string `json:"performer_ids"` - GalleryIds []string `json:"gallery_ids"` - PrimaryFileID *string `json:"primary_file_id"` + ClientMutationID *string `json:"clientMutationId"` + ID string `json:"id"` + Title *string `json:"title"` + Code *string `json:"code"` + Urls []string `json:"urls"` + Date *string `json:"date"` + Details *string `json:"details"` + Photographer *string `json:"photographer"` + Rating100 *int `json:"rating100"` + Organized *bool `json:"organized"` + SceneIds []string `json:"scene_ids"` + StudioID *string `json:"studio_id"` + TagIds []string `json:"tag_ids"` + PerformerIds []string `json:"performer_ids"` + GalleryIds []string `json:"gallery_ids"` + PrimaryFileID *string `json:"primary_file_id"` + CustomFields *CustomFieldsInput `json:"custom_fields"` // deprecated URL *string `json:"url"` diff --git a/pkg/models/jsonschema/image.go b/pkg/models/jsonschema/image.go index 1bdac8770..168ea9eec 100644 --- a/pkg/models/jsonschema/image.go +++ b/pkg/models/jsonschema/image.go @@ -18,18 +18,19 @@ type Image struct { // deprecated - for import only URL string `json:"url,omitempty"` - URLs []string `json:"urls,omitempty"` - Date string `json:"date,omitempty"` - Details string `json:"details,omitempty"` - Photographer string `json:"photographer,omitempty"` - Organized bool `json:"organized,omitempty"` - OCounter int `json:"o_counter,omitempty"` - Galleries []GalleryRef `json:"galleries,omitempty"` - Performers []string `json:"performers,omitempty"` - Tags []string `json:"tags,omitempty"` - Files []string `json:"files,omitempty"` - CreatedAt json.JSONTime `json:"created_at,omitempty"` - UpdatedAt json.JSONTime `json:"updated_at,omitempty"` + URLs []string `json:"urls,omitempty"` + Date string `json:"date,omitempty"` + Details string `json:"details,omitempty"` + Photographer string `json:"photographer,omitempty"` + Organized bool `json:"organized,omitempty"` + OCounter int `json:"o_counter,omitempty"` + Galleries []GalleryRef `json:"galleries,omitempty"` + Performers []string `json:"performers,omitempty"` + Tags []string `json:"tags,omitempty"` + Files []string `json:"files,omitempty"` + CreatedAt json.JSONTime `json:"created_at,omitempty"` + UpdatedAt json.JSONTime `json:"updated_at,omitempty"` + CustomFields map[string]interface{} `json:"custom_fields,omitempty"` } func (s Image) Filename(basename string, hash string) string { diff --git a/pkg/models/mocks/ImageReaderWriter.go b/pkg/models/mocks/ImageReaderWriter.go index afc5efdb7..f2c9934be 100644 --- a/pkg/models/mocks/ImageReaderWriter.go +++ b/pkg/models/mocks/ImageReaderWriter.go @@ -137,13 +137,13 @@ func (_m *ImageReaderWriter) CoverByGalleryID(ctx context.Context, galleryId int return r0, r1 } -// Create provides a mock function with given fields: ctx, newImage, fileIDs -func (_m *ImageReaderWriter) Create(ctx context.Context, newImage *models.Image, fileIDs []models.FileID) error { - ret := _m.Called(ctx, newImage, fileIDs) +// Create provides a mock function with given fields: ctx, newImage +func (_m *ImageReaderWriter) Create(ctx context.Context, newImage *models.CreateImageInput) error { + ret := _m.Called(ctx, newImage) var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *models.Image, []models.FileID) error); ok { - r0 = rf(ctx, newImage, fileIDs) + if rf, ok := ret.Get(0).(func(context.Context, *models.CreateImageInput) error); ok { + r0 = rf(ctx, newImage) } else { r0 = ret.Error(0) } @@ -393,6 +393,52 @@ func (_m *ImageReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models return r0, r1 } +// GetCustomFields provides a mock function with given fields: ctx, id +func (_m *ImageReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) { + ret := _m.Called(ctx, id) + + var r0 map[string]interface{} + if rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]interface{}) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetCustomFieldsBulk provides a mock function with given fields: ctx, ids +func (_m *ImageReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) { + ret := _m.Called(ctx, ids) + + var r0 []models.CustomFieldMap + if rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok { + r0 = rf(ctx, ids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.CustomFieldMap) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { + r1 = rf(ctx, ids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetFiles provides a mock function with given fields: ctx, relatedID func (_m *ImageReaderWriter) GetFiles(ctx context.Context, relatedID int) ([]models.File, error) { ret := _m.Called(ctx, relatedID) @@ -694,6 +740,20 @@ func (_m *ImageReaderWriter) ResetOCounter(ctx context.Context, id int) (int, er return r0, r1 } +// SetCustomFields provides a mock function with given fields: ctx, id, fields +func (_m *ImageReaderWriter) SetCustomFields(ctx context.Context, id int, fields models.CustomFieldsInput) error { + ret := _m.Called(ctx, id, fields) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, models.CustomFieldsInput) error); ok { + r0 = rf(ctx, id, fields) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Size provides a mock function with given fields: ctx func (_m *ImageReaderWriter) Size(ctx context.Context) (float64, error) { ret := _m.Called(ctx) diff --git a/pkg/models/model_image.go b/pkg/models/model_image.go index 1d0993536..72ca61826 100644 --- a/pkg/models/model_image.go +++ b/pkg/models/model_image.go @@ -47,6 +47,13 @@ func NewImage() Image { } } +type CreateImageInput struct { + *Image + + FileIDs []FileID + CustomFields map[string]interface{} `json:"custom_fields"` +} + type ImagePartial struct { Title OptionalString Code OptionalString @@ -66,6 +73,7 @@ type ImagePartial struct { TagIDs *UpdateIDs PerformerIDs *UpdateIDs PrimaryFileID *FileID + CustomFields CustomFieldsInput } func NewImagePartial() ImagePartial { diff --git a/pkg/models/repository_image.go b/pkg/models/repository_image.go index 672ecd063..99dab3479 100644 --- a/pkg/models/repository_image.go +++ b/pkg/models/repository_image.go @@ -43,7 +43,7 @@ type ImageCounter interface { // ImageCreator provides methods to create images. type ImageCreator interface { - Create(ctx context.Context, newImage *Image, fileIDs []FileID) error + Create(ctx context.Context, newImage *CreateImageInput) error } // ImageUpdater provides methods to update images. @@ -78,6 +78,7 @@ type ImageReader interface { FileLoader GalleryCoverFinder + CustomFieldsReader All(ctx context.Context) ([]*Image, error) Size(ctx context.Context) (float64, error) @@ -88,6 +89,7 @@ type ImageWriter interface { ImageCreator ImageUpdater ImageDestroyer + CustomFieldsWriter AddFileID(ctx context.Context, id int, fileID FileID) error RemoveFileID(ctx context.Context, id int, fileID FileID) error diff --git a/pkg/sqlite/custom_fields.go b/pkg/sqlite/custom_fields.go index 63f85b250..d78e3f9ab 100644 --- a/pkg/sqlite/custom_fields.go +++ b/pkg/sqlite/custom_fields.go @@ -192,6 +192,10 @@ func (s *customFieldsStore) GetCustomFieldsBulk(ctx context.Context, ids []int) const single = false ret := make([]models.CustomFieldMap, len(ids)) + // initialise ret with empty maps for each id + for i := range ret { + ret[i] = make(map[string]interface{}) + } idi := make(map[int]int, len(ids)) for i, id := range ids { diff --git a/pkg/sqlite/custom_fields_test.go b/pkg/sqlite/custom_fields_test.go index 2f7ecd7dc..5d5545210 100644 --- a/pkg/sqlite/custom_fields_test.go +++ b/pkg/sqlite/custom_fields_test.go @@ -247,6 +247,12 @@ func TestGallerySetCustomFields(t *testing.T) { testSetCustomFields(t, "Gallery", db.Gallery, galleryIDs[galleryIdx], getGalleryCustomFields(galleryIdx)) } +func TestImageSetCustomFields(t *testing.T) { + imageIdx := imageIdx2WithGallery + + testSetCustomFields(t, "Image", db.Image, imageIDs[imageIdx], getImageCustomFields(imageIdx)) +} + func TestGroupSetCustomFields(t *testing.T) { groupIdx := groupIdxWithScene diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 000b91c4d..003c6eebc 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 82 +var appSchemaVersion uint = 83 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index bcaf3f42f..da1c67a10 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -185,6 +185,8 @@ var ( ) type ImageStore struct { + customFieldsStore + tableMgr *table oCounterManager @@ -193,6 +195,10 @@ type ImageStore struct { func NewImageStore(r *storeRepository) *ImageStore { return &ImageStore{ + customFieldsStore: customFieldsStore{ + table: imagesCustomFieldsTable, + fk: imagesCustomFieldsTable.Col(imageIDColumn), + }, tableMgr: imageTableMgr, oCounterManager: oCounterManager{imageTableMgr}, repo: r, @@ -236,18 +242,18 @@ func (qb *ImageStore) selectDataset() *goqu.SelectDataset { ) } -func (qb *ImageStore) Create(ctx context.Context, newObject *models.Image, fileIDs []models.FileID) error { +func (qb *ImageStore) Create(ctx context.Context, newObject *models.CreateImageInput) error { var r imageRow - r.fromImage(*newObject) + r.fromImage(*newObject.Image) id, err := qb.tableMgr.insertID(ctx, r) if err != nil { return err } - if len(fileIDs) > 0 { + if len(newObject.FileIDs) > 0 { const firstPrimary = true - if err := imagesFilesTableMgr.insertJoins(ctx, id, firstPrimary, fileIDs); err != nil { + if err := imagesFilesTableMgr.insertJoins(ctx, id, firstPrimary, newObject.FileIDs); err != nil { return err } } @@ -276,12 +282,18 @@ func (qb *ImageStore) Create(ctx context.Context, newObject *models.Image, fileI } } + if err := qb.SetCustomFields(ctx, id, models.CustomFieldsInput{ + Full: newObject.CustomFields, + }); err != nil { + return err + } + updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) } - *newObject = *updated + *newObject.Image = *updated return nil } @@ -329,6 +341,10 @@ func (qb *ImageStore) UpdatePartial(ctx context.Context, id int, partial models. } } + if err := qb.SetCustomFields(ctx, id, partial.CustomFields); err != nil { + return nil, err + } + return qb.find(ctx, id) } diff --git a/pkg/sqlite/image_filter.go b/pkg/sqlite/image_filter.go index b56ade26d..aafd2aa40 100644 --- a/pkg/sqlite/image_filter.go +++ b/pkg/sqlite/image_filter.go @@ -100,6 +100,13 @@ func (qb *imageFilterHandler) criterionHandler() criterionHandler { ×tampCriterionHandler{imageFilter.CreatedAt, "images.created_at", nil}, ×tampCriterionHandler{imageFilter.UpdatedAt, "images.updated_at", nil}, + &customFieldsFilterHandler{ + table: imagesCustomFieldsTable.GetTable(), + fkCol: imageIDColumn, + c: imageFilter.CustomFields, + idCol: "images.id", + }, + &relatedFilterHandler{ relatedIDCol: "galleries_images.gallery_id", relatedRepo: galleryRepository.repository, diff --git a/pkg/sqlite/image_test.go b/pkg/sqlite/image_test.go index aa4ed3b99..3bad40b3b 100644 --- a/pkg/sqlite/image_test.go +++ b/pkg/sqlite/image_test.go @@ -73,81 +73,94 @@ func Test_imageQueryBuilder_Create(t *testing.T) { tests := []struct { name string - newObject models.Image + newObject models.CreateImageInput wantErr bool }{ { "full", - models.Image{ - Title: title, - Code: code, - Rating: &rating, - Date: &date, - Details: details, - Photographer: photographer, - URLs: models.NewRelatedStrings([]string{url}), - Organized: true, - OCounter: ocounter, - StudioID: &studioIDs[studioIdxWithImage], - CreatedAt: createdAt, - UpdatedAt: updatedAt, - GalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithImage]}), - TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithImage]}), - PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithImage], performerIDs[performerIdx1WithDupName]}), + models.CreateImageInput{ + Image: &models.Image{ + Title: title, + Code: code, + Rating: &rating, + Date: &date, + Details: details, + Photographer: photographer, + URLs: models.NewRelatedStrings([]string{url}), + Organized: true, + OCounter: ocounter, + StudioID: &studioIDs[studioIdxWithImage], + CreatedAt: createdAt, + UpdatedAt: updatedAt, + GalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithImage]}), + TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithImage]}), + PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithImage], performerIDs[performerIdx1WithDupName]}), + }, + CustomFields: testCustomFields, }, false, }, { "with file", - models.Image{ - Title: title, - Code: code, - Rating: &rating, - Date: &date, - Details: details, - Photographer: photographer, - URLs: models.NewRelatedStrings([]string{url}), - Organized: true, - OCounter: ocounter, - StudioID: &studioIDs[studioIdxWithImage], - Files: models.NewRelatedFiles([]models.File{ - imageFile.(*models.ImageFile), - }), - PrimaryFileID: &imageFile.Base().ID, - Path: imageFile.Base().Path, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - GalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithImage]}), - TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithImage]}), - PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithImage], performerIDs[performerIdx1WithDupName]}), + models.CreateImageInput{ + Image: &models.Image{ + Title: title, + Code: code, + Rating: &rating, + Date: &date, + Details: details, + Photographer: photographer, + URLs: models.NewRelatedStrings([]string{url}), + Organized: true, + OCounter: ocounter, + StudioID: &studioIDs[studioIdxWithImage], + Files: models.NewRelatedFiles([]models.File{ + imageFile.(*models.ImageFile), + }), + PrimaryFileID: &imageFile.Base().ID, + Path: imageFile.Base().Path, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + GalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithImage]}), + TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithImage]}), + PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithImage], performerIDs[performerIdx1WithDupName]}), + }, }, false, }, { "invalid studio id", - models.Image{ - StudioID: &invalidID, + models.CreateImageInput{ + Image: &models.Image{ + StudioID: &invalidID, + }, }, true, }, { "invalid gallery id", - models.Image{ - GalleryIDs: models.NewRelatedIDs([]int{invalidID}), + models.CreateImageInput{ + Image: &models.Image{ + GalleryIDs: models.NewRelatedIDs([]int{invalidID}), + }, }, true, }, { "invalid tag id", - models.Image{ - TagIDs: models.NewRelatedIDs([]int{invalidID}), + models.CreateImageInput{ + Image: &models.Image{ + TagIDs: models.NewRelatedIDs([]int{invalidID}), + }, }, true, }, { "invalid performer id", - models.Image{ - PerformerIDs: models.NewRelatedIDs([]int{invalidID}), + models.CreateImageInput{ + Image: &models.Image{ + PerformerIDs: models.NewRelatedIDs([]int{invalidID}), + }, }, true, }, @@ -165,8 +178,11 @@ func Test_imageQueryBuilder_Create(t *testing.T) { fileIDs = append(fileIDs, f.Base().ID) } } - s := tt.newObject - if err := qb.Create(ctx, &s, fileIDs); (err != nil) != tt.wantErr { + s := *tt.newObject.Image + if err := qb.Create(ctx, &models.CreateImageInput{ + Image: &s, + FileIDs: fileIDs, + }); (err != nil) != tt.wantErr { t.Errorf("imageQueryBuilder.Create() error = %v, wantErr = %v", err, tt.wantErr) } @@ -177,7 +193,7 @@ func Test_imageQueryBuilder_Create(t *testing.T) { assert.NotZero(s.ID) - copy := tt.newObject + copy := *tt.newObject.Image copy.ID = s.ID // load relationships @@ -201,8 +217,6 @@ func Test_imageQueryBuilder_Create(t *testing.T) { } assert.Equal(copy, *found) - - return }) } } @@ -387,8 +401,6 @@ func Test_imageQueryBuilder_Update(t *testing.T) { } assert.Equal(copy, *s) - - return }) } } @@ -832,6 +844,79 @@ func Test_imageQueryBuilder_UpdatePartialRelationships(t *testing.T) { } } +func Test_ImageStore_UpdatePartialCustomFields(t *testing.T) { + tests := []struct { + name string + id int + partial models.ImagePartial + expected map[string]interface{} // nil to use the partial + }{ + { + "set custom fields", + imageIDs[imageIdx1WithGallery], + models.ImagePartial{ + CustomFields: models.CustomFieldsInput{ + Full: testCustomFields, + }, + }, + nil, + }, + { + "clear custom fields", + imageIDs[imageIdx1WithGallery], + models.ImagePartial{ + CustomFields: models.CustomFieldsInput{ + Full: map[string]interface{}{}, + }, + }, + nil, + }, + { + "partial custom fields", + imageIDs[imageIdxWithStudio], + models.ImagePartial{ + CustomFields: models.CustomFieldsInput{ + Partial: map[string]interface{}{ + "string": "bbb", + "new_field": "new", + }, + }, + }, + map[string]interface{}{ + "int": int64(2), + "real": 1.2, + "string": "bbb", + "new_field": "new", + }, + }, + } + for _, tt := range tests { + qb := db.Image + + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + _, err := qb.UpdatePartial(ctx, tt.id, tt.partial) + if err != nil { + t.Errorf("ImageStore.UpdatePartial() error = %v", err) + return + } + + // ensure custom fields are correct + cf, err := qb.GetCustomFields(ctx, tt.id) + if err != nil { + t.Errorf("ImageStore.GetCustomFields() error = %v", err) + return + } + if tt.expected == nil { + assert.Equal(tt.partial.CustomFields.Full, cf) + } else { + assert.Equal(tt.expected, cf) + } + }) + } +} + func Test_imageQueryBuilder_IncrementOCounter(t *testing.T) { tests := []struct { name string @@ -3018,6 +3103,252 @@ func TestImageQueryPagination(t *testing.T) { }) } +func TestImageQueryCustomFields(t *testing.T) { + tests := []struct { + name string + filter *models.ImageFilterType + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "equals", + &models.ImageFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierEquals, + Value: []any{getImageStringValue(imageIdx1WithGallery, "custom")}, + }, + }, + }, + []int{imageIdx1WithGallery}, + nil, + false, + }, + { + "not equals", + &models.ImageFilterType{ + Title: &models.StringCriterionInput{ + Value: getImageStringValue(imageIdx1WithGallery, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotEquals, + Value: []any{getImageStringValue(imageIdx1WithGallery, "custom")}, + }, + }, + }, + nil, + []int{imageIdx1WithGallery}, + false, + }, + { + "includes", + &models.ImageFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierIncludes, + Value: []any{getImageStringValue(imageIdx1WithGallery, "custom")[9:]}, + }, + }, + }, + []int{imageIdx1WithGallery}, + nil, + false, + }, + { + "excludes", + &models.ImageFilterType{ + Title: &models.StringCriterionInput{ + Value: getImageStringValue(imageIdx1WithGallery, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierExcludes, + Value: []any{getImageStringValue(imageIdx1WithGallery, "custom")[9:]}, + }, + }, + }, + nil, + []int{imageIdx1WithGallery}, + false, + }, + { + "regex", + &models.ImageFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierMatchesRegex, + Value: []any{".*17_custom"}, + }, + }, + }, + []int{imageIdxWithPerformerTag}, + nil, + false, + }, + { + "invalid regex", + &models.ImageFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierMatchesRegex, + Value: []any{"["}, + }, + }, + }, + nil, + nil, + true, + }, + { + "not matches regex", + &models.ImageFilterType{ + Title: &models.StringCriterionInput{ + Value: getImageStringValue(imageIdxWithPerformerTag, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotMatchesRegex, + Value: []any{".*17_custom"}, + }, + }, + }, + nil, + []int{imageIdxWithPerformerTag}, + false, + }, + { + "invalid not matches regex", + &models.ImageFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotMatchesRegex, + Value: []any{"["}, + }, + }, + }, + nil, + nil, + true, + }, + { + "null", + &models.ImageFilterType{ + Title: &models.StringCriterionInput{ + Value: getImageStringValue(imageIdx1WithGallery, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "not existing", + Modifier: models.CriterionModifierIsNull, + }, + }, + }, + []int{imageIdx1WithGallery}, + nil, + false, + }, + { + "not null", + &models.ImageFilterType{ + Title: &models.StringCriterionInput{ + Value: getImageStringValue(imageIdx1WithGallery, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotNull, + }, + }, + }, + []int{imageIdx1WithGallery}, + nil, + false, + }, + { + "between", + &models.ImageFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "real", + Modifier: models.CriterionModifierBetween, + Value: []any{0.15, 0.25}, + }, + }, + }, + []int{imageIdx2WithGallery}, + nil, + false, + }, + { + "not between", + &models.ImageFilterType{ + Title: &models.StringCriterionInput{ + Value: getImageStringValue(imageIdx2WithGallery, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "real", + Modifier: models.CriterionModifierNotBetween, + Value: []any{0.15, 0.25}, + }, + }, + }, + nil, + []int{imageIdx2WithGallery}, + false, + }, + } + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + result, err := db.Image.Query(ctx, models.ImageQueryOptions{ + ImageFilter: tt.filter, + }) + if (err != nil) != tt.wantErr { + t.Errorf("ImageStore.Query() error = %v, wantErr %v", err, tt.wantErr) + } + + if err != nil { + return + } + + images, err := result.Resolve(ctx) + if err != nil { + t.Errorf("ImageStore.Query().Resolve() error = %v", err) + } + + ids := imagesToIDs(images) + include := indexesToIDs(imageIDs, tt.includeIdxs) + exclude := indexesToIDs(imageIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } +} + // TODO Count // TODO SizeCount // TODO All diff --git a/pkg/sqlite/migrations/83_image_custom_fields.up.sql b/pkg/sqlite/migrations/83_image_custom_fields.up.sql new file mode 100644 index 000000000..0aa3aa4d7 --- /dev/null +++ b/pkg/sqlite/migrations/83_image_custom_fields.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE `image_custom_fields` ( + `image_id` integer NOT NULL, + `field` varchar(64) NOT NULL, + `value` BLOB NOT NULL, + PRIMARY KEY (`image_id`, `field`), + foreign key(`image_id`) references `images`(`id`) on delete CASCADE +); + +CREATE INDEX `index_image_custom_fields_field_value` ON `image_custom_fields` (`field`, `value`); diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 0078f1a67..2848a0a14 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1247,6 +1247,18 @@ func getImageBasename(index int) string { return getImageStringValue(index, pathField) } +func getImageCustomFields(index int) map[string]interface{} { + if index%5 == 0 { + return nil + } + + return map[string]interface{}{ + "string": getImageStringValue(index, "custom"), + "int": int64(index % 5), + "real": float64(index) / 10, + } +} + func makeImageFile(i int) *models.ImageFile { return &models.ImageFile{ BaseFile: &models.BaseFile{ @@ -1309,7 +1321,11 @@ func createImages(ctx context.Context, n int) error { image := makeImage(i) - err := qb.Create(ctx, image, []models.FileID{f.ID}) + err := qb.Create(ctx, &models.CreateImageInput{ + Image: image, + FileIDs: []models.FileID{f.ID}, + CustomFields: getImageCustomFields(i), + }) if err != nil { return fmt.Errorf("Error creating image %v+: %s", image, err.Error()) diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 6c898048d..4c09113f0 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -14,6 +14,7 @@ var ( performersImagesJoinTable = goqu.T(performersImagesTable) imagesFilesJoinTable = goqu.T(imagesFilesTable) imagesURLsJoinTable = goqu.T(imagesURLsTable) + imagesCustomFieldsTable = goqu.T("image_custom_fields") galleriesFilesJoinTable = goqu.T(galleriesFilesTable) galleriesTagsJoinTable = goqu.T(galleriesTagsTable) From 410dd27d93bd3fce553902f334eccde0311f17da Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:54:20 +1100 Subject: [PATCH 082/177] Fix misclicks resulting in navigating to new page during selection (#6599) * Disable studio overlay link if selecting * Prevent scene preview scrubber click navigating during selection * Prevent gallery preview scrubber click navigating during selection --- .../src/components/Galleries/GalleryCard.tsx | 17 +++++++++++++++-- .../Galleries/GalleryPreviewScrubber.tsx | 3 +++ ui/v2.5/src/components/Images/ImageCard.tsx | 8 +++++++- .../src/components/Scenes/PreviewScrubber.tsx | 3 +++ ui/v2.5/src/components/Scenes/SceneCard.tsx | 18 ++++++++++++++++-- .../Shared/GridCard/StudioOverlay.tsx | 11 +++++++++-- .../src/components/Shared/HoverScrubber.tsx | 7 +++++++ 7 files changed, 60 insertions(+), 7 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryCard.tsx b/ui/v2.5/src/components/Galleries/GalleryCard.tsx index e4e227f3e..01e0b6045 100644 --- a/ui/v2.5/src/components/Galleries/GalleryCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryCard.tsx @@ -1,5 +1,5 @@ import { Button, ButtonGroup, OverlayTrigger, Tooltip } from "react-bootstrap"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import * as GQL from "src/core/generated-graphql"; import { GridCard } from "../Shared/GridCard/GridCard"; import { HoverPopover } from "../Shared/HoverPopover"; @@ -21,11 +21,13 @@ import { PatchComponent } from "src/patch"; interface IGalleryPreviewProps { gallery: GQL.SlimGalleryDataFragment; onScrubberClick?: (index: number) => void; + disabled?: boolean; } export const GalleryPreview: React.FC = ({ gallery, onScrubberClick, + disabled, }) => { const [imgSrc, setImgSrc] = useState( gallery.paths.cover ?? undefined @@ -48,6 +50,7 @@ export const GalleryPreview: React.FC = ({ imageCount={gallery.image_count} onClick={onScrubberClick} onPathChanged={setImgSrc} + disabled={disabled} /> )}
@@ -195,7 +198,16 @@ const GalleryCardDetails = PatchComponent( const GalleryCardOverlays = PatchComponent( "GalleryCard.Overlays", (props: IGalleryCardProps) => { - return ; + const ret = useMemo(() => { + return ( + + ); + }, [props.gallery.studio, props.selecting]); + + return ret; } ); @@ -211,6 +223,7 @@ const GalleryCardImage = PatchComponent( onScrubberClick={(i) => { history.push(`/galleries/${props.gallery.id}/images/${i}`); }} + disabled={props.selecting} /> diff --git a/ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx b/ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx index ef47782bf..5c0a07356 100644 --- a/ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx @@ -10,6 +10,7 @@ export const GalleryPreviewScrubber: React.FC<{ imageCount: number; onClick?: (imageIndex: number) => void; onPathChanged: React.Dispatch>; + disabled?: boolean; }> = ({ className, previewPath, @@ -17,6 +18,7 @@ export const GalleryPreviewScrubber: React.FC<{ imageCount, onClick, onPathChanged, + disabled, }) => { const [activeIndex, setActiveIndex] = useState(); const debounceSetActiveIndex = useThrottle(setActiveIndex, 50); @@ -48,6 +50,7 @@ export const GalleryPreviewScrubber: React.FC<{ activeIndex={activeIndex} setActiveIndex={(i) => debounceSetActiveIndex(i)} onClick={onScrubberClick} + disabled={disabled} />
); diff --git a/ui/v2.5/src/components/Images/ImageCard.tsx b/ui/v2.5/src/components/Images/ImageCard.tsx index adaee9923..a1189c844 100644 --- a/ui/v2.5/src/components/Images/ImageCard.tsx +++ b/ui/v2.5/src/components/Images/ImageCard.tsx @@ -148,7 +148,13 @@ const ImageCardDetails = PatchComponent( const ImageCardOverlays = PatchComponent( "ImageCard.Overlays", (props: IImageCardProps) => { - return ; + const ret = useMemo(() => { + return ( + + ); + }, [props.image.studio, props.selecting]); + + return ret; } ); diff --git a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx index 8ecb6e557..8c9d3097d 100644 --- a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx +++ b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx @@ -13,6 +13,7 @@ import { HoverScrubber } from "../Shared/HoverScrubber"; interface IScenePreviewProps { vttPath: string | undefined; onClick?: (timestamp: number) => void; + disabled?: boolean; } function scaleToFit(dimensions: { w: number; h: number }, bounds: DOMRect) { @@ -32,6 +33,7 @@ const defaultSprites = 81; // 9x9 grid by default export const PreviewScrubber: React.FC = ({ vttPath, onClick, + disabled, }) => { const imageParentRef = useRef(null); const [style, setStyle] = useState({}); @@ -113,6 +115,7 @@ export const PreviewScrubber: React.FC = ({ activeIndex={activeIndex} setActiveIndex={(i) => debounceSetActiveIndex(i)} onClick={onScrubberClick} + disabled={disabled} />
); diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index 2cb4a9af3..b7c263168 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -38,6 +38,7 @@ interface IScenePreviewProps { soundActive: boolean; vttPath?: string; onScrubberClick?: (timestamp: number) => void; + disabled?: boolean; } export const ScenePreview: React.FC = ({ @@ -47,6 +48,7 @@ export const ScenePreview: React.FC = ({ soundActive, vttPath, onScrubberClick, + disabled, }) => { const videoEl = useRef(null); @@ -86,7 +88,11 @@ export const ScenePreview: React.FC = ({ ref={videoEl} src={video} /> - +
); }; @@ -336,7 +342,13 @@ const SceneCardDetails = PatchComponent( const SceneCardOverlays = PatchComponent( "SceneCard.Overlays", (props: ISceneCardProps) => { - return ; + const ret = useMemo(() => { + return ( + + ); + }, [props.scene.studio, props.selecting]); + + return ret; } ); @@ -390,6 +402,7 @@ const SceneCardImage = PatchComponent( } function onScrubberClick(timestamp: number) { + if (props.selecting) return; const link = props.queue ? props.queue.makeLink(props.scene.id, { sceneIndex: props.index, @@ -416,6 +429,7 @@ const SceneCardImage = PatchComponent( soundActive={configuration?.interface?.soundOnPreview ?? false} vttPath={props.scene.paths.vtt ?? undefined} onScrubberClick={onScrubberClick} + disabled={props.selecting} /> {maybeRenderSceneSpecsOverlay()} diff --git a/ui/v2.5/src/components/Shared/GridCard/StudioOverlay.tsx b/ui/v2.5/src/components/Shared/GridCard/StudioOverlay.tsx index 9bfd25071..6fe07a454 100644 --- a/ui/v2.5/src/components/Shared/GridCard/StudioOverlay.tsx +++ b/ui/v2.5/src/components/Shared/GridCard/StudioOverlay.tsx @@ -10,7 +10,8 @@ interface IStudio { export const StudioOverlay: React.FC<{ studio: IStudio | null | undefined; -}> = ({ studio }) => { + disabled?: boolean; +}> = ({ studio, disabled }) => { const { configuration } = useConfigurationContext(); const configValue = configuration?.interface.showStudioAsText; @@ -29,12 +30,18 @@ export const StudioOverlay: React.FC<{ return false; }, [configValue, studio?.image_path]); + function onClick(e: React.MouseEvent) { + if (disabled) { + e.preventDefault(); + } + } + if (!studio) return <>; return ( // this class name is incorrect
- + {showStudioAsText ? ( studio.name ) : ( diff --git a/ui/v2.5/src/components/Shared/HoverScrubber.tsx b/ui/v2.5/src/components/Shared/HoverScrubber.tsx index 7c07e8adc..17c0ed79e 100644 --- a/ui/v2.5/src/components/Shared/HoverScrubber.tsx +++ b/ui/v2.5/src/components/Shared/HoverScrubber.tsx @@ -9,6 +9,7 @@ interface IHoverScrubber { activeIndex: number | undefined; setActiveIndex: (index: number | undefined) => void; onClick?: (index: number) => void; + disabled?: boolean; } export const HoverScrubber: React.FC = ({ @@ -16,6 +17,7 @@ export const HoverScrubber: React.FC = ({ activeIndex, setActiveIndex, onClick, + disabled, }) => { function getActiveIndex( e: @@ -69,6 +71,11 @@ export const HoverScrubber: React.FC = ({ | React.TouchEvent ) { if (!onClick) return; + if (disabled) { + // allow propagation up so that selection still works + e.preventDefault(); + return; + } const relatedTarget = e.currentTarget; From 14105a2d54d77324ad90a994f586bd5d0705a14b Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:54:40 +1100 Subject: [PATCH 083/177] Rename checksum and hash fields (#6600) Checksum -> MD5 Checksum Hash -> oshash with hover showing OpenSubtitles Hash. Also internationalised perceptual hash hover text. --- .../Images/ImageDetails/ImageFileInfoPanel.tsx | 7 ++++--- .../Scenes/SceneDetails/SceneFileInfoPanel.tsx | 11 ++++++++--- ui/v2.5/src/locales/en-GB.json | 6 ++++-- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx index c346cf874..097a64340 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import { Accordion, Button, Card } from "react-bootstrap"; -import { FormattedMessage, FormattedTime } from "react-intl"; +import { FormattedMessage, FormattedTime, useIntl } from "react-intl"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { DeleteFilesDialog } from "src/components/Shared/DeleteFilesDialog"; import { RevealInFilesystemButton } from "src/components/Shared/RevealInFilesystemButton"; @@ -24,6 +24,7 @@ interface IFileInfoPanelProps { const FileInfoPanel: React.FC = ( props: IFileInfoPanelProps ) => { + const intl = useIntl(); const checksum = props.file.fingerprints.find((f) => f.type === "md5"); const phash = props.file.fingerprints.find((f) => f.type === "phash"); @@ -38,10 +39,10 @@ const FileInfoPanel: React.FC = ( )} - + = ( )} - - + + Date: Tue, 24 Feb 2026 16:39:14 -0800 Subject: [PATCH 084/177] FR: Tags Tagger (#6559) * Refactor Tagger components * condense localization * add alias and description to model and schema --- graphql/schema/schema.graphql | 2 + graphql/schema/types/scraper.graphql | 2 + graphql/stash-box/query.graphql | 2 + internal/api/resolver_mutation_stash_box.go | 10 + internal/manager/manager_tasks.go | 130 +++ internal/manager/task_stash_box_tag.go | 173 ++++ pkg/models/mocks/TagReaderWriter.go | 23 + pkg/models/model_scraped_item.go | 52 +- pkg/models/repository_tag.go | 1 + pkg/sqlite/tag.go | 30 + pkg/stashbox/graphql/generated_client.go | 28 +- pkg/stashbox/tag.go | 25 +- ui/v2.5/graphql/data/scrapers.graphql | 2 + ui/v2.5/graphql/mutations/stash-box.graphql | 4 + .../Shared/ScrapeDialog/ScrapedObjectsRow.tsx | 8 +- ...merFieldSelector.tsx => FieldSelector.tsx} | 13 +- .../Config.tsx => TaggerConfig.tsx} | 51 +- ui/v2.5/src/components/Tagger/constants.ts | 4 + .../Tagger/performers/PerformerTagger.tsx | 12 +- ui/v2.5/src/components/Tagger/queries.ts | 41 + .../src/components/Tagger/studios/Config.tsx | 130 --- .../Tagger/studios/StudioFieldSelector.tsx | 68 -- .../Tagger/studios/StudioTagger.tsx | 34 +- .../Tagger/tags/StashSearchResult.tsx | 119 +++ .../src/components/Tagger/tags/TagModal.tsx | 144 ++++ .../src/components/Tagger/tags/TagTagger.tsx | 758 ++++++++++++++++++ ui/v2.5/src/components/Tagger/utils.ts | 30 +- ui/v2.5/src/components/Tags/TagList.tsx | 4 + ui/v2.5/src/core/StashService.ts | 6 + ui/v2.5/src/locales/en-GB.json | 52 +- ui/v2.5/src/models/list-filter/tags.ts | 6 +- 31 files changed, 1702 insertions(+), 262 deletions(-) rename ui/v2.5/src/components/Tagger/{PerformerFieldSelector.tsx => FieldSelector.tsx} (84%) rename ui/v2.5/src/components/Tagger/{performers/Config.tsx => TaggerConfig.tsx} (69%) delete mode 100644 ui/v2.5/src/components/Tagger/studios/Config.tsx delete mode 100644 ui/v2.5/src/components/Tagger/studios/StudioFieldSelector.tsx create mode 100644 ui/v2.5/src/components/Tagger/tags/StashSearchResult.tsx create mode 100644 ui/v2.5/src/components/Tagger/tags/TagModal.tsx create mode 100644 ui/v2.5/src/components/Tagger/tags/TagTagger.tsx diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 996afefe7..7f07e4579 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -583,6 +583,8 @@ type Mutation { stashBoxBatchPerformerTag(input: StashBoxBatchTagInput!): String! "Run batch studio tag task. Returns the job ID." stashBoxBatchStudioTag(input: StashBoxBatchTagInput!): String! + "Run batch tag tag task. Returns the job ID." + stashBoxBatchTagTag(input: StashBoxBatchTagInput!): String! "Enables DLNA for an optional duration. Has no effect if DLNA is enabled by default" enableDLNA(input: EnableDLNAInput!): Boolean! diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index 9c0e33fdf..b8810aa79 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -71,6 +71,8 @@ type ScrapedTag { "Set if tag matched" stored_id: ID name: String! + description: String + alias_list: [String!] "Remote site ID, if applicable" remote_site_id: String } diff --git a/graphql/stash-box/query.graphql b/graphql/stash-box/query.graphql index e2686ac4d..edd44c835 100644 --- a/graphql/stash-box/query.graphql +++ b/graphql/stash-box/query.graphql @@ -29,6 +29,8 @@ fragment StudioFragment on Studio { fragment TagFragment on Tag { name id + description + aliases } fragment MeasurementsFragment on Measurements { diff --git a/internal/api/resolver_mutation_stash_box.go b/internal/api/resolver_mutation_stash_box.go index 436937511..6d2ab84fd 100644 --- a/internal/api/resolver_mutation_stash_box.go +++ b/internal/api/resolver_mutation_stash_box.go @@ -58,6 +58,16 @@ func (r *mutationResolver) StashBoxBatchStudioTag(ctx context.Context, input man return strconv.Itoa(jobID), nil } +func (r *mutationResolver) StashBoxBatchTagTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) { + b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) //nolint:staticcheck + if err != nil { + return "", err + } + + jobID := manager.GetInstance().StashBoxBatchTagTag(ctx, b, input) + return strconv.Itoa(jobID), nil +} + func (r *mutationResolver) SubmitStashBoxSceneDraft(ctx context.Context, input StashBoxDraftSubmissionInput) (*string, error) { b, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint) if err != nil { diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index bac726c1b..e97227fcf 100644 --- a/internal/manager/manager_tasks.go +++ b/internal/manager/manager_tasks.go @@ -704,3 +704,133 @@ func (s *Manager) StashBoxBatchStudioTag(ctx context.Context, box *models.StashB return s.JobManager.Add(ctx, "Batch stash-box studio tag...", j) } + +func (s *Manager) batchTagTagsByIds(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) { + var tasks []Task + + err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { + tagQuery := s.Repository.Tag + + for _, tagID := range input.Ids { + if id, err := strconv.Atoi(tagID); err == nil { + t, err := tagQuery.Find(ctx, id) + if err != nil { + return err + } + + if err := t.LoadStashIDs(ctx, tagQuery); err != nil { + return fmt.Errorf("loading tag stash ids: %w", err) + } + + hasStashID := t.StashIDs.ForEndpoint(box.Endpoint) != nil + if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) { + tasks = append(tasks, &stashBoxBatchTagTagTask{ + tag: t, + box: box, + excludedFields: input.ExcludeFields, + }) + } + } + } + return nil + }) + + return tasks, err +} + +func (s *Manager) batchTagTagsByNamesOrStashIds(input StashBoxBatchTagInput, box *models.StashBox) []Task { + var tasks []Task + + for i := range input.StashIDs { + stashID := input.StashIDs[i] + if len(stashID) > 0 { + tasks = append(tasks, &stashBoxBatchTagTagTask{ + stashID: &stashID, + box: box, + excludedFields: input.ExcludeFields, + }) + } + } + + for i := range input.Names { + name := input.Names[i] + if len(name) > 0 { + tasks = append(tasks, &stashBoxBatchTagTagTask{ + name: &name, + box: box, + excludedFields: input.ExcludeFields, + }) + } + } + + return tasks +} + +func (s *Manager) batchTagAllTags(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) { + var tasks []Task + + err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { + tagQuery := s.Repository.Tag + var tags []*models.Tag + var err error + + tags, err = tagQuery.FindByStashIDStatus(ctx, input.Refresh, box.Endpoint) + + if err != nil { + return fmt.Errorf("error querying tags: %v", err) + } + + for _, t := range tags { + tasks = append(tasks, &stashBoxBatchTagTagTask{ + tag: t, + box: box, + excludedFields: input.ExcludeFields, + }) + } + return nil + }) + + return tasks, err +} + +func (s *Manager) StashBoxBatchTagTag(ctx context.Context, box *models.StashBox, input StashBoxBatchTagInput) int { + j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error { + logger.Infof("Initiating stash-box batch tag tag") + + var tasks []Task + var err error + + switch input.getBatchTagType(false) { + case batchTagByIds: + tasks, err = s.batchTagTagsByIds(ctx, input, box) + case batchTagByNamesOrStashIds: + tasks = s.batchTagTagsByNamesOrStashIds(input, box) + case batchTagAll: + tasks, err = s.batchTagAllTags(ctx, input, box) + } + + if err != nil { + return err + } + + if len(tasks) == 0 { + return nil + } + + progress.SetTotal(len(tasks)) + + logger.Infof("Starting stash-box batch operation for %d tags", len(tasks)) + + for _, task := range tasks { + progress.ExecuteTask(task.GetDescription(), func() { + task.Start(ctx) + }) + + progress.Increment() + } + + return nil + }) + + return s.JobManager.Add(ctx, "Batch stash-box tag tag...", j) +} diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index 4848b46ad..97c766010 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -12,6 +12,7 @@ import ( "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/stashbox" "github.com/stashapp/stash/pkg/studio" + "github.com/stashapp/stash/pkg/tag" ) // stashBoxBatchPerformerTagTask is used to tag or create performers from stash-box. @@ -529,3 +530,175 @@ func (t *stashBoxBatchStudioTagTask) processParentStudio(ctx context.Context, pa return err } } + +// stashBoxBatchTagTagTask is used to tag or create tags from stash-box. +// +// Two modes of operation: +// - Update existing tag: set tag to update from stash-box data +// - Create new tag: set name or stashID to search stash-box and create locally +type stashBoxBatchTagTagTask struct { + box *models.StashBox + name *string + stashID *string + tag *models.Tag + excludedFields []string +} + +func (t *stashBoxBatchTagTagTask) getName() string { + switch { + case t.name != nil: + return *t.name + case t.stashID != nil: + return *t.stashID + case t.tag != nil: + return t.tag.Name + default: + return "" + } +} + +func (t *stashBoxBatchTagTagTask) Start(ctx context.Context) { + scrapedTag, err := t.findStashBoxTag(ctx) + if err != nil { + logger.Errorf("Error fetching tag data from stash-box: %v", err) + return + } + + excluded := map[string]bool{} + for _, field := range t.excludedFields { + excluded[field] = true + } + + if scrapedTag != nil { + t.processMatchedTag(ctx, scrapedTag, excluded) + } else { + logger.Infof("No match found for %s", t.getName()) + } +} + +func (t *stashBoxBatchTagTagTask) GetDescription() string { + return fmt.Sprintf("Tagging tag %s from stash-box", t.getName()) +} + +func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models.ScrapedTag, error) { + var results []*models.ScrapedTag + var err error + + r := instance.Repository + + client := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns())) + + switch { + case t.name != nil: + results, err = client.QueryTag(ctx, *t.name) + case t.stashID != nil: + results, err = client.QueryTag(ctx, *t.stashID) + case t.tag != nil: + var remoteID string + if err := r.WithReadTxn(ctx, func(ctx context.Context) error { + if !t.tag.StashIDs.Loaded() { + err = t.tag.LoadStashIDs(ctx, r.Tag) + if err != nil { + return err + } + } + for _, id := range t.tag.StashIDs.List() { + if id.Endpoint == t.box.Endpoint { + remoteID = id.StashID + } + } + return nil + }); err != nil { + return nil, err + } + + if remoteID != "" { + results, err = client.QueryTag(ctx, remoteID) + } else { + results, err = client.QueryTag(ctx, t.tag.Name) + } + } + + if err != nil { + return nil, err + } + + if len(results) == 0 { + return nil, nil + } + + result := results[0] + + if err := r.WithReadTxn(ctx, func(ctx context.Context) error { + return match.ScrapedTag(ctx, r.Tag, result, t.box.Endpoint) + }); err != nil { + return nil, err + } + + return result, nil +} + +func (t *stashBoxBatchTagTagTask) processMatchedTag(ctx context.Context, s *models.ScrapedTag, excluded map[string]bool) { + // Determine the tag ID to update — either from the task's tag or from the + // StoredID set by match.ScrapedTag (when batch adding by name and the tag + // already exists locally). + tagID := 0 + if t.tag != nil { + tagID = t.tag.ID + } else if s.StoredID != nil { + tagID, _ = strconv.Atoi(*s.StoredID) + } + + if tagID > 0 { + r := instance.Repository + err := r.WithTxn(ctx, func(ctx context.Context) error { + qb := r.Tag + + existingStashIDs, err := qb.GetStashIDs(ctx, tagID) + if err != nil { + return err + } + + storedID := strconv.Itoa(tagID) + partial := s.ToPartial(storedID, t.box.Endpoint, excluded, existingStashIDs) + + if err := tag.ValidateUpdate(ctx, tagID, partial, qb); err != nil { + return err + } + + if _, err := qb.UpdatePartial(ctx, tagID, partial); err != nil { + return err + } + + return nil + }) + if err != nil { + logger.Errorf("Failed to update tag %s: %v", s.Name, err) + } else { + logger.Infof("Updated tag %s", s.Name) + } + } else if s.Name != "" { + // no existing tag, create a new one + newTag := s.ToTag(t.box.Endpoint, excluded) + + r := instance.Repository + err := r.WithTxn(ctx, func(ctx context.Context) error { + qb := r.Tag + + if err := tag.ValidateCreate(ctx, *newTag, qb); err != nil { + return err + } + + if err := qb.Create(ctx, &models.CreateTagInput{Tag: newTag}); err != nil { + return err + } + + return nil + }) + if err != nil { + logger.Errorf("Failed to create tag %s: %v", s.Name, err) + } else { + logger.Infof("Created tag %s", s.Name) + } + } +} diff --git a/pkg/models/mocks/TagReaderWriter.go b/pkg/models/mocks/TagReaderWriter.go index 95a3b7a87..c4423ee52 100644 --- a/pkg/models/mocks/TagReaderWriter.go +++ b/pkg/models/mocks/TagReaderWriter.go @@ -450,6 +450,29 @@ func (_m *TagReaderWriter) FindByStashID(ctx context.Context, stashID models.Sta return r0, r1 } +// FindByStashIDStatus provides a mock function with given fields: ctx, hasStashID, stashboxEndpoint +func (_m *TagReaderWriter) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Tag, error) { + ret := _m.Called(ctx, hasStashID, stashboxEndpoint) + + var r0 []*models.Tag + if rf, ok := ret.Get(0).(func(context.Context, bool, string) []*models.Tag); ok { + r0 = rf(ctx, hasStashID, stashboxEndpoint) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Tag) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, bool, string) error); ok { + r1 = rf(ctx, hasStashID, stashboxEndpoint) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // FindByStudioID provides a mock function with given fields: ctx, studioID func (_m *TagReaderWriter) FindByStudioID(ctx context.Context, studioID int) ([]*models.Tag, error) { ret := _m.Called(ctx, studioID) diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 3c0e083c1..1367003cb 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -471,9 +471,11 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, type ScrapedTag struct { // Set if tag matched - StoredID *string `json:"stored_id"` - Name string `json:"name"` - RemoteSiteID *string `json:"remote_site_id"` + StoredID *string `json:"stored_id"` + Name string `json:"name"` + Description *string `json:"description"` + AliasList []string `json:"alias_list"` + RemoteSiteID *string `json:"remote_site_id"` } func (ScrapedTag) IsScrapedContent() {} @@ -482,6 +484,17 @@ func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag { currentTime := time.Now() ret := NewTag() ret.Name = t.Name + ret.ParentIDs = NewRelatedIDs([]int{}) + ret.ChildIDs = NewRelatedIDs([]int{}) + ret.Aliases = NewRelatedStrings([]string{}) + + if t.Description != nil && !excluded["description"] { + ret.Description = *t.Description + } + + if len(t.AliasList) > 0 && !excluded["aliases"] { + ret.Aliases = NewRelatedStrings(t.AliasList) + } if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" { ret.StashIDs = NewRelatedStashIDs([]StashID{ @@ -496,6 +509,39 @@ func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag { return &ret } +func (t *ScrapedTag) ToPartial(storedID string, endpoint string, excluded map[string]bool, existingStashIDs []StashID) TagPartial { + ret := NewTagPartial() + + if t.Name != "" && !excluded["name"] { + ret.Name = NewOptionalString(t.Name) + } + + if t.Description != nil && !excluded["description"] { + ret.Description = NewOptionalString(*t.Description) + } + + if len(t.AliasList) > 0 && !excluded["aliases"] { + ret.Aliases = &UpdateStrings{ + Values: t.AliasList, + Mode: RelationshipUpdateModeSet, + } + } + + if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" { + ret.StashIDs = &UpdateStashIDs{ + StashIDs: existingStashIDs, + Mode: RelationshipUpdateModeSet, + } + ret.StashIDs.Set(StashID{ + Endpoint: endpoint, + StashID: *t.RemoteSiteID, + UpdatedAt: time.Now(), + }) + } + + return ret +} + func ScrapedTagSortFunction(a, b *ScrapedTag) int { return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) } diff --git a/pkg/models/repository_tag.go b/pkg/models/repository_tag.go index ba403cf2d..02dfe0cb6 100644 --- a/pkg/models/repository_tag.go +++ b/pkg/models/repository_tag.go @@ -26,6 +26,7 @@ type TagFinder interface { FindByName(ctx context.Context, name string, nocase bool) (*Tag, error) FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error) FindByStashID(ctx context.Context, stashID StashID) ([]*Tag, error) + FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*Tag, error) } // TagQueryer provides methods to query tags. diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index a926dd56e..750836516 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -597,6 +597,36 @@ func (qb *TagStore) FindByStashID(ctx context.Context, stashID models.StashID) ( return ret, nil } +func (qb *TagStore) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Tag, error) { + table := qb.table() + sq := dialect.From(table).LeftJoin( + tagsStashIDsJoinTable, + goqu.On(table.Col(idColumn).Eq(tagsStashIDsJoinTable.Col(tagIDColumn))), + ).Select(table.Col(idColumn)) + + if hasStashID { + sq = sq.Where( + tagsStashIDsJoinTable.Col("stash_id").IsNotNull(), + tagsStashIDsJoinTable.Col("endpoint").Eq(stashboxEndpoint), + ) + } else { + sq = sq.Where( + tagsStashIDsJoinTable.Col("stash_id").IsNull(), + ) + } + + idsQuery := qb.selectDataset().Where( + table.Col(idColumn).In(sq), + ) + + ret, err := qb.getMany(ctx, idsQuery) + if err != nil { + return nil, fmt.Errorf("getting tags for stash-box endpoint %s: %w", stashboxEndpoint, err) + } + + return ret, nil +} + func (qb *TagStore) GetParentIDs(ctx context.Context, relatedID int) ([]int, error) { return tagsParentTagsTableMgr.get(ctx, relatedID) } diff --git a/pkg/stashbox/graphql/generated_client.go b/pkg/stashbox/graphql/generated_client.go index 29b702a7f..acb2202dc 100644 --- a/pkg/stashbox/graphql/generated_client.go +++ b/pkg/stashbox/graphql/generated_client.go @@ -128,8 +128,10 @@ func (t *StudioFragment) GetImages() []*ImageFragment { } type TagFragment struct { - Name string "json:\"name\" graphql:\"name\"" - ID string "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" + ID string "json:\"id\" graphql:\"id\"" + Description *string "json:\"description,omitempty\" graphql:\"description\"" + Aliases []string "json:\"aliases\" graphql:\"aliases\"" } func (t *TagFragment) GetName() string { @@ -144,6 +146,18 @@ func (t *TagFragment) GetID() string { } return t.ID } +func (t *TagFragment) GetDescription() *string { + if t == nil { + t = &TagFragment{} + } + return t.Description +} +func (t *TagFragment) GetAliases() []string { + if t == nil { + t = &TagFragment{} + } + return t.Aliases +} type MeasurementsFragment struct { BandSize *int "json:\"band_size,omitempty\" graphql:\"band_size\"" @@ -849,6 +863,8 @@ fragment StudioFragment on Studio { fragment TagFragment on Tag { name id + description + aliases } fragment PerformerAppearanceFragment on PerformerAppearance { as @@ -985,6 +1001,8 @@ fragment StudioFragment on Studio { fragment TagFragment on Tag { name id + description + aliases } fragment PerformerAppearanceFragment on PerformerAppearance { as @@ -1279,6 +1297,8 @@ fragment StudioFragment on Studio { fragment TagFragment on Tag { name id + description + aliases } fragment PerformerAppearanceFragment on PerformerAppearance { as @@ -1413,6 +1433,8 @@ const FindTagDocument = `query FindTag ($id: ID, $name: String) { fragment TagFragment on Tag { name id + description + aliases } ` @@ -1445,6 +1467,8 @@ const QueryTagsDocument = `query QueryTags ($input: TagQueryInput!) { fragment TagFragment on Tag { name id + description + aliases } ` diff --git a/pkg/stashbox/tag.go b/pkg/stashbox/tag.go index df2ecbcc0..452dd9928 100644 --- a/pkg/stashbox/tag.go +++ b/pkg/stashbox/tag.go @@ -31,10 +31,8 @@ func (c Client) findTagByID(ctx context.Context, id string) ([]*models.ScrapedTa return nil, nil } - return []*models.ScrapedTag{{ - Name: tag.FindTag.Name, - RemoteSiteID: &tag.FindTag.ID, - }}, nil + ret := tagFragmentToScrapedTag(*tag.FindTag) + return []*models.ScrapedTag{ret}, nil } func (c Client) queryTagsByName(ctx context.Context, name string) ([]*models.ScrapedTag, error) { @@ -57,11 +55,22 @@ func (c Client) queryTagsByName(ctx context.Context, name string) ([]*models.Scr var ret []*models.ScrapedTag for _, t := range result.QueryTags.Tags { - ret = append(ret, &models.ScrapedTag{ - Name: t.Name, - RemoteSiteID: &t.ID, - }) + ret = append(ret, tagFragmentToScrapedTag(*t)) } return ret, nil } + +func tagFragmentToScrapedTag(t graphql.TagFragment) *models.ScrapedTag { + ret := &models.ScrapedTag{ + Name: t.Name, + Description: t.Description, + RemoteSiteID: &t.ID, + } + + if len(t.Aliases) > 0 { + ret.AliasList = t.Aliases + } + + return ret +} diff --git a/ui/v2.5/graphql/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql index e58c21a20..7214c2064 100644 --- a/ui/v2.5/graphql/data/scrapers.graphql +++ b/ui/v2.5/graphql/data/scrapers.graphql @@ -160,6 +160,8 @@ fragment ScrapedSceneStudioData on ScrapedStudio { fragment ScrapedSceneTagData on ScrapedTag { stored_id name + description + alias_list remote_site_id } diff --git a/ui/v2.5/graphql/mutations/stash-box.graphql b/ui/v2.5/graphql/mutations/stash-box.graphql index 596dc4302..de5f5136c 100644 --- a/ui/v2.5/graphql/mutations/stash-box.graphql +++ b/ui/v2.5/graphql/mutations/stash-box.graphql @@ -12,6 +12,10 @@ mutation StashBoxBatchStudioTag($input: StashBoxBatchTagInput!) { stashBoxBatchStudioTag(input: $input) } +mutation StashBoxBatchTagTag($input: StashBoxBatchTagInput!) { + stashBoxBatchTagTag(input: $input) +} + mutation SubmitStashBoxSceneDraft($input: StashBoxDraftSubmissionInput!) { submitStashBoxSceneDraft(input: $input) } diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx index f383f245a..6bd535df7 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx @@ -395,7 +395,13 @@ export const ScrapedTagsRow: React.FC< onSelect={(items) => { if (onChangeFn) { // map the id back to stored_id - onChangeFn(items.map((p) => ({ ...p, stored_id: p.id }))); + onChangeFn( + items.map((p) => ({ + ...p, + stored_id: p.id, + alias_list: p.aliases, + })) + ); } }} ids={selectValue} diff --git a/ui/v2.5/src/components/Tagger/PerformerFieldSelector.tsx b/ui/v2.5/src/components/Tagger/FieldSelector.tsx similarity index 84% rename from ui/v2.5/src/components/Tagger/PerformerFieldSelector.tsx rename to ui/v2.5/src/components/Tagger/FieldSelector.tsx index b50716511..7a47862b5 100644 --- a/ui/v2.5/src/components/Tagger/PerformerFieldSelector.tsx +++ b/ui/v2.5/src/components/Tagger/FieldSelector.tsx @@ -5,22 +5,25 @@ import { useIntl } from "react-intl"; import { ModalComponent } from "../Shared/Modal"; import { Icon } from "../Shared/Icon"; -import { PERFORMER_FIELDS } from "./constants"; interface IProps { show: boolean; + fields: string[]; excludedFields: string[]; onSelect: (fields: string[]) => void; } -const PerformerFieldSelect: React.FC = ({ +const FieldSelector: React.FC = ({ show, + fields, excludedFields, onSelect, }) => { const intl = useIntl(); const [excluded, setExcluded] = useState>( - excludedFields.reduce((dict, field) => ({ ...dict, [field]: true }), {}) + excludedFields + .filter((field) => fields.includes(field)) + .reduce((dict, field) => ({ ...dict, [field]: true }), {}) ); const toggleField = (field: string) => @@ -57,9 +60,9 @@ const PerformerFieldSelect: React.FC = ({
These fields will be tagged by default. Click the button to toggle.
- {PERFORMER_FIELDS.map((f) => renderField(f))} + {fields.map((f) => renderField(f))} ); }; -export default PerformerFieldSelect; +export default FieldSelector; diff --git a/ui/v2.5/src/components/Tagger/performers/Config.tsx b/ui/v2.5/src/components/Tagger/TaggerConfig.tsx similarity index 69% rename from ui/v2.5/src/components/Tagger/performers/Config.tsx rename to ui/v2.5/src/components/Tagger/TaggerConfig.tsx index 0d5316735..c578d58c4 100644 --- a/ui/v2.5/src/components/Tagger/performers/Config.tsx +++ b/ui/v2.5/src/components/Tagger/TaggerConfig.tsx @@ -3,21 +3,33 @@ import { Badge, Button, Card, Collapse, Form } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import { useConfigurationContext } from "src/hooks/Config"; -import { ITaggerConfig } from "../constants"; -import PerformerFieldSelector from "../PerformerFieldSelector"; +import { ITaggerConfig } from "./constants"; +import FieldSelector from "./FieldSelector"; -interface IConfigProps { +interface ITaggerConfigProps { show: boolean; config: ITaggerConfig; setConfig: Dispatch; + excludedFields: string[]; + onFieldsChange: (fields: string[]) => void; + fields: string[]; + entityName: string; + extraConfig?: React.ReactNode; } -const Config: React.FC = ({ show, config, setConfig }) => { +const TaggerConfig: React.FC = ({ + show, + config, + setConfig, + excludedFields, + onFieldsChange, + fields, + entityName, + extraConfig, +}) => { const { configuration: stashConfig } = useConfigurationContext(); const [showExclusionModal, setShowExclusionModal] = useState(false); - const excludedFields = config.excludedPerformerFields ?? []; - const handleInstanceSelect = (e: React.ChangeEvent) => { const selectedEndpoint = e.currentTarget.value; setConfig({ @@ -28,8 +40,8 @@ const Config: React.FC = ({ show, config, setConfig }) => { const stashBoxes = stashConfig?.general.stashBoxes ?? []; - const handleFieldSelect = (fields: string[]) => { - setConfig({ ...config, excludedPerformerFields: fields }); + const handleFieldSelect = (selectedFields: string[]) => { + onFieldsChange(selectedFields); setShowExclusionModal(false); }; @@ -43,9 +55,10 @@ const Config: React.FC = ({ show, config, setConfig }) => {
- + {extraConfig} +
- +
{excludedFields.length > 0 ? ( @@ -55,17 +68,20 @@ const Config: React.FC = ({ show, config, setConfig }) => { )) ) : ( - + )} - +
= ({ show, config, setConfig }) => { className="align-items-center row no-gutters mt-4" > - + = ({ show, config, setConfig }) => { > {!stashBoxes.length && ( )} {stashConfig?.general.stashBoxes.map((i) => ( @@ -98,8 +114,9 @@ const Config: React.FC = ({ show, config, setConfig }) => {
- @@ -107,4 +124,4 @@ const Config: React.FC = ({ show, config, setConfig }) => { ); }; -export default Config; +export default TaggerConfig; diff --git a/ui/v2.5/src/components/Tagger/constants.ts b/ui/v2.5/src/components/Tagger/constants.ts index d59a6d3d5..af9afcefb 100644 --- a/ui/v2.5/src/components/Tagger/constants.ts +++ b/ui/v2.5/src/components/Tagger/constants.ts @@ -24,6 +24,7 @@ export const DEFAULT_BLACKLIST = [ ]; export const DEFAULT_EXCLUDED_PERFORMER_FIELDS = ["name"]; export const DEFAULT_EXCLUDED_STUDIO_FIELDS = ["name"]; +export const DEFAULT_EXCLUDED_TAG_FIELDS = ["name"]; export const initialConfig: ITaggerConfig = { blacklist: DEFAULT_BLACKLIST, @@ -35,6 +36,7 @@ export const initialConfig: ITaggerConfig = { excludedPerformerFields: DEFAULT_EXCLUDED_PERFORMER_FIELDS, markSceneAsOrganizedOnSave: false, excludedStudioFields: DEFAULT_EXCLUDED_STUDIO_FIELDS, + excludedTagFields: DEFAULT_EXCLUDED_TAG_FIELDS, createParentStudios: true, }; @@ -52,6 +54,7 @@ export interface ITaggerConfig { excludedPerformerFields?: string[]; markSceneAsOrganizedOnSave?: boolean; excludedStudioFields?: string[]; + excludedTagFields?: string[]; createParentStudios: boolean; } @@ -82,3 +85,4 @@ export const PERFORMER_FIELDS = [ ]; export const STUDIO_FIELDS = ["name", "image", "url", "parent_studio"]; +export const TAG_FIELDS = ["name", "description", "aliases"]; diff --git a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx index bb934a241..8106d6a44 100755 --- a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx +++ b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx @@ -19,8 +19,8 @@ import { Manual } from "src/components/Help/Manual"; import { useConfigurationContext } from "src/hooks/Config"; import StashSearchResult from "./StashSearchResult"; -import PerformerConfig from "./Config"; -import { ITaggerConfig } from "../constants"; +import TaggerConfig from "../TaggerConfig"; +import { ITaggerConfig, PERFORMER_FIELDS } from "../constants"; import PerformerModal from "../PerformerModal"; import { useUpdatePerformer } from "../queries"; import { faStar, faTags } from "@fortawesome/free-solid-svg-icons"; @@ -771,10 +771,16 @@ export const PerformerTagger: React.FC = ({ performers }) => {
- + setConfig({ ...config, excludedPerformerFields: fields }) + } + fields={PERFORMER_FIELDS} + entityName="performers" /> { return updateStudioHandler; }; + +export const useUpdateTag = () => { + const [updateTag] = GQL.useTagUpdateMutation({ + onError: (errors) => errors, + errorPolicy: "all", + }); + + const updateTagHandler = (input: GQL.TagUpdateInput) => + updateTag({ + variables: { + input, + }, + update: (store, updatedTag) => { + if (!updatedTag.data?.tagUpdate) return; + + updatedTag.data.tagUpdate.stash_ids.forEach((id) => { + store.writeQuery({ + query: GQL.FindTagsDocument, + variables: { + tag_filter: { + stash_id_endpoint: { + stash_id: id.stash_id, + endpoint: id.endpoint, + modifier: GQL.CriterionModifier.Equals, + }, + }, + }, + data: { + findTags: { + count: 1, + tags: [updatedTag.data!.tagUpdate!], + __typename: "FindTagsResultType", + }, + }, + }); + }); + }, + }); + + return updateTagHandler; +}; diff --git a/ui/v2.5/src/components/Tagger/studios/Config.tsx b/ui/v2.5/src/components/Tagger/studios/Config.tsx deleted file mode 100644 index ddfd17b1e..000000000 --- a/ui/v2.5/src/components/Tagger/studios/Config.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import React, { Dispatch, useState } from "react"; -import { Badge, Button, Card, Collapse, Form } from "react-bootstrap"; -import { FormattedMessage } from "react-intl"; -import { useConfigurationContext } from "src/hooks/Config"; - -import { ITaggerConfig } from "../constants"; -import StudioFieldSelector from "./StudioFieldSelector"; - -interface IConfigProps { - show: boolean; - config: ITaggerConfig; - setConfig: Dispatch; -} - -const Config: React.FC = ({ show, config, setConfig }) => { - const { configuration: stashConfig } = useConfigurationContext(); - const [showExclusionModal, setShowExclusionModal] = useState(false); - - const excludedFields = config.excludedStudioFields ?? []; - - const handleInstanceSelect = (e: React.ChangeEvent) => { - const selectedEndpoint = e.currentTarget.value; - setConfig({ - ...config, - selectedEndpoint, - }); - }; - - const stashBoxes = stashConfig?.general.stashBoxes ?? []; - - const handleFieldSelect = (fields: string[]) => { - setConfig({ ...config, excludedStudioFields: fields }); - setShowExclusionModal(false); - }; - - return ( - <> - - -
-

- -

-
-
- - - } - checked={config.createParentStudios} - onChange={(e: React.ChangeEvent) => - setConfig({ - ...config, - createParentStudios: e.currentTarget.checked, - }) - } - /> - - - - - -
- -
- - {excludedFields.length > 0 ? ( - excludedFields.map((f) => ( - - - - )) - ) : ( - - )} - - - - - -
- - - - - - {!stashBoxes.length && ( - - )} - {stashConfig?.general.stashBoxes.map((i) => ( - - ))} - - -
-
-
-
- - - ); -}; - -export default Config; diff --git a/ui/v2.5/src/components/Tagger/studios/StudioFieldSelector.tsx b/ui/v2.5/src/components/Tagger/studios/StudioFieldSelector.tsx deleted file mode 100644 index 658f23510..000000000 --- a/ui/v2.5/src/components/Tagger/studios/StudioFieldSelector.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { faCheck, faList, faTimes } from "@fortawesome/free-solid-svg-icons"; -import React, { useState } from "react"; -import { Button, Row, Col } from "react-bootstrap"; -import { useIntl } from "react-intl"; - -import { ModalComponent } from "../../Shared/Modal"; -import { Icon } from "../../Shared/Icon"; -import { STUDIO_FIELDS } from "../constants"; - -interface IProps { - show: boolean; - excludedFields: string[]; - onSelect: (fields: string[]) => void; -} - -const StudioFieldSelect: React.FC = ({ - show, - excludedFields, - onSelect, -}) => { - const intl = useIntl(); - const [excluded, setExcluded] = useState>( - // filter out fields that aren't in STUDIO_FIELDS - excludedFields - .filter((field) => STUDIO_FIELDS.includes(field)) - .reduce((dict, field) => ({ ...dict, [field]: true }), {}) - ); - - const toggleField = (field: string) => - setExcluded({ - ...excluded, - [field]: !excluded[field], - }); - - const renderField = (field: string) => ( - - - {intl.formatMessage({ id: field })} - - ); - - return ( - - onSelect(Object.keys(excluded).filter((f) => excluded[f])), - }} - > -

Select tagged fields

-
- These fields will be tagged by default. Click the button to toggle. -
- {STUDIO_FIELDS.map((f) => renderField(f))} -
- ); -}; - -export default StudioFieldSelect; diff --git a/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx b/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx index ed9570431..64bb99b72 100644 --- a/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx +++ b/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx @@ -20,8 +20,8 @@ import { Manual } from "src/components/Help/Manual"; import { useConfigurationContext } from "src/hooks/Config"; import StashSearchResult from "./StashSearchResult"; -import StudioConfig from "./Config"; -import { ITaggerConfig } from "../constants"; +import TaggerConfig from "../TaggerConfig"; +import { ITaggerConfig, STUDIO_FIELDS } from "../constants"; import StudioModal from "../scenes/StudioModal"; import { useUpdateStudio } from "../queries"; import { apolloError } from "src/utils"; @@ -825,10 +825,38 @@ export const StudioTagger: React.FC = ({ studios }) => {
- + setConfig({ ...config, excludedStudioFields: fields }) + } + fields={STUDIO_FIELDS} + entityName="studios" + extraConfig={ + + + } + checked={config.createParentStudios} + onChange={(e: React.ChangeEvent) => + setConfig({ + ...config, + createParentStudios: e.currentTarget.checked, + }) + } + /> + + + + + } /> & + Partial> + ) => void; + excludedTagFields: string[]; +} + +const StashSearchResult: React.FC = ({ + tag, + stashboxTags, + onTagTagged, + excludedTagFields, + endpoint, +}) => { + const intl = useIntl(); + + const [modalTag, setModalTag] = useState(); + const [saveState, setSaveState] = useState(""); + const [error, setError] = useState<{ message?: string; details?: string }>( + {} + ); + + const updateTag = useUpdateTag(); + + const handleSave = async (input: GQL.TagCreateInput) => { + setError({}); + setModalTag(undefined); + setSaveState("Saving tag"); + + const updateData: GQL.TagUpdateInput = { + ...input, + id: tag.id, + }; + + updateData.stash_ids = await mergeTagStashIDs( + tag.id, + input.stash_ids ?? [] + ); + + const res = await updateTag(updateData); + + if (!res?.data?.tagUpdate) { + setError({ + message: intl.formatMessage( + { id: "tag_tagger.failed_to_save_tag" }, + { tag: input.name ?? tag.name } + ), + details: + res?.errors?.[0]?.message === "UNIQUE constraint failed: tags.name" + ? intl.formatMessage({ + id: "tag_tagger.name_already_exists", + }) + : res?.errors?.[0]?.message ?? "", + }); + } else { + onTagTagged(tag); + } + setSaveState(""); + }; + + const tags = stashboxTags.map((p) => ( + + )); + + return ( + <> + {modalTag && ( + setModalTag(undefined)} + modalVisible={modalTag !== undefined} + tag={modalTag} + onSave={handleSave} + icon={faTags} + header="Update Tag" + excludedTagFields={excludedTagFields} + endpoint={endpoint} + /> + )} +
{tags}
+
+ {error.message && ( +
+ + Error: + {error.message} + +
{error.details}
+
+ )} + {saveState && ( + {saveState} + )} +
+ + ); +}; + +export default StashSearchResult; diff --git a/ui/v2.5/src/components/Tagger/tags/TagModal.tsx b/ui/v2.5/src/components/Tagger/tags/TagModal.tsx new file mode 100644 index 000000000..1183d8f0c --- /dev/null +++ b/ui/v2.5/src/components/Tagger/tags/TagModal.tsx @@ -0,0 +1,144 @@ +import React, { useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; +import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; + +import * as GQL from "src/core/generated-graphql"; +import { Icon } from "src/components/Shared/Icon"; +import { ModalComponent } from "src/components/Shared/Modal"; +import { + faCheck, + faExternalLinkAlt, + faTimes, +} from "@fortawesome/free-solid-svg-icons"; +import { Button } from "react-bootstrap"; +import { TruncatedText } from "src/components/Shared/TruncatedText"; +import { excludeFields } from "src/utils/data"; +import { ExternalLink } from "src/components/Shared/ExternalLink"; + +interface ITagModalProps { + tag: GQL.ScrapedSceneTagDataFragment; + modalVisible: boolean; + closeModal: () => void; + onSave: (input: GQL.TagCreateInput) => void; + excludedTagFields?: string[]; + header: string; + icon: IconDefinition; + endpoint?: string; +} + +const TagModal: React.FC = ({ + modalVisible, + tag, + onSave, + closeModal, + excludedTagFields = [], + header, + icon, + endpoint, +}) => { + const intl = useIntl(); + + const [excluded, setExcluded] = useState>( + excludedTagFields.reduce((dict, field) => ({ ...dict, [field]: true }), {}) + ); + const toggleField = (name: string) => + setExcluded({ + ...excluded, + [name]: !excluded[name], + }); + + function maybeRenderField(id: string, text: string | null | undefined) { + if (!text) return; + + return ( +
+
+ + + : + +
+ +
+ ); + } + + function maybeRenderStashBoxLink() { + const base = endpoint?.match(/https?:\/\/.*?\//)?.[0]; + const link = base ? `${base}tags/${tag.remote_site_id}` : undefined; + + if (!link) return; + + return ( +
+ + + + +
+ ); + } + + function handleSave() { + if (!tag.name) { + throw new Error("tag name must be set"); + } + + const tagData: GQL.TagCreateInput = { + name: tag.name, + description: tag.description ?? undefined, + aliases: tag.alias_list?.filter((a) => a) ?? undefined, + }; + + // stashid handling code + const remoteSiteID = tag.remote_site_id; + if (remoteSiteID && endpoint) { + tagData.stash_ids = [ + { + endpoint, + stash_id: remoteSiteID, + updated_at: new Date().toISOString(), + }, + ]; + } + + // handle exclusions + excludeFields(tagData, excluded); + + onSave(tagData); + } + + return ( + closeModal(), variant: "secondary" }} + onHide={() => closeModal()} + dialogClassName="studio-create-modal" + icon={icon} + header={header} + > +
+
+
+ {maybeRenderField("name", tag.name)} + {maybeRenderField("description", tag.description)} + {maybeRenderField("aliases", tag.alias_list?.join(", "))} + {maybeRenderStashBoxLink()} +
+
+
+
+ ); +}; + +export default TagModal; diff --git a/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx b/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx new file mode 100644 index 000000000..1113bdfd4 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx @@ -0,0 +1,758 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; +import { Link } from "react-router-dom"; +import { HashLink } from "react-router-hash-link"; + +import * as GQL from "src/core/generated-graphql"; +import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; +import { ModalComponent } from "src/components/Shared/Modal"; +import { + stashBoxTagQuery, + useJobsSubscribe, + mutateStashBoxBatchTagTag, + getClient, +} from "src/core/StashService"; +import { Manual } from "src/components/Help/Manual"; +import { useConfigurationContext } from "src/hooks/Config"; + +import StashSearchResult from "./StashSearchResult"; +import TaggerConfig from "../TaggerConfig"; +import { ITaggerConfig, TAG_FIELDS } from "../constants"; +import { useUpdateTag } from "../queries"; +import { faStar, faTags } from "@fortawesome/free-solid-svg-icons"; +import { ExternalLink } from "src/components/Shared/ExternalLink"; +import { mergeTagStashIDs } from "../utils"; +import { separateNamesAndStashIds } from "src/utils/stashIds"; +import { useTaggerConfig } from "../config"; + +type JobFragment = Pick< + GQL.Job, + "id" | "status" | "subTasks" | "description" | "progress" +>; + +const CLASSNAME = "StudioTagger"; + +interface ITagBatchUpdateModal { + tags: GQL.TagListDataFragment[]; + isIdle: boolean; + selectedEndpoint: { endpoint: string; index: number }; + onBatchUpdate: (queryAll: boolean, refresh: boolean) => void; + close: () => void; +} + +const TagBatchUpdateModal: React.FC = ({ + tags, + isIdle, + selectedEndpoint, + onBatchUpdate, + close, +}) => { + const intl = useIntl(); + + const [queryAll, setQueryAll] = useState(false); + + const [refresh, setRefresh] = useState(false); + const { data: allTags } = GQL.useFindTagsQuery({ + variables: { + tag_filter: { + stash_id_endpoint: { + endpoint: selectedEndpoint.endpoint, + modifier: refresh + ? GQL.CriterionModifier.NotNull + : GQL.CriterionModifier.IsNull, + }, + }, + filter: { + per_page: 0, + }, + }, + }); + + const tagCount = useMemo(() => { + const filteredStashIDs = tags.map((t) => + t.stash_ids.filter((s) => s.endpoint === selectedEndpoint.endpoint) + ); + + return queryAll + ? allTags?.findTags.count + : filteredStashIDs.filter((s) => + refresh ? s.length > 0 : s.length === 0 + ).length; + }, [queryAll, refresh, tags, allTags, selectedEndpoint.endpoint]); + + return ( + onBatchUpdate(queryAll, refresh), + }} + cancel={{ + text: intl.formatMessage({ id: "actions.cancel" }), + variant: "danger", + onClick: () => close(), + }} + disabled={!isIdle} + > + + +
+ +
+
+ } + checked={!queryAll} + onChange={() => setQueryAll(false)} + /> + setQueryAll(true)} + /> +
+ + +
+ +
+
+ setRefresh(false)} + /> + + + + setRefresh(true)} + /> + + + +
+ + + +
+ ); +}; + +interface ITagBatchAddModal { + isIdle: boolean; + onBatchAdd: (input: string) => void; + close: () => void; +} + +const TagBatchAddModal: React.FC = ({ + isIdle, + onBatchAdd, + close, +}) => { + const intl = useIntl(); + + const tagInput = useRef(null); + + return ( + { + if (tagInput.current) { + onBatchAdd(tagInput.current.value); + } else { + close(); + } + }, + }} + cancel={{ + text: intl.formatMessage({ id: "actions.cancel" }), + variant: "danger", + onClick: () => close(), + }} + disabled={!isIdle} + > + + + + + + ); +}; + +interface ITagTaggerListProps { + tags: GQL.TagListDataFragment[]; + selectedEndpoint: { endpoint: string; index: number }; + isIdle: boolean; + config: ITaggerConfig; + onBatchAdd: (tagInput: string) => void; + onBatchUpdate: (ids: string[] | undefined, refresh: boolean) => void; +} + +const TagTaggerList: React.FC = ({ + tags, + selectedEndpoint, + isIdle, + config, + onBatchAdd, + onBatchUpdate, +}) => { + const intl = useIntl(); + + const [loading, setLoading] = useState(false); + const [searchResults, setSearchResults] = useState< + Record + >({}); + const [searchErrors, setSearchErrors] = useState< + Record + >({}); + const [taggedTags, setTaggedTags] = useState< + Record> + >({}); + const [queries, setQueries] = useState>({}); + + const [showBatchAdd, setShowBatchAdd] = useState(false); + const [showBatchUpdate, setShowBatchUpdate] = useState(false); + + const [error, setError] = useState< + Record + >({}); + const [loadingUpdate, setLoadingUpdate] = useState(); + + const doBoxSearch = (tagID: string, searchVal: string) => { + stashBoxTagQuery(searchVal, selectedEndpoint.endpoint) + .then((queryData) => { + const s = queryData.data?.scrapeSingleTag ?? []; + setSearchResults({ + ...searchResults, + [tagID]: s, + }); + setSearchErrors({ + ...searchErrors, + [tagID]: undefined, + }); + setLoading(false); + }) + .catch(() => { + setLoading(false); + const { [tagID]: unassign, ...results } = searchResults; + setSearchResults(results); + setSearchErrors({ + ...searchErrors, + [tagID]: intl.formatMessage({ + id: "tag_tagger.network_error", + }), + }); + }); + + setLoading(true); + }; + + const updateTag = useUpdateTag(); + + const doBoxUpdate = (tagID: string, stashID: string, endpoint: string) => { + setLoadingUpdate(stashID); + setError({ + ...error, + [tagID]: undefined, + }); + stashBoxTagQuery(stashID, endpoint) + .then(async (queryData) => { + const data = queryData.data?.scrapeSingleTag ?? []; + if (data.length > 0) { + const stashboxTag = data[0]; + const updateData: GQL.TagUpdateInput = { + id: tagID, + }; + + if ( + !(config.excludedTagFields ?? []).includes("name") && + stashboxTag.name + ) { + updateData.name = stashboxTag.name; + } + + if ( + stashboxTag.description && + !(config.excludedTagFields ?? []).includes("description") + ) { + updateData.description = stashboxTag.description; + } + + if ( + stashboxTag.alias_list && + stashboxTag.alias_list.length > 0 && + !(config.excludedTagFields ?? []).includes("aliases") + ) { + updateData.aliases = stashboxTag.alias_list; + } + + if (stashboxTag.remote_site_id) { + updateData.stash_ids = await mergeTagStashIDs(tagID, [ + { + endpoint, + stash_id: stashboxTag.remote_site_id, + }, + ]); + } + + const res = await updateTag(updateData); + if (!res?.data?.tagUpdate) { + setError({ + ...error, + [tagID]: { + message: `Failed to update tag`, + details: res?.errors?.[0]?.message ?? "", + }, + }); + } + } + }) + .finally(() => setLoadingUpdate(undefined)); + }; + + async function handleBatchAdd(input: string) { + onBatchAdd(input); + setShowBatchAdd(false); + } + + const handleBatchUpdate = (queryAll: boolean, refresh: boolean) => { + onBatchUpdate(!queryAll ? tags.map((t) => t.id) : undefined, refresh); + setShowBatchUpdate(false); + }; + + const handleTaggedTag = ( + tag: Pick & + Partial> + ) => { + setTaggedTags({ + ...taggedTags, + [tag.id]: tag, + }); + }; + + const renderTags = () => + tags.map((tag) => { + const isTagged = taggedTags[tag.id]; + + const stashID = tag.stash_ids.find((s) => { + return s.endpoint === selectedEndpoint.endpoint; + }); + + let mainContent; + if (!isTagged && stashID !== undefined) { + mainContent = ( +
+
+ +
+
+ ); + } else if (!isTagged && !stashID) { + mainContent = ( + + + setQueries({ + ...queries, + [tag.id]: e.currentTarget.value, + }) + } + onKeyPress={(e: React.KeyboardEvent) => + e.key === "Enter" && + doBoxSearch(tag.id, queries[tag.id] ?? tag.name ?? "") + } + /> + + + + + ); + } else if (isTagged) { + mainContent = ( +
+
+ +
+
+ ); + } + + let subContent; + if (stashID !== undefined) { + const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0]; + const link = base ? ( + + {stashID.stash_id} + + ) : ( +
{stashID.stash_id}
+ ); + + subContent = ( +
+ + {link} + + + + + {error[tag.id] && ( +
+ + Error: + {error[tag.id]?.message} + +
{error[tag.id]?.details}
+
+ )} +
+ ); + } else if (searchErrors[tag.id]) { + subContent = ( +
+ {searchErrors[tag.id]} +
+ ); + } else if (searchResults[tag.id]?.length === 0) { + subContent = ( +
+ +
+ ); + } + + let searchResult; + if (searchResults[tag.id]?.length > 0 && !isTagged) { + searchResult = ( + + ); + } + + return ( +
+
+
+
+ + + +
+
+ +

{tag.name}

+ + {mainContent} +
{subContent}
+ {searchResult} +
+
+
+ ); + }); + + return ( + + {showBatchUpdate && ( + setShowBatchUpdate(false)} + isIdle={isIdle} + selectedEndpoint={selectedEndpoint} + tags={tags} + onBatchUpdate={handleBatchUpdate} + /> + )} + + {showBatchAdd && ( + setShowBatchAdd(false)} + isIdle={isIdle} + onBatchAdd={handleBatchAdd} + /> + )} +
+ + +
+
{renderTags()}
+
+ ); +}; + +interface ITaggerProps { + tags: GQL.TagListDataFragment[]; +} + +export const TagTagger: React.FC = ({ tags }) => { + const jobsSubscribe = useJobsSubscribe(); + const intl = useIntl(); + const { configuration: stashConfig } = useConfigurationContext(); + const { config, setConfig } = useTaggerConfig(); + const [showConfig, setShowConfig] = useState(false); + const [showManual, setShowManual] = useState(false); + + const [batchJobID, setBatchJobID] = useState(); + const [batchJob, setBatchJob] = useState(); + + useEffect(() => { + if (!jobsSubscribe.data) { + return; + } + + const event = jobsSubscribe.data.jobsSubscribe; + if (event.job.id !== batchJobID) { + return; + } + + if (event.type !== GQL.JobStatusUpdateType.Remove) { + setBatchJob(event.job); + } else { + setBatchJob(undefined); + setBatchJobID(undefined); + + const ac = getClient(); + ac.cache.evict({ fieldName: "findTags" }); + ac.cache.gc(); + } + }, [jobsSubscribe, batchJobID]); + + if (!config) return ; + + const savedEndpointIndex = + stashConfig?.general.stashBoxes.findIndex( + (s) => s.endpoint === config.selectedEndpoint + ) ?? -1; + const selectedEndpointIndex = + savedEndpointIndex === -1 && stashConfig?.general.stashBoxes.length + ? 0 + : savedEndpointIndex; + const selectedEndpoint = + stashConfig?.general.stashBoxes[selectedEndpointIndex]; + + async function batchAdd(tagInput: string) { + if (tagInput && selectedEndpoint) { + const inputs = tagInput + .split(",") + .map((n) => n.trim()) + .filter((n) => n.length > 0); + + const { names, stashIds } = separateNamesAndStashIds(inputs); + + if (names.length > 0 || stashIds.length > 0) { + const ret = await mutateStashBoxBatchTagTag({ + names: names.length > 0 ? names : undefined, + stash_ids: stashIds.length > 0 ? stashIds : undefined, + endpoint: selectedEndpointIndex, + refresh: false, + createParent: false, + exclude_fields: config?.excludedTagFields ?? [], + }); + + setBatchJobID(ret.data?.stashBoxBatchTagTag); + } + } + } + + async function batchUpdate(ids: string[] | undefined, refresh: boolean) { + if (selectedEndpoint) { + const ret = await mutateStashBoxBatchTagTag({ + ids: ids, + endpoint: selectedEndpointIndex, + refresh, + createParent: false, + exclude_fields: config?.excludedTagFields ?? [], + }); + + setBatchJobID(ret.data?.stashBoxBatchTagTag); + } + } + + function renderStatus() { + if (batchJob) { + const progress = + batchJob.progress !== undefined && batchJob.progress !== null + ? batchJob.progress * 100 + : undefined; + return ( + +
+ +
+ {progress !== undefined && ( + + )} +
+ ); + } + + if (batchJobID !== undefined) { + return ( + +
+ +
+
+ ); + } + } + + const showHideConfigId = showConfig + ? "actions.hide_configuration" + : "actions.show_configuration"; + + return ( + <> + setShowManual(false)} + defaultActiveTab="Tagger.md" + /> + {renderStatus()} +
+ {selectedEndpointIndex !== -1 && selectedEndpoint ? ( + <> +
+ + +
+ + + setConfig({ ...config, excludedTagFields: fields }) + } + fields={TAG_FIELDS} + entityName="tags" + /> + + + ) : ( +
+

+ +

+
+ Please see{" "} + + el.scrollIntoView({ behavior: "smooth", block: "center" }) + } + > + Settings. + +
+
+ )} +
+ + ); +}; diff --git a/ui/v2.5/src/components/Tagger/utils.ts b/ui/v2.5/src/components/Tagger/utils.ts index 8c1cf54e5..cddad33d9 100644 --- a/ui/v2.5/src/components/Tagger/utils.ts +++ b/ui/v2.5/src/components/Tagger/utils.ts @@ -1,6 +1,6 @@ import * as GQL from "src/core/generated-graphql"; import { ParseMode } from "./constants"; -import { queryFindStudio } from "src/core/StashService"; +import { queryFindStudio, queryFindTag } from "src/core/StashService"; import { mergeStashIDs } from "src/utils/stashbox"; const months = [ @@ -173,14 +173,32 @@ export const parsePath = (filePath: string) => { return { paths, file, ext }; }; -export async function mergeStudioStashIDs( +async function mergeEntityStashIDs( + fetchExisting: (id: string) => Promise, id: string, newStashIDs: GQL.StashIdInput[] ) { - const existing = await queryFindStudio(id); - if (existing?.data?.findStudio?.stash_ids) { - return mergeStashIDs(existing.data.findStudio.stash_ids, newStashIDs); + const existing = await fetchExisting(id); + if (existing) { + return mergeStashIDs(existing, newStashIDs); } - return newStashIDs; } + +export const mergeStudioStashIDs = ( + id: string, + newStashIDs: GQL.StashIdInput[] +) => + mergeEntityStashIDs( + async (studioId) => + (await queryFindStudio(studioId))?.data?.findStudio?.stash_ids, + id, + newStashIDs + ); + +export const mergeTagStashIDs = (id: string, newStashIDs: GQL.StashIdInput[]) => + mergeEntityStashIDs( + async (tagId) => (await queryFindTag(tagId))?.data?.findTag?.stash_ids, + id, + newStashIDs + ); diff --git a/ui/v2.5/src/components/Tags/TagList.tsx b/ui/v2.5/src/components/Tags/TagList.tsx index e30f6071b..61b81b727 100644 --- a/ui/v2.5/src/components/Tags/TagList.tsx +++ b/ui/v2.5/src/components/Tags/TagList.tsx @@ -30,6 +30,7 @@ import { EditTagsDialog } from "./EditTagsDialog"; import { View } from "../List/views"; import { IItemListOperation } from "../List/FilteredListToolbar"; import { PatchComponent } from "src/patch"; +import { TagTagger } from "../Tagger/tags/TagTagger"; function getItems(result: GQL.FindTagsForListQueryResult) { return result?.data?.findTags?.tags ?? []; @@ -355,6 +356,9 @@ export const TagList: React.FC = PatchComponent( if (filter.displayMode === DisplayMode.Wall) { return

TODO

; } + if (filter.displayMode === DisplayMode.Tagger) { + return ; + } } return ( <> diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index d276806fc..27186d6e1 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -2463,6 +2463,12 @@ export const mutateStashBoxBatchStudioTag = ( variables: { input }, }); +export const mutateStashBoxBatchTagTag = (input: GQL.StashBoxBatchTagInput) => + client.mutate({ + mutation: GQL.StashBoxBatchTagTagDocument, + variables: { input }, + }); + export const useListGroupScrapers = () => GQL.useListGroupScrapersQuery(); export const queryScrapeGroupURL = (url: string) => diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 629f1ece8..b7d3e2894 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1345,14 +1345,6 @@ "any_names_entered_will_be_queried": "Any names entered will be queried from the remote Stash-Box instance and added if found. Only exact matches will be considered a match.", "batch_add_performers": "Batch Add Performers", "batch_update_performers": "Batch Update Performers", - "config": { - "active_stash-box_instance": "Active stash-box instance:", - "edit_excluded_fields": "Edit Excluded Fields", - "excluded_fields": "Excluded fields:", - "no_fields_are_excluded": "No fields are excluded", - "no_instances_found": "No instances found", - "these_fields_will_not_be_changed_when_updating_performers": "These fields will not be changed when updating performers." - }, "current_page": "Current page", "failed_to_save_performer": "Failed to save performer \"{performer}\"", "name_already_exists": "Name already exists", @@ -1555,14 +1547,8 @@ "batch_add_studios": "Batch Add Studios", "batch_update_studios": "Batch Update Studios", "config": { - "active_stash-box_instance": "Active stash-box instance:", "create_parent_desc": "Create missing parent studios, or tag and update data/image for existing parent studios with exact name matches", - "create_parent_label": "Create parent studios", - "edit_excluded_fields": "Edit Excluded Fields", - "excluded_fields": "Excluded fields:", - "no_fields_are_excluded": "No fields are excluded", - "no_instances_found": "No instances found", - "these_fields_will_not_be_changed_when_updating_studios": "These fields will not be changed when updating studios." + "create_parent_label": "Create parent studios" }, "create_or_tag_parent_studios": "Create missing or tag existing parent studios", "current_page": "Current page", @@ -1604,6 +1590,42 @@ "tag_count": "Tag Count", "tag_parent_tooltip": "Has parent tags", "tag_sub_tag_tooltip": "Has sub-tags", + "tag_tagger": { + "add_new_tags": "Add New Tags", + "any_names_entered_will_be_queried": "Any names entered will be queried from the remote Stash-Box instance and added if found. Only exact matches will be considered a match.", + "batch_add_tags": "Batch Add Tags", + "batch_update_tags": "Batch Update Tags", + "current_page": "Current page", + "failed_to_save_tag": "Failed to save tag \"{tag}\"", + "name_already_exists": "Name already exists", + "network_error": "Network Error", + "no_results_found": "No results found.", + "number_of_tags_will_be_processed": "{tag_count} tags will be processed", + "query_all_tags_in_the_database": "All tags in the database", + "refresh_tagged_tags": "Refresh tagged tags", + "refreshing_will_update_the_data": "Refreshing will update the data of any tagged tags from the stash-box instance.", + "status_tagging_job_queued": "Status: Tagging job queued", + "status_tagging_tags": "Status: Tagging tags", + "tag_already_tagged": "Tag already tagged", + "tag_names_or_stashids_separated_by_comma": "Tag names or StashIDs separated by comma", + "tag_selection": "Tag selection", + "tag_successfully_tagged": "Tag successfully tagged", + "tag_status": "Tag Status", + "to_use_the_tag_tagger": "To use the tag tagger a stash-box instance needs to be configured.", + "untagged_tags": "Untagged tags", + "update_tags": "Update Tags", + "updating_untagged_tags_description": "Updating untagged tags will try to match any tags that lack a stashid and update the metadata." + }, + "tagger": { + "config": { + "active_stash-box_instance": "Active stash-box instance:", + "edit_excluded_fields": "Edit Excluded Fields", + "excluded_fields": "Excluded fields:", + "fields_will_not_be_changed": "These fields will not be changed when updating {entity}.", + "no_fields_are_excluded": "No fields are excluded", + "no_instances_found": "No instances found" + } + }, "tags": "Tags", "tattoos": "Tattoos", "time": "Time", diff --git a/ui/v2.5/src/models/list-filter/tags.ts b/ui/v2.5/src/models/list-filter/tags.ts index e2d4fbed4..39ce9ca39 100644 --- a/ui/v2.5/src/models/list-filter/tags.ts +++ b/ui/v2.5/src/models/list-filter/tags.ts @@ -50,7 +50,11 @@ const sortByOptions = ["name", "random", "scenes_duration"] }, ]); -const displayModeOptions = [DisplayMode.Grid, DisplayMode.List]; +const displayModeOptions = [ + DisplayMode.Grid, + DisplayMode.List, + DisplayMode.Tagger, +]; const criterionOptions = [ FavoriteTagCriterionOption, createMandatoryStringCriterionOption("name"), From cf04e854d62178a719909d97e79d0b4318790040 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:21:16 +1100 Subject: [PATCH 085/177] Fix missing message id changes from #6600 --- .../Galleries/GalleryDetails/GalleryFileInfoPanel.tsx | 2 +- ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx | 2 +- ui/v2.5/src/models/list-filter/galleries.ts | 2 +- ui/v2.5/src/models/list-filter/images.ts | 2 +- ui/v2.5/src/models/list-filter/scenes.ts | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx index e97146b91..b7dab09a0 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx @@ -38,7 +38,7 @@ const FileInfoPanel: React.FC = ( )} - + diff --git a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx index dc0a616d6..f39fef103 100755 --- a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx @@ -201,7 +201,7 @@ const getFingerprintStatus = ( , + hash_type: , }} />
diff --git a/ui/v2.5/src/models/list-filter/galleries.ts b/ui/v2.5/src/models/list-filter/galleries.ts index 630267c72..3d4d40a1c 100644 --- a/ui/v2.5/src/models/list-filter/galleries.ts +++ b/ui/v2.5/src/models/list-filter/galleries.ts @@ -49,7 +49,7 @@ const criterionOptions = [ createStringCriterionOption("details"), createStringCriterionOption("photographer"), PathCriterionOption, - createStringCriterionOption("checksum", "media_info.checksum"), + createStringCriterionOption("checksum", "media_info.md5"), RatingCriterionOption, OrganizedCriterionOption, AverageResolutionCriterionOption, diff --git a/ui/v2.5/src/models/list-filter/images.ts b/ui/v2.5/src/models/list-filter/images.ts index 2d3db8265..9468e5eaf 100644 --- a/ui/v2.5/src/models/list-filter/images.ts +++ b/ui/v2.5/src/models/list-filter/images.ts @@ -47,7 +47,7 @@ const criterionOptions = [ createStringCriterionOption("code", "scene_code"), createStringCriterionOption("details"), createStringCriterionOption("photographer"), - createMandatoryStringCriterionOption("checksum", "media_info.checksum"), + createMandatoryStringCriterionOption("checksum", "media_info.md5"), PhashCriterionOption, PathCriterionOption, GalleriesCriterionOption, diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index 251e2592d..f4f93deeb 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -97,8 +97,8 @@ const criterionOptions = [ PathCriterionOption, createStringCriterionOption("details"), createStringCriterionOption("director"), - createMandatoryStringCriterionOption("oshash", "media_info.hash"), - createStringCriterionOption("checksum", "media_info.checksum"), + createMandatoryStringCriterionOption("oshash", "media_info.oshash"), + createStringCriterionOption("checksum", "media_info.md5"), PhashCriterionOption, DuplicatedCriterionOption, OrganizedCriterionOption, From 01d351c85d57b4a580cefd4482c1499afa829c05 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Tue, 24 Feb 2026 19:56:24 -0800 Subject: [PATCH 086/177] FR: Custom Fields Frontend (#6601) * Add "custom-field-" prefix to custom field detail item ids --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- ui/v2.5/graphql/data/gallery.graphql | 2 + ui/v2.5/graphql/data/group.graphql | 2 + ui/v2.5/graphql/data/image.graphql | 2 + ui/v2.5/graphql/data/scene.graphql | 2 + ui/v2.5/graphql/data/studio.graphql | 1 + ui/v2.5/graphql/queries/scene.graphql | 8 ++ .../GalleryDetails/GalleryDetailPanel.tsx | 2 + .../GalleryDetails/GalleryEditPanel.tsx | 39 ++++++- ui/v2.5/src/components/Galleries/styles.scss | 14 +++ .../Groups/GroupDetails/GroupDetailsPanel.tsx | 2 + .../Groups/GroupDetails/GroupEditPanel.tsx | 37 +++++- .../Images/ImageDetails/ImageDetailPanel.tsx | 2 + .../Images/ImageDetails/ImageEditPanel.tsx | 30 ++++- ui/v2.5/src/components/Images/styles.scss | 14 +++ .../PerformerDetails/PerformerEditPanel.tsx | 19 +-- ui/v2.5/src/components/Performers/styles.scss | 5 - .../Scenes/SceneDetails/SceneDetailPanel.tsx | 2 + .../Scenes/SceneDetails/SceneEditPanel.tsx | 39 ++++++- .../components/Scenes/SceneMergeDialog.tsx | 109 ++++++++++++++---- ui/v2.5/src/components/Scenes/styles.scss | 14 +++ .../src/components/Shared/CustomFields.tsx | 25 ++-- ui/v2.5/src/components/Shared/styles.scss | 35 ++++++ .../StudioDetails/StudioDetailsPanel.tsx | 2 + .../Studios/StudioDetails/StudioEditPanel.tsx | 38 +++++- .../Tags/TagDetails/TagDetailsPanel.tsx | 2 + .../Tags/TagDetails/TagEditPanel.tsx | 36 +++++- ui/v2.5/src/core/StashService.ts | 8 ++ ui/v2.5/src/models/list-filter/galleries.ts | 2 + ui/v2.5/src/models/list-filter/groups.ts | 2 + ui/v2.5/src/models/list-filter/images.ts | 2 + ui/v2.5/src/models/list-filter/scenes.ts | 2 + ui/v2.5/src/models/list-filter/studios.ts | 2 + ui/v2.5/src/models/list-filter/tags.ts | 2 + 33 files changed, 434 insertions(+), 69 deletions(-) diff --git a/ui/v2.5/graphql/data/gallery.graphql b/ui/v2.5/graphql/data/gallery.graphql index 89f3ed44c..349a52ad7 100644 --- a/ui/v2.5/graphql/data/gallery.graphql +++ b/ui/v2.5/graphql/data/gallery.graphql @@ -39,6 +39,8 @@ fragment GalleryData on Gallery { scenes { ...SlimSceneData } + + custom_fields } fragment SelectGalleryData on Gallery { diff --git a/ui/v2.5/graphql/data/group.graphql b/ui/v2.5/graphql/data/group.graphql index 440c420da..a9968bbae 100644 --- a/ui/v2.5/graphql/data/group.graphql +++ b/ui/v2.5/graphql/data/group.graphql @@ -39,6 +39,8 @@ fragment GroupData on Group { id title } + + custom_fields } # Lightweight fragment for list views - excludes expensive recursive counts diff --git a/ui/v2.5/graphql/data/image.graphql b/ui/v2.5/graphql/data/image.graphql index 52163b007..63ce5b458 100644 --- a/ui/v2.5/graphql/data/image.graphql +++ b/ui/v2.5/graphql/data/image.graphql @@ -37,4 +37,6 @@ fragment ImageData on Image { visual_files { ...VisualFileData } + + custom_fields } diff --git a/ui/v2.5/graphql/data/scene.graphql b/ui/v2.5/graphql/data/scene.graphql index e4a6e5cc6..b7378c1da 100644 --- a/ui/v2.5/graphql/data/scene.graphql +++ b/ui/v2.5/graphql/data/scene.graphql @@ -79,6 +79,8 @@ fragment SceneData on Scene { mime_type label } + + custom_fields } fragment SelectSceneData on Scene { diff --git a/ui/v2.5/graphql/data/studio.graphql b/ui/v2.5/graphql/data/studio.graphql index 8347b4739..0e23a885e 100644 --- a/ui/v2.5/graphql/data/studio.graphql +++ b/ui/v2.5/graphql/data/studio.graphql @@ -41,6 +41,7 @@ fragment StudioData on Studio { ...SlimTagData } o_counter + custom_fields } fragment SelectStudioData on Studio { diff --git a/ui/v2.5/graphql/queries/scene.graphql b/ui/v2.5/graphql/queries/scene.graphql index d6a3afd47..0e1a9fa11 100644 --- a/ui/v2.5/graphql/queries/scene.graphql +++ b/ui/v2.5/graphql/queries/scene.graphql @@ -40,6 +40,14 @@ query FindScene($id: ID!, $checksum: String) { } } +query FindFullScenes($ids: [Int!]) { + findScenes(scene_ids: $ids) { + scenes { + ...SceneData + } + } +} + query FindSceneMarkerTags($id: ID!) { sceneMarkerTags(scene_id: $id) { tag { diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx index 597a57b15..ead882ec0 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx @@ -6,6 +6,7 @@ import { TagLink } from "src/components/Shared/TagLink"; import { PerformerCard } from "src/components/Performers/PerformerCard"; import { sortPerformers } from "src/core/performers"; import { PhotographerLink } from "src/components/Shared/Link"; +import { CustomFields } from "src/components/Shared/CustomFields"; interface IGalleryDetailProps { gallery: GQL.GalleryDataFragment; @@ -108,6 +109,7 @@ export const GalleryDetailPanel: React.FC = ({ {renderDetails()} {renderTags()} {renderPerformers()} +
diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx index 04b802784..14b5d6aad 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx @@ -31,6 +31,11 @@ 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 { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; +import { cloneDeep } from "@apollo/client/utilities"; interface IProps { gallery: Partial; @@ -76,6 +81,7 @@ export const GalleryEditPanel: React.FC = ({ tag_ids: yup.array(yup.string().required()).defined(), scene_ids: yup.array(yup.string().required()).defined(), details: yup.string().ensure(), + custom_fields: yup.object().required().defined(), }); const initialValues = { @@ -89,15 +95,26 @@ export const GalleryEditPanel: React.FC = ({ tag_ids: (gallery?.tags ?? []).map((t) => t.id), scene_ids: (gallery?.scenes ?? []).map((s) => s.id), details: gallery?.details ?? "", + custom_fields: cloneDeep(gallery?.custom_fields ?? {}), }; type InputValues = yup.InferType; + const [customFieldsError, setCustomFieldsError] = useState(); + + function submit(values: InputValues) { + const input = { + ...schema.cast(values), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), + }; + onSave(input); + } + const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), - onSubmit: (values) => onSave(schema.cast(values)), + onSubmit: submit, }); const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( @@ -189,7 +206,10 @@ export const GalleryEditPanel: React.FC = ({ } async function onSaveAndNewClick() { - const input = schema.cast(formik.values); + const input = { + ...schema.cast(formik.values), + custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields), + }; onSave(input, true); } @@ -455,7 +475,9 @@ export const GalleryEditPanel: React.FC = ({ id="gallery-save-split-button" className="edit-button" variant="primary" - disabled={!isEqual(formik.errors, {})} + disabled={ + !isEqual(formik.errors, {}) || customFieldsError !== undefined + } title={intl.formatMessage({ id: "actions.save" })} onClick={() => formik.submitForm()} > @@ -468,7 +490,9 @@ export const GalleryEditPanel: React.FC = ({ className="edit-button" variant="primary" disabled={ - (!isNew && !formik.dirty) || !isEqual(formik.errors, {}) + (!isNew && !formik.dirty) || + !isEqual(formik.errors, {}) || + customFieldsError !== undefined } onClick={() => formik.submitForm()} > @@ -523,6 +547,13 @@ export const GalleryEditPanel: React.FC = ({ {cover} + + formik.setFieldValue("custom_fields", v)} + error={customFieldsError} + setError={(e) => setCustomFieldsError(e)} + /> diff --git a/ui/v2.5/src/components/Galleries/styles.scss b/ui/v2.5/src/components/Galleries/styles.scss index c53175313..ac9330e9a 100644 --- a/ui/v2.5/src/components/Galleries/styles.scss +++ b/ui/v2.5/src/components/Galleries/styles.scss @@ -208,6 +208,20 @@ $galleryTabWidth: 450px; .form-group[data-field="urls"] .string-list-input input.form-control { font-size: 0.85em; } + + @include media-breakpoint-up(xl) { + .custom-fields-input { + .custom-fields-field { + flex: 0 0 25%; + max-width: 25%; + } + + .custom-fields-value { + flex: 0 0 75%; + max-width: 75%; + } + } + } } .gallery-cover { diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx index b8e39ffe6..8ae4b16a9 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx @@ -6,6 +6,7 @@ import { DetailItem } from "src/components/Shared/DetailItem"; import { Link } from "react-router-dom"; import { DirectorLink } from "src/components/Shared/Link"; import { GroupLink, TagLink } from "src/components/Shared/TagLink"; +import { CustomFields } from "src/components/Shared/CustomFields"; interface IGroupDescription { group: GQL.SlimGroupDataFragment; @@ -101,6 +102,7 @@ export const GroupDetailsPanel: React.FC = ({ fullWidth={fullWidth} /> )} +
); }; diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx index f0a6f17c1..6401738fa 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx @@ -28,6 +28,11 @@ 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 { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; +import { cloneDeep } from "@apollo/client/utilities"; interface IGroupEditPanel { group: Partial; @@ -84,6 +89,7 @@ export const GroupEditPanel: React.FC = ({ synopsis: yup.string().ensure(), front_image: yup.string().nullable().optional(), back_image: yup.string().nullable().optional(), + custom_fields: yup.object().required().defined(), }); const initialValues = { @@ -99,15 +105,26 @@ export const GroupEditPanel: React.FC = ({ director: group?.director ?? "", urls: group?.urls ?? [], synopsis: group?.synopsis ?? "", + custom_fields: cloneDeep(group?.custom_fields ?? {}), }; type InputValues = yup.InferType; + const [customFieldsError, setCustomFieldsError] = useState(); + + function submit(values: InputValues) { + const input = { + ...schema.cast(values), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), + }; + onSave(input); + } + const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), - onSubmit: (values) => onSave(schema.cast(values)), + onSubmit: submit, }); const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( @@ -220,7 +237,10 @@ export const GroupEditPanel: React.FC = ({ } async function onSaveAndNewClick() { - const input = schema.cast(formik.values); + const input = { + ...schema.cast(formik.values), + custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields), + }; onSave(input, true); } @@ -458,6 +478,13 @@ export const GroupEditPanel: React.FC = ({ {renderURLListField("urls", onScrapeGroupURL, urlScrapable)} {renderInputField("synopsis", "textarea")} {renderTagsField()} + + formik.setFieldValue("custom_fields", v)} + error={customFieldsError} + setError={(e) => setCustomFieldsError(e)} + /> = ({ onToggleEdit={onCancel} onSave={formik.handleSubmit} onSaveAndNew={isNew ? onSaveAndNewClick : undefined} - saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})} + saveDisabled={ + (!isNew && !formik.dirty) || + !isEqual(formik.errors, {}) || + customFieldsError !== undefined + } onImageChange={onFrontImageChange} onImageChangeURL={onFrontImageLoad} onClearImage={() => onFrontImageLoad(null)} diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx index a2044fcff..cf33b648b 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx @@ -7,6 +7,7 @@ import { sortPerformers } from "src/core/performers"; import { FormattedMessage, useIntl } from "react-intl"; import { PhotographerLink } from "src/components/Shared/Link"; import { PatchComponent } from "../../../patch"; +import { CustomFields } from "src/components/Shared/CustomFields"; interface IImageDetailProps { image: GQL.ImageDataFragment; } @@ -132,6 +133,7 @@ export const ImageDetailPanel: React.FC = PatchComponent( {renderDetails()} {renderTags()} {renderPerformers()} +
diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx index 58b809d41..94dddac4b 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx @@ -35,6 +35,11 @@ import { } from "src/components/Galleries/GallerySelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { ScraperMenu } from "src/components/Shared/ScraperMenu"; +import { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; +import { cloneDeep } from "@apollo/client/utilities"; interface IProps { image: GQL.ImageDataFragment; @@ -86,6 +91,7 @@ export const ImageEditPanel: React.FC = ({ studio_id: yup.string().required().nullable(), performer_ids: yup.array(yup.string().required()).defined(), tag_ids: yup.array(yup.string().required()).defined(), + custom_fields: yup.object().required().defined(), }); const initialValues = { @@ -99,15 +105,26 @@ export const ImageEditPanel: React.FC = ({ studio_id: image.studio?.id ?? null, performer_ids: (image.performers ?? []).map((p) => p.id), tag_ids: (image.tags ?? []).map((t) => t.id), + custom_fields: cloneDeep(image.custom_fields ?? {}), }; type InputValues = yup.InferType; + const [customFieldsError, setCustomFieldsError] = useState(); + + function submit(values: InputValues) { + const input = { + ...schema.cast(values), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), + }; + onSave(input); + } + const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), - onSubmit: (values) => onSave(schema.cast(values)), + onSubmit: submit, }); const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( @@ -444,7 +461,9 @@ export const ImageEditPanel: React.FC = ({ className="edit-button" variant="primary" disabled={ - (!isNew && !formik.dirty) || !isEqual(formik.errors, {}) + (!isNew && !formik.dirty) || + !isEqual(formik.errors, {}) || + customFieldsError !== undefined } onClick={() => formik.submitForm()} > @@ -492,6 +511,13 @@ export const ImageEditPanel: React.FC = ({ {renderDetailsField()} + + formik.setFieldValue("custom_fields", v)} + error={customFieldsError} + setError={(e) => setCustomFieldsError(e)} + /> diff --git a/ui/v2.5/src/components/Images/styles.scss b/ui/v2.5/src/components/Images/styles.scss index 0050a9434..43ac56590 100644 --- a/ui/v2.5/src/components/Images/styles.scss +++ b/ui/v2.5/src/components/Images/styles.scss @@ -179,6 +179,20 @@ $imageTabWidth: 450px; .form-group[data-field="urls"] .string-list-input input.form-control { font-size: 0.85em; } + + @include media-breakpoint-up(xl) { + .custom-fields-input { + .custom-fields-field { + flex: 0 0 25%; + max-width: 25%; + } + + .custom-fields-value { + flex: 0 0 75%; + max-width: 75%; + } + } + } } .image-file-card.card { diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index 98871bf9a..93b69e7b5 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -48,7 +48,10 @@ import { yupUniqueStringList, } from "src/utils/yup"; import { useTagsEdit } from "src/hooks/tagsEdit"; -import { CustomFieldsInput } from "src/components/Shared/CustomFields"; +import { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; import { cloneDeep } from "@apollo/client/utilities"; const isScraper = ( @@ -67,16 +70,6 @@ interface IPerformerDetails { setEncodingImage: (loading: boolean) => void; } -function customFieldInput(isNew: boolean, input: {}) { - if (isNew) { - return input; - } else { - return { - full: input, - }; - } -} - export const PerformerEditPanel: React.FC = ({ performer, isVisible, @@ -173,7 +166,7 @@ export const PerformerEditPanel: React.FC = ({ function submit(values: InputValues) { const input = { ...schema.cast(values), - custom_fields: customFieldInput(isNew, values.custom_fields), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), }; onSave(input); } @@ -368,7 +361,7 @@ export const PerformerEditPanel: React.FC = ({ const { values } = formik; const input = { ...schema.cast(values), - custom_fields: customFieldInput(isNew, values.custom_fields), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), }; onSave(input, true); } diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index 54a010e50..49dc27550 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -82,11 +82,6 @@ font-weight: 700; padding-left: 0; } - - .custom-fields .detail-item-title, - .custom-fields .detail-item-value { - font-family: "Courier New", Courier, monospace; - } /* stylelint-enable selector-class-pattern */ } diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx index ad7663e9d..b109016b1 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx @@ -6,6 +6,7 @@ import { TagLink } from "src/components/Shared/TagLink"; import { PerformerCard } from "src/components/Performers/PerformerCard"; import { sortPerformers } from "src/core/performers"; import { DirectorLink } from "src/components/Shared/Link"; +import { CustomFields } from "src/components/Shared/CustomFields"; interface ISceneDetailProps { scene: GQL.SceneDataFragment; @@ -103,6 +104,7 @@ export const SceneDetailPanel: React.FC = (props) => { {renderDetails()} {renderTags()} {renderPerformers()} +
diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index 54bf5b573..41293ff78 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -50,6 +50,11 @@ 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 { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; +import { cloneDeep } from "@apollo/client/utilities"; const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog")); const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal")); @@ -140,6 +145,7 @@ export const SceneEditPanel: React.FC = ({ stash_ids: yup.mixed().defined(), details: yup.string().ensure(), cover_image: yup.string().nullable().optional(), + custom_fields: yup.object().required().defined(), }); const initialValues = useMemo( @@ -159,17 +165,28 @@ export const SceneEditPanel: React.FC = ({ stash_ids: getStashIDs(scene.stash_ids), details: scene.details ?? "", cover_image: initialCoverImage, + custom_fields: cloneDeep(scene.custom_fields ?? {}), }), [scene, initialCoverImage] ); type InputValues = yup.InferType; + const [customFieldsError, setCustomFieldsError] = useState(); + + function submit(values: InputValues) { + const input = { + ...schema.cast(values), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), + }; + onSave(input); + } + const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), - onSubmit: (values) => onSave(schema.cast(values)), + onSubmit: submit, }); const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( @@ -288,7 +305,10 @@ export const SceneEditPanel: React.FC = ({ } async function onSaveAndNewClick() { - const input = schema.cast(formik.values); + const input = { + ...schema.cast(formik.values), + custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields), + }; onSave(input, true); } @@ -759,7 +779,9 @@ export const SceneEditPanel: React.FC = ({ id="scene-save-split-button" className="edit-button" variant="primary" - disabled={!isEqual(formik.errors, {})} + disabled={ + !isEqual(formik.errors, {}) || customFieldsError !== undefined + } title={intl.formatMessage({ id: "actions.save" })} onClick={() => formik.submitForm()} > @@ -772,7 +794,9 @@ export const SceneEditPanel: React.FC = ({ className="edit-button" variant="primary" disabled={ - (!isNew && !formik.dirty) || !isEqual(formik.errors, {}) + (!isNew && !formik.dirty) || + !isEqual(formik.errors, {}) || + customFieldsError !== undefined } onClick={() => formik.submitForm()} > @@ -863,6 +887,13 @@ export const SceneEditPanel: React.FC = ({ onReset={scene.id ? onResetCover : undefined} /> + + formik.setFieldValue("custom_fields", v)} + error={customFieldsError} + setError={(e) => setCustomFieldsError(e)} + /> diff --git a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx index 9455af186..89d445002 100644 --- a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx @@ -7,12 +7,16 @@ import { StringListSelect, GallerySelect } from "../Shared/Select"; import * as FormUtils from "src/utils/form"; import ImageUtils from "src/utils/image"; import TextUtils from "src/utils/text"; -import { mutateSceneMerge, queryFindScenesByID } from "src/core/StashService"; +import { + mutateSceneMerge, + queryFindFullScenesByID, +} from "src/core/StashService"; import { FormattedMessage, useIntl } from "react-intl"; import { useToast } from "src/hooks/Toast"; import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons"; import { ScrapeDialogRow, + ScrapedCustomFieldRows, ScrapedImageRow, ScrapedInputGroupRow, ScrapedStringListRow, @@ -24,6 +28,7 @@ import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { ModalComponent } from "../Shared/Modal"; import { IHasStoredID, sortStoredIdObjects } from "src/utils/data"; import { + CustomFieldScrapeResults, ObjectListScrapeResult, ScrapeResult, ZeroableScrapeResult, @@ -52,8 +57,8 @@ type MergeOptions = { }; interface ISceneMergeDetailsProps { - sources: GQL.SlimSceneDataFragment[]; - dest: GQL.SlimSceneDataFragment; + sources: GQL.SceneDataFragment[]; + dest: GQL.SceneDataFragment; onClose: (options?: MergeOptions) => void; } @@ -173,6 +178,10 @@ const SceneMergeDetails: React.FC = ({ new ScrapeResult(dest.paths.screenshot) ); + const [customFields, setCustomFields] = useState( + new Map() + ); + // calculate the values for everything // uses the first set value for single value fields, and combines all useEffect(() => { @@ -309,28 +318,64 @@ const SceneMergeDetails: React.FC = ({ ) ); + const customFieldNames = new Set( + Object.keys(dest.custom_fields ?? {}) + ); + + for (const s of sources) { + for (const n of Object.keys(s.custom_fields ?? {})) { + customFieldNames.add(n); + } + } + + setCustomFields( + new Map( + Array.from(customFieldNames) + .sort() + .map((field) => { + return [ + field, + new ScrapeResult( + dest.custom_fields?.[field], + sources.find((s) => s.custom_fields?.[field])?.custom_fields?.[ + field + ], + dest.custom_fields?.[field] === undefined + ), + ]; + }) + ) + ); + loadImages(); }, [sources, dest]); + const hasCustomFieldValues = useMemo(() => { + return hasScrapedValues(Array.from(customFields.values())); + }, [customFields]); + // ensure this is updated if fields are changed const hasValues = useMemo(() => { - return hasScrapedValues([ - title, - code, - url, - date, - rating, - oCounter, - galleries, - studio, - performers, - groups, - tags, - details, - organized, - stashIDs, - image, - ]); + return ( + hasCustomFieldValues || + hasScrapedValues([ + title, + code, + url, + date, + rating, + oCounter, + galleries, + studio, + performers, + groups, + tags, + details, + organized, + stashIDs, + image, + ]) + ); }, [ title, code, @@ -347,6 +392,7 @@ const SceneMergeDetails: React.FC = ({ organized, stashIDs, image, + hasCustomFieldValues, ]); function renderScrapeRows() { @@ -566,6 +612,12 @@ const SceneMergeDetails: React.FC = ({ result={image} onChange={(value) => setImage(value)} /> + {hasCustomFieldValues && ( + setCustomFields(newCustomFields)} + /> + )} ); } @@ -606,6 +658,13 @@ const SceneMergeDetails: React.FC = ({ organized: organized.getNewValue(), stash_ids: stashIDs.getNewValue(), cover_image: coverImage, + custom_fields: { + partial: Object.fromEntries( + Array.from(customFields.entries()).flatMap(([field, v]) => + v.useNewValue ? [[field, v.getNewValue()]] : [] + ) + ), + }, }, includeViewHistory: playCount.getNewValue() !== undefined, includeOHistory: oCounter.getNewValue() !== undefined, @@ -655,10 +714,10 @@ export const SceneMergeModal: React.FC = ({ const [sourceScenes, setSourceScenes] = useState([]); const [destScene, setDestScene] = useState([]); - const [loadedSources, setLoadedSources] = useState< - GQL.SlimSceneDataFragment[] - >([]); - const [loadedDest, setLoadedDest] = useState(); + const [loadedSources, setLoadedSources] = useState( + [] + ); + const [loadedDest, setLoadedDest] = useState(); const [running, setRunning] = useState(false); const [secondStep, setSecondStep] = useState(false); @@ -684,7 +743,7 @@ export const SceneMergeModal: React.FC = ({ async function loadScenes() { const sceneIDs = sourceScenes.map((s) => parseInt(s.id)); sceneIDs.push(parseInt(destScene[0].id)); - const query = await queryFindScenesByID(sceneIDs); + const query = await queryFindFullScenesByID(sceneIDs); const { scenes: loadedScenes } = query.data.findScenes; setLoadedDest(loadedScenes.find((s) => s.id === destScene[0].id)); diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index 78644b4c9..3f142f4bd 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -562,6 +562,20 @@ input[type="range"].blue-slider { .form-group[data-field="urls"] .string-list-input input.form-control { font-size: 0.85em; } + + @include media-breakpoint-up(xl) { + .custom-fields-input { + .custom-fields-field { + flex: 0 0 25%; + max-width: 25%; + } + + .custom-fields-value { + flex: 0 0 75%; + max-width: 75%; + } + } + } } .scene-markers-panel { diff --git a/ui/v2.5/src/components/Shared/CustomFields.tsx b/ui/v2.5/src/components/Shared/CustomFields.tsx index c8d389a17..e6e892f7c 100644 --- a/ui/v2.5/src/components/Shared/CustomFields.tsx +++ b/ui/v2.5/src/components/Shared/CustomFields.tsx @@ -18,6 +18,7 @@ export type CustomFieldMap = { interface ICustomFields { values: CustomFieldMap; + fullWidth?: boolean; } function convertValue(value: unknown): string { @@ -41,7 +42,7 @@ const CustomField: React.FC<{ field: string; value: unknown }> = ({ const valueStr = convertValue(value); // replace spaces with hyphen characters for css id - const id = field.toLowerCase().replace(/ /g, "-"); + const id = `custom-field-${field.toLowerCase().replace(/ /g, "-")}`; return ( = ({ export const CustomFields: React.FC = PatchComponent( "CustomFields", - ({ values }) => { + ({ values, fullWidth }) => { const intl = useIntl(); if (Object.keys(values).length === 0) { return null; @@ -65,7 +66,7 @@ export const CustomFields: React.FC = PatchComponent( return ( // according to linter rule CSS classes shouldn't use underscores -
+
@@ -125,7 +126,7 @@ const CustomFieldInput: React.FC<{ - + {isNew ? ( <> {currentField} )} - + void; } +export function formatCustomFieldInput(isNew: boolean, input: {}) { + if (isNew) { + return input; + } else { + return { + full: input, + }; + } +} + export const CustomFieldsInput: React.FC = PatchComponent( "CustomFieldsInput", ({ values, error, onChange, setError }) => { @@ -282,10 +293,10 @@ export const CustomFieldsInput: React.FC = PatchComponent( - + - + diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 32b222832..97a5c4387 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -795,6 +795,11 @@ button.btn.favorite-button { .detail-item { max-width: 100%; } + + .detail-item-title, + .detail-item-value { + font-family: "Courier New", Courier, monospace; + } } .custom-fields .detail-item .detail-item-title { @@ -816,6 +821,36 @@ button.btn.favorite-button { font-weight: 700; } +.custom-fields-input { + .custom-fields-field { + flex: 0 0 100%; + max-width: 100%; + + @include media-breakpoint-up(sm) { + flex: 0 0 25%; + max-width: 25%; + } + @include media-breakpoint-up(xl) { + flex: 0 0 16.667%; + max-width: 16.667%; + } + } + + .custom-fields-value { + flex: 0 0 100%; + max-width: 100%; + + @include media-breakpoint-up(sm) { + flex: 0 0 75%; + max-width: 75%; + } + @include media-breakpoint-up(xl) { + flex: 0 0 58.33%; + max-width: 58.33%; + } + } +} + .custom-fields-row { align-items: center; font-family: "Courier New", Courier, monospace; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx index 5ad92100f..ae8314fe8 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx @@ -4,6 +4,7 @@ import * as GQL from "src/core/generated-graphql"; import { DetailItem } from "src/components/Shared/DetailItem"; import { StashIDPill } from "src/components/Shared/StashID"; import { PatchComponent } from "src/patch"; +import { CustomFields } from "src/components/Shared/CustomFields"; import { Link } from "react-router-dom"; interface IStudioDetailsPanel { @@ -87,6 +88,7 @@ export const StudioDetailsPanel: React.FC = PatchComponent( value={renderStashIDs()} fullWidth={fullWidth} /> +
); } diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx index f887e5403..490f09a55 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx @@ -21,6 +21,11 @@ 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"; +import { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; +import { cloneDeep } from "@apollo/client/utilities"; interface IStudioEditPanel { studio: Partial; @@ -63,6 +68,7 @@ export const StudioEditPanel: React.FC = ({ ignore_auto_tag: yup.boolean().defined(), stash_ids: yup.mixed().defined(), image: yup.string().nullable().optional(), + custom_fields: yup.object().required().defined(), }); const initialValues = { @@ -75,15 +81,26 @@ export const StudioEditPanel: React.FC = ({ tag_ids: (studio.tags ?? []).map((t) => t.id), ignore_auto_tag: studio.ignore_auto_tag ?? false, stash_ids: getStashIDs(studio.stash_ids), + custom_fields: cloneDeep(studio.custom_fields ?? {}), }; type InputValues = yup.InferType; + const [customFieldsError, setCustomFieldsError] = useState(); + + function submit(values: InputValues) { + const input = { + ...schema.cast(values), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), + }; + onSave(input); + } + const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), - onSubmit: (values) => onSave(schema.cast(values)), + onSubmit: submit, }); const { tagsControl } = useTagsEdit(studio.tags, (ids) => @@ -144,7 +161,10 @@ export const StudioEditPanel: React.FC = ({ } async function onSaveAndNewClick() { - const input = schema.cast(formik.values); + const input = { + ...schema.cast(formik.values), + custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields), + }; onSave(input, true); } @@ -242,6 +262,14 @@ export const StudioEditPanel: React.FC = ({ )} + + formik.setFieldValue("custom_fields", v)} + error={customFieldsError} + setError={(e) => setCustomFieldsError(e)} + /> +
{renderInputField("ignore_auto_tag", "checkbox")} @@ -254,7 +282,11 @@ export const StudioEditPanel: React.FC = ({ onToggleEdit={onCancel} onSave={formik.handleSubmit} onSaveAndNew={isNew ? onSaveAndNewClick : undefined} - saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})} + saveDisabled={ + (!isNew && !formik.dirty) || + !isEqual(formik.errors, {}) || + customFieldsError !== undefined + } onImageChange={onImageChange} onImageChangeURL={onImageLoad} onClearImage={() => onImageLoad(null)} diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagDetailsPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagDetailsPanel.tsx index 92c92d072..bf2e80c91 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagDetailsPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagDetailsPanel.tsx @@ -3,6 +3,7 @@ import { TagLink } from "src/components/Shared/TagLink"; import { DetailItem } from "src/components/Shared/DetailItem"; import { StashIDPill } from "src/components/Shared/StashID"; import * as GQL from "src/core/generated-graphql"; +import { CustomFields } from "src/components/Shared/CustomFields"; interface ITagDetails { tag: GQL.TagDataFragment; @@ -90,6 +91,7 @@ export const TagDetailsPanel: React.FC = ({ tag, fullWidth }) => { value={renderStashIDs()} fullWidth={fullWidth} /> +
); }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx index 22c99b80e..21cd32c53 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx @@ -20,6 +20,11 @@ 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"; +import { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; +import { cloneDeep } from "@apollo/client/utilities"; interface ITagEditPanel { tag: Partial; @@ -63,6 +68,7 @@ export const TagEditPanel: React.FC = ({ ignore_auto_tag: yup.boolean().defined(), stash_ids: yup.mixed().defined(), image: yup.string().nullable().optional(), + custom_fields: yup.object().required().defined(), }); const initialValues = { @@ -74,15 +80,26 @@ export const TagEditPanel: React.FC = ({ child_ids: (tag?.children ?? []).map((t) => t.id), ignore_auto_tag: tag?.ignore_auto_tag ?? false, stash_ids: getStashIDs(tag?.stash_ids), + custom_fields: cloneDeep(tag?.custom_fields ?? {}), }; type InputValues = yup.InferType; + const [customFieldsError, setCustomFieldsError] = useState(); + + function submit(values: InputValues) { + const input = { + ...schema.cast(values), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), + }; + onSave(input); + } + const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), - onSubmit: (values) => onSave(schema.cast(values)), + onSubmit: submit, }); function onSetParentTags(items: Tag[]) { @@ -134,7 +151,10 @@ export const TagEditPanel: React.FC = ({ } async function onSaveAndNewClick() { - const input = schema.cast(formik.values); + const input = { + ...schema.cast(formik.values), + custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields), + }; onSave(input, true); } @@ -266,6 +286,14 @@ export const TagEditPanel: React.FC = ({ )} + + formik.setFieldValue("custom_fields", v)} + error={customFieldsError} + setError={(e) => setCustomFieldsError(e)} + /> +
{renderInputField("ignore_auto_tag", "checkbox")} @@ -279,7 +307,9 @@ export const TagEditPanel: React.FC = ({ onSave={formik.handleSubmit} onSaveAndNew={isNew ? onSaveAndNewClick : undefined} saveDisabled={ - (!isNew && !formik.dirty) || !isEqual(formik.errors, {}) + (!isNew && !formik.dirty) || + !isEqual(formik.errors, {}) || + customFieldsError !== undefined } onImageChange={onImageChange} onImageChangeURL={onImageLoad} diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 27186d6e1..535beed65 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -166,6 +166,14 @@ export const queryFindScenesByID = (sceneIDs: number[]) => }, }); +export const queryFindFullScenesByID = (sceneIDs: number[]) => + client.query({ + query: GQL.FindFullScenesDocument, + variables: { + ids: sceneIDs, + }, + }); + export const queryFindScenesForSelect = (filter: ListFilterModel) => client.query({ query: GQL.FindScenesForSelectDocument, diff --git a/ui/v2.5/src/models/list-filter/galleries.ts b/ui/v2.5/src/models/list-filter/galleries.ts index 3d4d40a1c..adac37e3c 100644 --- a/ui/v2.5/src/models/list-filter/galleries.ts +++ b/ui/v2.5/src/models/list-filter/galleries.ts @@ -21,6 +21,7 @@ import { ListFilterOptions, MediaSortByOptions } from "./filter-options"; import { DisplayMode } from "./types"; import { RatingCriterionOption } from "./criteria/rating"; import { PathCriterionOption } from "./criteria/path"; +import { CustomFieldsCriterionOption } from "./criteria/custom-fields"; const defaultSortBy = "path"; @@ -71,6 +72,7 @@ const criterionOptions = [ createDateCriterionOption("date"), createMandatoryTimestampCriterionOption("created_at"), createMandatoryTimestampCriterionOption("updated_at"), + CustomFieldsCriterionOption, ]; export const GalleryListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/groups.ts b/ui/v2.5/src/models/list-filter/groups.ts index ee0c90d73..9c5b3f2d4 100644 --- a/ui/v2.5/src/models/list-filter/groups.ts +++ b/ui/v2.5/src/models/list-filter/groups.ts @@ -17,6 +17,7 @@ import { ContainingGroupsCriterionOption, SubGroupsCriterionOption, } from "./criteria/groups"; +import { CustomFieldsCriterionOption } from "./criteria/custom-fields"; const defaultSortBy = "name"; @@ -66,6 +67,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("scene_count"), createMandatoryTimestampCriterionOption("created_at"), createMandatoryTimestampCriterionOption("updated_at"), + CustomFieldsCriterionOption, ]; export const GroupListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/images.ts b/ui/v2.5/src/models/list-filter/images.ts index 9468e5eaf..eabcbfd26 100644 --- a/ui/v2.5/src/models/list-filter/images.ts +++ b/ui/v2.5/src/models/list-filter/images.ts @@ -23,6 +23,7 @@ import { ListFilterOptions, MediaSortByOptions } from "./filter-options"; import { DisplayMode } from "./types"; import { GalleriesCriterionOption } from "./criteria/galleries"; import { PhashCriterionOption } from "./criteria/phash"; +import { CustomFieldsCriterionOption } from "./criteria/custom-fields"; const defaultSortBy = "path"; @@ -73,6 +74,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("file_count"), createMandatoryTimestampCriterionOption("created_at"), createMandatoryTimestampCriterionOption("updated_at"), + CustomFieldsCriterionOption, ]; export const ImageListFilterOptions = new ListFilterOptions( defaultSortBy, diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index f4f93deeb..c0e4a75a1 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -35,6 +35,7 @@ import { StashIDCriterionOption } from "./criteria/stash-ids"; import { RatingCriterionOption } from "./criteria/rating"; import { PathCriterionOption } from "./criteria/path"; import { OrientationCriterionOption } from "./criteria/orientation"; +import { CustomFieldsCriterionOption } from "./criteria/custom-fields"; const defaultSortBy = "date"; const sortByOptions = [ @@ -141,6 +142,7 @@ const criterionOptions = [ createDateCriterionOption("date"), createMandatoryTimestampCriterionOption("created_at"), createMandatoryTimestampCriterionOption("updated_at"), + CustomFieldsCriterionOption, ]; export const SceneListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/studios.ts b/ui/v2.5/src/models/list-filter/studios.ts index a38540a47..e62d41c7a 100644 --- a/ui/v2.5/src/models/list-filter/studios.ts +++ b/ui/v2.5/src/models/list-filter/studios.ts @@ -13,6 +13,7 @@ import { ParentStudiosCriterionOption } from "./criteria/studios"; import { TagsCriterionOption } from "./criteria/tags"; import { ListFilterOptions } from "./filter-options"; import { DisplayMode } from "./types"; +import { CustomFieldsCriterionOption } from "./criteria/custom-fields"; const defaultSortBy = "name"; const sortByOptions = [ @@ -67,6 +68,7 @@ const criterionOptions = [ ), createMandatoryTimestampCriterionOption("created_at"), createMandatoryTimestampCriterionOption("updated_at"), + CustomFieldsCriterionOption, ]; export const StudioListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/tags.ts b/ui/v2.5/src/models/list-filter/tags.ts index 39ce9ca39..4c8bed69f 100644 --- a/ui/v2.5/src/models/list-filter/tags.ts +++ b/ui/v2.5/src/models/list-filter/tags.ts @@ -15,6 +15,7 @@ import { } from "./criteria/tags"; import { FavoriteTagCriterionOption } from "./criteria/favorite"; import { StashIDCriterionOption } from "./criteria/stash-ids"; +import { CustomFieldsCriterionOption } from "./criteria/custom-fields"; const defaultSortBy = "name"; const sortByOptions = ["name", "random", "scenes_duration"] @@ -77,6 +78,7 @@ const criterionOptions = [ new MandatoryNumberCriterionOption("sub_tag_count", "child_count"), createMandatoryTimestampCriterionOption("created_at"), createMandatoryTimestampCriterionOption("updated_at"), + CustomFieldsCriterionOption, ]; export const TagListFilterOptions = new ListFilterOptions( From c9f0dba62f2dd4ecdb03ac80e5cca9cc65207696 Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:54:12 +0200 Subject: [PATCH 087/177] Fix capitalization in custom localisation heading [skip-ci] (#6606) --- ui/v2.5/src/locales/en-GB.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index b7d3e2894..e19f9ca8f 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -669,7 +669,7 @@ }, "custom_locales": { "description": "Override individual locale strings. See https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json for the master list. Page must be reloaded for changes to take effect.", - "heading": "Custom Localisation", + "heading": "Custom localisation", "option_label": "Custom localisation enabled" }, "custom_title": { From 5734ee43ff788d12c2caa604de2bfa18358fb269 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 26 Feb 2026 07:54:40 +1100 Subject: [PATCH 088/177] Add sidebar to scene markers list (#6603) * Add tag markers filter * Add marker count and markers filter to performer filter * Add sidebar to marker list --- graphql/schema/types/filters.graphql | 6 + pkg/models/performer.go | 4 + pkg/models/tag.go | 2 + pkg/sqlite/criterion_handlers.go | 13 +- pkg/sqlite/performer_filter.go | 27 + pkg/sqlite/tag_filter.go | 14 + .../List/Filters/LabeledIdFilter.tsx | 19 + .../src/components/Scenes/SceneMarkerList.tsx | 538 ++++++++++++++---- .../Tags/TagDetails/TagMarkersPanel.tsx | 4 +- 9 files changed, 504 insertions(+), 123 deletions(-) diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 907e597f4..d9814ef34 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -177,6 +177,8 @@ input PerformerFilterType { tag_count: IntCriterionInput "Filter by scene count" scene_count: IntCriterionInput + "Filter by marker count (via scene)" + marker_count: IntCriterionInput "Filter by image count" image_count: IntCriterionInput "Filter by gallery count" @@ -220,6 +222,8 @@ input PerformerFilterType { galleries_filter: GalleryFilterType "Filter by related tags that meet this criteria" tags_filter: TagFilterType + "Filter by related scene markers (via scene) that meet this criteria" + markers_filter: SceneMarkerFilterType "Filter by creation time" created_at: TimestampCriterionInput "Filter by last update time" @@ -684,6 +688,8 @@ input TagFilterType { performers_filter: PerformerFilterType "Filter by related studios that meet this criteria" studios_filter: StudioFilterType + "Filter by related scene markers that meet this criteria" + markers_filter: SceneMarkerFilterType "Filter by creation time" created_at: TimestampCriterionInput diff --git a/pkg/models/performer.go b/pkg/models/performer.go index e4fb8dd98..8de5d94f4 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -158,6 +158,8 @@ type PerformerFilterType struct { TagCount *IntCriterionInput `json:"tag_count"` // Filter by scene count SceneCount *IntCriterionInput `json:"scene_count"` + // Filter by scene marker count (via scene) + MarkerCount *IntCriterionInput `json:"marker_count"` // Filter by image count ImageCount *IntCriterionInput `json:"image_count"` // Filter by gallery count @@ -202,6 +204,8 @@ type PerformerFilterType struct { GalleriesFilter *GalleryFilterType `json:"galleries_filter"` // Filter by related tags that meet this criteria TagsFilter *TagFilterType `json:"tags_filter"` + // Filter by related scene markers (via scene) that meet this criteria + MarkersFilter *SceneMarkerFilterType `json:"markers_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at diff --git a/pkg/models/tag.go b/pkg/models/tag.go index 3a133dcad..b166e5a69 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -56,6 +56,8 @@ type TagFilterType struct { PerformersFilter *PerformerFilterType `json:"performers_filter"` // Filter by related studios that meet this criteria StudiosFilter *StudioFilterType `json:"studios_filter"` + // Filter by related scene markers that meet this criteria + MarkersFilter *SceneMarkerFilterType `json:"markers_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at diff --git a/pkg/sqlite/criterion_handlers.go b/pkg/sqlite/criterion_handlers.go index 1496df71d..943704cfe 100644 --- a/pkg/sqlite/criterion_handlers.go +++ b/pkg/sqlite/criterion_handlers.go @@ -1089,11 +1089,16 @@ func (h *stashIDsCriterionHandler) handle(ctx context.Context, f *filterBuilder) } type relatedFilterHandler struct { - relatedIDCol string - relatedRepo repository + // column on the primary table that relates to the related table (eg scene_id) + relatedIDCol string + // repository for the related table (eg sceneRepository) + relatedRepo repository + // handler for the filter on the related table relatedHandler criterionHandler - joinFn func(f *filterBuilder) - directJoin bool + // optional function to perform the necessary join(s) to the related table + joinFn func(f *filterBuilder) + // if true, related filter handler will be run using the existing filterBuilder instead of a subquery. + directJoin bool } func (h *relatedFilterHandler) handle(ctx context.Context, f *filterBuilder) { diff --git a/pkg/sqlite/performer_filter.go b/pkg/sqlite/performer_filter.go index 5296d5a25..e99f3068f 100644 --- a/pkg/sqlite/performer_filter.go +++ b/pkg/sqlite/performer_filter.go @@ -195,6 +195,7 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler { qb.tagCountCriterionHandler(filter.TagCount), qb.sceneCountCriterionHandler(filter.SceneCount), + qb.markerCountCriterionHandler(filter.MarkerCount), qb.imageCountCriterionHandler(filter.ImageCount), qb.galleryCountCriterionHandler(filter.GalleryCount), qb.playCounterCriterionHandler(filter.PlayCount), @@ -204,6 +205,16 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler { ×tampCriterionHandler{filter.CreatedAt, tableName + ".created_at", nil}, ×tampCriterionHandler{filter.UpdatedAt, tableName + ".updated_at", nil}, + &relatedFilterHandler{ + relatedIDCol: "scene_markers.id", + relatedRepo: sceneMarkerRepository.repository, + relatedHandler: &sceneMarkerFilterHandler{filter.MarkersFilter}, + joinFn: func(f *filterBuilder) { + performerRepository.scenes.innerJoin(f, "", "performers.id") + f.addInnerJoin(sceneMarkerTable, "", "scene_markers.scene_id = performers_scenes.scene_id") + }, + }, + &relatedFilterHandler{ relatedIDCol: "performers_scenes.scene_id", relatedRepo: sceneRepository.repository, @@ -387,6 +398,22 @@ func (qb *performerFilterHandler) sceneCountCriterionHandler(count *models.IntCr return h.handler(count) } +func (qb *performerFilterHandler) markerCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if count != nil { + performerRepository.scenes.innerJoin(f, "", "performers.id") + + const query = `(SELECT COUNT(*) FROM scene_markers + INNER JOIN scenes ON scene_markers.scene_id = scenes.id + INNER JOIN performers_scenes ON performers_scenes.scene_id = scenes.id + WHERE performers_scenes.performer_id = performers.id)` + + clause, args := getIntCriterionWhereClause(query, *count) + f.addWhere(clause, args...) + } + } +} + func (qb *performerFilterHandler) imageCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: performerTable, diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index b3a7c1756..4e2313080 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -161,6 +161,20 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler { tagRepository.studios.innerJoin(f, "", "tags.id") }, }, + + &relatedFilterHandler{ + relatedIDCol: "markers_tags.marker_id", + relatedRepo: sceneMarkerRepository.repository, + relatedHandler: &sceneMarkerFilterHandler{tagFilter.MarkersFilter}, + joinFn: func(f *filterBuilder) { + f.addWith(`markers_tags AS ( + SELECT mt.scene_marker_id AS marker_id, mt.tag_id AS tag_id FROM scene_markers_tags mt + UNION + SELECT m.id, m.primary_tag_id FROM scene_markers m + )`) + f.addInnerJoin("markers_tags", "", "markers_tags.tag_id = tags.id") + }, + }, } } diff --git a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx index a9163578f..f19472d64 100644 --- a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx @@ -24,6 +24,7 @@ import { IntCriterionInput, PerformerFilterType, SceneFilterType, + SceneMarkerFilterType, StudioFilterType, } from "src/core/generated-graphql"; import { useIntl } from "react-intl"; @@ -527,6 +528,8 @@ interface IFilterType { group_count?: InputMaybe; studios_filter?: InputMaybe; studio_count?: InputMaybe; + marker_count?: InputMaybe; + markers_filter?: InputMaybe; } export function setObjectFilter( @@ -549,6 +552,7 @@ export function setObjectFilter( modifier: CriterionModifier.GreaterThan, value: 0, }; + break; } out.scenes_filter = relatedFilterOutput as SceneFilterType; break; @@ -559,6 +563,7 @@ export function setObjectFilter( modifier: CriterionModifier.GreaterThan, value: 0, }; + break; } out.performers_filter = relatedFilterOutput as PerformerFilterType; break; @@ -569,6 +574,7 @@ export function setObjectFilter( modifier: CriterionModifier.GreaterThan, value: 0, }; + break; } out.galleries_filter = relatedFilterOutput as GalleryFilterType; break; @@ -579,6 +585,7 @@ export function setObjectFilter( modifier: CriterionModifier.GreaterThan, value: 0, }; + break; } out.groups_filter = relatedFilterOutput as GroupFilterType; break; @@ -589,9 +596,21 @@ export function setObjectFilter( modifier: CriterionModifier.GreaterThan, value: 0, }; + break; } out.studios_filter = relatedFilterOutput as StudioFilterType; break; + case FilterMode.SceneMarkers: + // if empty, only get objects with scene markers + if (empty) { + out.marker_count = { + modifier: CriterionModifier.GreaterThan, + value: 0, + }; + break; + } + out.markers_filter = relatedFilterOutput as SceneMarkerFilterType; + break; default: throw new Error("Invalid filter mode"); } diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx index b5975ca5a..781a3f0b2 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx @@ -1,7 +1,7 @@ import cloneDeep from "lodash-es/cloneDeep"; -import React from "react"; +import React, { useCallback, useEffect } from "react"; import { useHistory } from "react-router-dom"; -import { useIntl } from "react-intl"; +import { FormattedMessage, useIntl } from "react-intl"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { @@ -9,7 +9,7 @@ import { useFindSceneMarkers, } from "src/core/StashService"; import NavUtils from "src/utils/navigation"; -import { ItemList, ItemListContext } from "../List/ItemList"; +import { useFilteredItemList } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { MarkerWallPanel } from "./SceneMarkerWallPanel"; @@ -17,17 +17,179 @@ import { View } from "../List/views"; import { SceneMarkerCardGrid } from "./SceneMarkerCardGrid"; import { DeleteSceneMarkersDialog } from "./DeleteSceneMarkersDialog"; import { EditSceneMarkersDialog } from "./EditSceneMarkersDialog"; -import { PatchComponent } from "src/patch"; -import { IItemListOperation } from "../List/FilteredListToolbar"; +import { PatchComponent, PatchContainerComponent } from "src/patch"; +import { + FilteredListToolbar, + IItemListOperation, +} from "../List/FilteredListToolbar"; +import { + Sidebar, + SidebarPane, + SidebarPaneContent, + SidebarStateContext, + useSidebarState, +} from "../Shared/Sidebar"; +import { useCloseEditDelete, useFilterOperations } from "../List/util"; +import { + FilteredSidebarHeader, + useFilteredSidebarKeybinds, +} from "../List/Filters/FilterSidebar"; +import { useZoomKeybinds } from "../List/ZoomSlider"; +import { + IListFilterOperation, + ListOperations, +} from "../List/ListOperationButtons"; +import cx from "classnames"; +import { FilterTags } from "../List/FilterTags"; +import { Pagination, PaginationIndex } from "../List/Pagination"; +import { LoadedContent } from "../List/PagedList"; +import useFocus from "src/utils/focus"; +import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter"; +import { SidebarTagsFilter } from "../List/Filters/TagsFilter"; +import { Button } from "react-bootstrap"; -function getItems(result: GQL.FindSceneMarkersQueryResult) { - return result?.data?.findSceneMarkers?.scene_markers ?? []; +const SceneMarkerList: React.FC<{ + markers: GQL.SceneMarkerDataFragment[]; + filter: ListFilterModel; + selectedIds: Set; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; +}> = PatchComponent( + "SceneList", + ({ markers, filter, selectedIds, onSelectChange }) => { + if (markers.length === 0) { + return null; + } + + if (filter.displayMode === DisplayMode.Wall) { + return ( + + ); + } + + if (filter.displayMode === DisplayMode.Grid) { + return ( + + ); + } + + return null; + } +); + +function usePlayRandom(filter: ListFilterModel, count: number) { + const history = useHistory(); + + const playRandom = useCallback(async () => { + // query for a random scene + if (count === 0) { + return; + } + + const pages = Math.ceil(count / filter.itemsPerPage); + const page = Math.floor(Math.random() * pages) + 1; + + const indexMax = Math.min(filter.itemsPerPage, count); + const index = Math.floor(Math.random() * indexMax); + const filterCopy = cloneDeep(filter); + filterCopy.currentPage = page; + filterCopy.sortBy = "random"; + const queryResults = await queryFindSceneMarkers(filterCopy); + const marker = queryResults.data.findSceneMarkers.scene_markers[index]; + if (marker) { + // navigate to the scene player page + const url = NavUtils.makeSceneMarkerUrl(marker); + history.push(url); + } + }, [filter, count, history]); + + return playRandom; } -function getCount(result: GQL.FindSceneMarkersQueryResult) { - return result?.data?.findSceneMarkers?.count ?? 0; +function useAddKeybinds(filter: ListFilterModel, count: number) { + const playRandom = usePlayRandom(filter, count); + + useEffect(() => { + Mousetrap.bind("p r", () => { + playRandom(); + }); + + return () => { + Mousetrap.unbind("p r"); + }; + }, [playRandom]); } +const ScenesFilterSidebarSections = PatchContainerComponent( + "FilteredSceneMarkerList.SidebarSections" +); + +const SidebarContent: React.FC<{ + filter: ListFilterModel; + setFilter: (filter: ListFilterModel) => void; + filterHook?: (filter: ListFilterModel) => ListFilterModel; + view?: View; + sidebarOpen: boolean; + onClose?: () => void; + showEditFilter: (editingCriterion?: string) => void; + count?: number; + focus?: ReturnType; +}> = ({ + filter, + setFilter, + filterHook, + view, + showEditFilter, + sidebarOpen, + onClose, + count, + focus, +}) => { + const showResultsId = + count !== undefined ? "actions.show_count_results" : "actions.show_results"; + + return ( + <> + + + + + + + +
+ +
+ + ); +}; + interface ISceneMarkerList { filterHook?: (filter: ListFilterModel) => ListFilterModel; view?: View; @@ -36,132 +198,274 @@ interface ISceneMarkerList { extraOperations?: IItemListOperation[]; } -export const SceneMarkerList: React.FC = PatchComponent( - "SceneMarkerList", - ({ filterHook, view, alterQuery, extraOperations = [] }) => { +export const FilteredSceneMarkerList = PatchComponent( + "FilteredSceneMarkerList", + (props: ISceneMarkerList) => { const intl = useIntl(); - const history = useHistory(); - const filterMode = GQL.FilterMode.SceneMarkers; + const searchFocus = useFocus(); - const otherOperations = [ - ...extraOperations, - { - text: intl.formatMessage({ id: "actions.play_random" }), - onClick: playRandom, - }, - ]; + const { + filterHook, + defaultSort, + view, + alterQuery, + extraOperations = [], + } = props; - function addKeybinds( - result: GQL.FindSceneMarkersQueryResult, - filter: ListFilterModel - ) { - Mousetrap.bind("p r", () => { - playRandom(result, filter); + // States + const { + showSidebar, + setShowSidebar, + loading: sidebarStateLoading, + sectionOpen, + setSectionOpen, + } = useSidebarState(view); + + const { filterState, queryResult, modalState, listSelect, showEditFilter } = + useFilteredItemList({ + filterStateProps: { + filterMode: GQL.FilterMode.SceneMarkers, + defaultSort, + view, + useURL: alterQuery, + }, + queryResultProps: { + useResult: useFindSceneMarkers, + getCount: (r) => r.data?.findSceneMarkers.count ?? 0, + getItems: (r) => r.data?.findSceneMarkers.scene_markers ?? [], + filterHook, + }, + }); + + const { filter, setFilter } = filterState; + + const { effectiveFilter, result, cachedResult, items, totalCount } = + queryResult; + + const { + selectedIds, + selectedItems, + onSelectChange, + onSelectAll, + onSelectNone, + onInvertSelection, + hasSelection, + } = listSelect; + + const { modal, showModal, closeModal } = modalState; + + // Utility hooks + const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({ + filter, + setFilter, + }); + + useAddKeybinds(filter, totalCount); + useFilteredSidebarKeybinds({ + showSidebar, + setShowSidebar, + }); + + const onCloseEditDelete = useCloseEditDelete({ + closeModal, + onSelectNone, + result, + }); + + const onEdit = useCallback(() => { + showModal( + + ); + }, [showModal, selectedItems, onCloseEditDelete]); + + const onDelete = useCallback(() => { + showModal( + + ); + }, [showModal, selectedItems, onCloseEditDelete]); + + useEffect(() => { + Mousetrap.bind("e", () => { + if (hasSelection) { + onEdit?.(); + } + }); + + Mousetrap.bind("d d", () => { + if (hasSelection) { + onDelete?.(); + } }); return () => { - Mousetrap.unbind("p r"); + Mousetrap.unbind("e"); + Mousetrap.unbind("d d"); }; - } + }, [onSelectAll, onSelectNone, hasSelection, onEdit, onDelete]); - async function playRandom( - result: GQL.FindSceneMarkersQueryResult, - filter: ListFilterModel - ) { - // query for a random scene - if (result.data?.findSceneMarkers) { - const { count } = result.data.findSceneMarkers; + useZoomKeybinds({ + zoomIndex: filter.zoomIndex, + onChangeZoom: (zoom) => setFilter(filter.setZoom(zoom)), + }); - const index = Math.floor(Math.random() * count); - const filterCopy = cloneDeep(filter); - filterCopy.itemsPerPage = 1; - filterCopy.currentPage = index + 1; - const singleResult = await queryFindSceneMarkers(filterCopy); - if (singleResult.data.findSceneMarkers.scene_markers.length === 1) { - // navigate to the scene player page - const url = NavUtils.makeSceneMarkerUrl( - singleResult.data.findSceneMarkers.scene_markers[0] - ); - history.push(url); - } - } - } + const playRandom = usePlayRandom(effectiveFilter, totalCount); - function renderContent( - result: GQL.FindSceneMarkersQueryResult, - filter: ListFilterModel, - selectedIds: Set, - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void - ) { - if (!result.data?.findSceneMarkers) return; + const convertedExtraOperations: IListFilterOperation[] = + extraOperations.map((o) => ({ + ...o, + isDisplayed: o.isDisplayed + ? () => o.isDisplayed!(result, filter, selectedIds) + : undefined, + onClick: () => { + o.onClick(result, filter, selectedIds); + }, + })); - if (filter.displayMode === DisplayMode.Wall) { - return ( - - ); - } + const otherOperations = [ + ...convertedExtraOperations, + { + text: intl.formatMessage({ id: "actions.select_all" }), + onClick: () => onSelectAll(), + isDisplayed: () => totalCount > 0, + }, + { + text: intl.formatMessage({ id: "actions.select_none" }), + onClick: () => onSelectNone(), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.invert_selection" }), + onClick: () => onInvertSelection(), + isDisplayed: () => totalCount > 0, + }, + { + text: intl.formatMessage({ id: "actions.play_random" }), + onClick: playRandom, + isDisplayed: () => totalCount > 1, + }, + // { + // text: `${intl.formatMessage({ id: "actions.generate" })}…`, + // onClick: () => + // showModal( + // closeModal()} + // /> + // ), + // isDisplayed: () => hasSelection, + // }, + ]; - if (filter.displayMode === DisplayMode.Grid) { - return ( - - ); - } - } + // render + if (sidebarStateLoading) return null; - function renderEditDialog( - selectedMarkers: GQL.SceneMarkerDataFragment[], - onClose: (applied: boolean) => void - ) { - return ( - - ); - } - - function renderDeleteDialog( - selectedSceneMarkers: GQL.SceneMarkerDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ( - - ); - } + const operations = ( + + ); return ( - - - + {modal} + + + + setShowSidebar(false)}> + setShowSidebar(false)} + count={cachedResult.loading ? undefined : totalCount} + focus={searchFocus} + /> + + setShowSidebar(!showSidebar)} + > + + + showEditFilter(c.criterionOption.type)} + onRemoveCriterion={removeCriterion} + onRemoveAll={clearAllCriteria} + /> + +
+ setFilter(filter.changePage(page))} + /> + +
+ + + + + + {totalCount > filter.itemsPerPage && ( +
+
+ +
+
+ )} +
+
+
+
); } ); -export default SceneMarkerList; +export default FilteredSceneMarkerList; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx index f32f26497..63b906c80 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx @@ -5,7 +5,7 @@ import { TagsCriterion, TagsCriterionOption, } from "src/models/list-filter/criteria/tags"; -import { SceneMarkerList } from "src/components/Scenes/SceneMarkerList"; +import { FilteredSceneMarkerList } from "src/components/Scenes/SceneMarkerList"; import { View } from "src/components/List/views"; function useFilterHook(tag: GQL.TagDataFragment, showSubTagContent?: boolean) { @@ -60,7 +60,7 @@ export const TagMarkersPanel: React.FC = ({ const filterHook = useFilterHook(tag, showSubTagContent); return ( - Date: Thu, 26 Feb 2026 07:55:26 +1100 Subject: [PATCH 089/177] Show unsupported filter criteria in filter tags (#6604) * Show unsupported filter criteria in filter tags Shows a warning coloured filter tag, with warning icon and text " (unsupported) ...". Cannot be edited, can only be removed. Won't be saved to saved filters. * Generalise filtered recommendation rows. Include warning popover for unsupported criteria --- .../FrontPage/FilteredRecommendationRow.tsx | 79 +++++++++++++++++++ .../FrontPage/RecommendationRow.tsx | 2 +- .../Galleries/GalleryRecommendationRow.tsx | 55 +++++-------- .../Groups/GroupRecommendationRow.tsx | 52 ++++-------- .../Images/ImageRecommendationRow.tsx | 52 ++++-------- ui/v2.5/src/components/List/FilterTags.tsx | 29 ++++++- ui/v2.5/src/components/List/styles.scss | 4 + .../Performers/PerformerRecommendationRow.tsx | 55 +++++-------- .../Scenes/SceneMarkerRecommendationRow.tsx | 67 ++++++---------- .../Scenes/SceneRecommendationRow.tsx | 64 ++++++--------- .../src/components/Shared/HoverPopover.tsx | 19 +++++ ui/v2.5/src/components/Shared/styles.scss | 13 +++ .../Studios/StudioRecommendationRow.tsx | 55 +++++-------- .../components/Tags/TagRecommendationRow.tsx | 49 ++++-------- ui/v2.5/src/locales/en-GB.json | 2 + .../models/list-filter/criteria/criterion.ts | 51 ++++++++++++ ui/v2.5/src/models/list-filter/filter.ts | 4 +- 17 files changed, 355 insertions(+), 297 deletions(-) create mode 100644 ui/v2.5/src/components/FrontPage/FilteredRecommendationRow.tsx diff --git a/ui/v2.5/src/components/FrontPage/FilteredRecommendationRow.tsx b/ui/v2.5/src/components/FrontPage/FilteredRecommendationRow.tsx new file mode 100644 index 000000000..8cf27a625 --- /dev/null +++ b/ui/v2.5/src/components/FrontPage/FilteredRecommendationRow.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import Slider from "@ant-design/react-slick"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { getSlickSliderSettings } from "src/core/recommendations"; +import { RecommendationRow } from "../FrontPage/RecommendationRow"; +import { FormattedMessage } from "react-intl"; +import { PatchComponent } from "src/patch"; +import { UnsupportedCriterion } from "src/models/list-filter/criteria/criterion"; +import { PopoverCard, WarningHoverPopover } from "../Shared/HoverPopover"; + +interface IProps { + className?: string; + isTouch: boolean; + filter: ListFilterModel; + heading: string; + count: number; + loading: boolean; + url: string; +} + +export const FilteredRecommendationRow: React.FC = PatchComponent( + "FilteredRecommendationRow", + (props) => { + const cardCount = props.count; + + const unsupportedCriteria = props.filter.criteria.filter( + (criterion) => criterion instanceof UnsupportedCriterion + ); + + const header = unsupportedCriteria.length ? ( +
+ {props.heading} + + c.criterionOption.type) + .join(", "), + }} + /> + + } + /> +
+ ) : ( + props.heading + ); + + if (!props.loading && !cardCount) { + return null; + } + + return ( + + + + } + > + + {props.children} + + + ); + } +); diff --git a/ui/v2.5/src/components/FrontPage/RecommendationRow.tsx b/ui/v2.5/src/components/FrontPage/RecommendationRow.tsx index 115d8642a..97e43f294 100644 --- a/ui/v2.5/src/components/FrontPage/RecommendationRow.tsx +++ b/ui/v2.5/src/components/FrontPage/RecommendationRow.tsx @@ -3,7 +3,7 @@ import { PatchComponent } from "src/patch"; interface IProps { className?: string; - header: string; + header: React.ReactNode; link: JSX.Element; } diff --git a/ui/v2.5/src/components/Galleries/GalleryRecommendationRow.tsx b/ui/v2.5/src/components/Galleries/GalleryRecommendationRow.tsx index b56b48c36..3df07b643 100644 --- a/ui/v2.5/src/components/Galleries/GalleryRecommendationRow.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryRecommendationRow.tsx @@ -1,13 +1,9 @@ import React from "react"; -import { Link } from "react-router-dom"; import { useFindGalleries } from "src/core/StashService"; -import Slider from "@ant-design/react-slick"; import { GalleryCard } from "./GalleryCard"; import { ListFilterModel } from "src/models/list-filter/filter"; -import { getSlickSliderSettings } from "src/core/recommendations"; -import { RecommendationRow } from "../FrontPage/RecommendationRow"; -import { FormattedMessage } from "react-intl"; import { PatchComponent } from "src/patch"; +import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow"; interface IProps { isTouch: boolean; @@ -19,40 +15,29 @@ export const GalleryRecommendationRow: React.FC = PatchComponent( "GalleryRecommendationRow", (props) => { const result = useFindGalleries(props.filter); - const cardCount = result.data?.findGalleries.count; - - if (!result.loading && !cardCount) { - return null; - } + const count = result.data?.findGalleries.count ?? 0; return ( - - - - } + heading={props.header} + url={`/galleries?${props.filter.makeQueryParameters()}`} + count={count} + loading={result.loading} + isTouch={props.isTouch} + filter={props.filter} > - - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
- )) - : result.data?.findGalleries.galleries.map((g) => ( - - ))} -
-
+ {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findGalleries.galleries.map((g) => ( + + ))} + ); } ); diff --git a/ui/v2.5/src/components/Groups/GroupRecommendationRow.tsx b/ui/v2.5/src/components/Groups/GroupRecommendationRow.tsx index 228cb3467..b9e523b34 100644 --- a/ui/v2.5/src/components/Groups/GroupRecommendationRow.tsx +++ b/ui/v2.5/src/components/Groups/GroupRecommendationRow.tsx @@ -1,13 +1,9 @@ import React from "react"; -import { Link } from "react-router-dom"; import { useFindGroups } from "src/core/StashService"; -import Slider from "@ant-design/react-slick"; import { GroupCard } from "./GroupCard"; import { ListFilterModel } from "src/models/list-filter/filter"; -import { getSlickSliderSettings } from "src/core/recommendations"; -import { RecommendationRow } from "../FrontPage/RecommendationRow"; -import { FormattedMessage } from "react-intl"; import { PatchComponent } from "src/patch"; +import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow"; interface IProps { isTouch: boolean; @@ -19,40 +15,26 @@ export const GroupRecommendationRow: React.FC = PatchComponent( "GroupRecommendationRow", (props: IProps) => { const result = useFindGroups(props.filter); - const cardCount = result.data?.findGroups.count; - - if (!result.loading && !cardCount) { - return null; - } + const count = result.data?.findGroups.count ?? 0; return ( - - - - } + heading={props.header} + url={`/groups?${props.filter.makeQueryParameters()}`} + count={count} + loading={result.loading} + isTouch={props.isTouch} + filter={props.filter} > - - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
- )) - : result.data?.findGroups.groups.map((g) => ( - - ))} -
-
+ {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findGroups.groups.map((g) => ( + + ))} + ); } ); diff --git a/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx b/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx index 6499be894..0541e5934 100644 --- a/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx +++ b/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx @@ -1,13 +1,9 @@ import React from "react"; -import { Link } from "react-router-dom"; import { useFindImages } from "src/core/StashService"; -import Slider from "@ant-design/react-slick"; import { ListFilterModel } from "src/models/list-filter/filter"; -import { getSlickSliderSettings } from "src/core/recommendations"; -import { RecommendationRow } from "../FrontPage/RecommendationRow"; -import { FormattedMessage } from "react-intl"; import { ImageCard } from "./ImageCard"; import { PatchComponent } from "src/patch"; +import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow"; interface IProps { isTouch: boolean; @@ -19,40 +15,26 @@ export const ImageRecommendationRow: React.FC = PatchComponent( "ImageRecommendationRow", (props: IProps) => { const result = useFindImages(props.filter); - const cardCount = result.data?.findImages.count; - - if (!result.loading && !cardCount) { - return null; - } + const count = result.data?.findImages.count ?? 0; return ( - - - - } + heading={props.header} + url={`/images?${props.filter.makeQueryParameters()}`} + count={count} + loading={result.loading} + isTouch={props.isTouch} + filter={props.filter} > - - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
- )) - : result.data?.findImages.images.map((i) => ( - - ))} -
-
+ {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findImages.images.map((i) => ( + + ))} + ); } ); diff --git a/ui/v2.5/src/components/List/FilterTags.tsx b/ui/v2.5/src/components/List/FilterTags.tsx index 5597cae79..28c9f77fa 100644 --- a/ui/v2.5/src/components/List/FilterTags.tsx +++ b/ui/v2.5/src/components/List/FilterTags.tsx @@ -6,10 +6,17 @@ import React, { useRef, } from "react"; import { Badge, BadgeProps, Button, Overlay, Popover } from "react-bootstrap"; -import { Criterion } from "src/models/list-filter/criteria/criterion"; +import { + Criterion, + UnsupportedCriterion, +} from "src/models/list-filter/criteria/criterion"; import { FormattedMessage, useIntl } from "react-intl"; import { Icon } from "../Shared/Icon"; -import { faMagnifyingGlass, faTimes } from "@fortawesome/free-solid-svg-icons"; +import { + faExclamationTriangle, + faMagnifyingGlass, + faTimes, +} from "@fortawesome/free-solid-svg-icons"; import { BsPrefixProps, ReplaceProps } from "react-bootstrap/esm/helpers"; import { CustomFieldsCriterion } from "src/models/list-filter/criteria/custom-fields"; import { useDebounce } from "src/hooks/debounce"; @@ -38,9 +45,20 @@ export const FilterTag: React.FC<{ label: React.ReactNode; onClick: React.MouseEventHandler; onRemove: React.MouseEventHandler; -}> = ({ className, label, onClick, onRemove }) => { + unsupported?: boolean; +}> = ({ className, label, onClick, onRemove, unsupported }) => { + function handleClick(e: React.MouseEvent) { + if (unsupported) { + return; + } + onClick(e); + } + return ( - + + {unsupported && ( + + )} {label} +
+ + ); +}; + +function useViewRandom(filter: ListFilterModel, count: number) { + const history = useHistory(); + + const viewRandom = useCallback(async () => { + // query for a random image + if (count === 0) { + return; + } + + const index = Math.floor(Math.random() * count); + const filterCopy = cloneDeep(filter); + filterCopy.itemsPerPage = 1; + filterCopy.currentPage = index + 1; + const singleResult = await queryFindImages(filterCopy); + if (singleResult.data.findImages.images.length === 1) { + const { id } = singleResult.data.findImages.images[0]; + // navigate to the image player page + history.push(`/images/${id}`); + } + }, [history, filter, count]); + + return viewRandom; +} + +function useAddKeybinds(filter: ListFilterModel, count: number) { + const viewRandom = useViewRandom(filter, count); + + useEffect(() => { + Mousetrap.bind("p r", () => { + viewRandom(); + }); + + return () => { + Mousetrap.unbind("p r"); + }; + }, [viewRandom]); +} + interface IImageList { filterHook?: (filter: ListFilterModel) => ListFilterModel; view?: View; @@ -347,28 +502,185 @@ interface IImageList { chapters?: GQL.GalleryChapterDataFragment[]; } -export const ImageList: React.FC = PatchComponent( - "ImageList", - ({ filterHook, view, alterQuery, extraOperations = [], chapters = [] }) => { +export const FilteredImageList = PatchComponent( + "FilteredImageList", + (props: IImageList) => { const intl = useIntl(); - const history = useHistory(); - const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); - const [isExportAll, setIsExportAll] = useState(false); + const [slideshowRunning, setSlideshowRunning] = useState(false); - const filterMode = GQL.FilterMode.Images; + const searchFocus = useFocus(); - const { modal, showModal, closeModal } = useModal(); + const withSidebar = props.view !== View.GalleryImages; - const otherOperations: IItemListOperation[] = [ - ...extraOperations, + const { + filterHook, + view, + alterQuery, + extraOperations: providedOperations = [], + chapters, + } = props; + + // States + const { + showSidebar, + setShowSidebar, + sectionOpen, + setSectionOpen, + loading: sidebarStateLoading, + } = useSidebarState(view); + + const { + filterState, + queryResult, + metadataInfo, + modalState, + listSelect, + showEditFilter, + } = useFilteredItemList({ + filterStateProps: { + filterMode: GQL.FilterMode.Images, + view, + useURL: alterQuery, + }, + queryResultProps: { + useResult: useFindImages, + useMetadataInfo: useFindImagesMetadata, + getCount: (r) => r.data?.findImages.count ?? 0, + getItems: (r) => r.data?.findImages.images ?? [], + filterHook, + }, + }); + + const { filter, setFilter } = filterState; + + const { effectiveFilter, result, cachedResult, items, totalCount } = + queryResult; + + const metadataByline = useMemo(() => { + if (cachedResult.loading) return null; + + return renderMetadataByline(metadataInfo) ?? null; + }, [cachedResult.loading, metadataInfo]); + + const { + selectedIds, + selectedItems, + onSelectChange, + onSelectAll, + onSelectNone, + onInvertSelection, + hasSelection, + } = listSelect; + + const { modal, showModal, closeModal } = modalState; + + // Utility hooks + const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({ + filter, + setFilter, + }); + + useAddKeybinds(filter, totalCount); + useFilteredSidebarKeybinds({ + showSidebar, + setShowSidebar, + }); + + useEffect(() => { + Mousetrap.bind("e", () => { + if (hasSelection) { + onEdit?.(); + } + }); + + Mousetrap.bind("d d", () => { + if (hasSelection) { + onDelete?.(); + } + }); + + return () => { + Mousetrap.unbind("e"); + Mousetrap.unbind("d d"); + }; + }); + + const onCloseEditDelete = useCloseEditDelete({ + closeModal, + onSelectNone, + result, + }); + + const viewRandom = useViewRandom(effectiveFilter, totalCount); + + function onExport(all: boolean) { + showModal( + closeModal()} + /> + ); + } + + function onEdit() { + showModal( + + ); + } + + function onDelete() { + showModal( + + ); + } + + const convertedExtraOperations: IListFilterOperation[] = + providedOperations.map((o) => ({ + ...o, + isDisplayed: o.isDisplayed + ? () => o.isDisplayed!(result, filter, selectedIds) + : undefined, + onClick: () => { + o.onClick(result, filter, selectedIds); + }, + })); + + const otherOperations: IListFilterOperation[] = [ + ...convertedExtraOperations, + { + text: intl.formatMessage({ id: "actions.select_all" }), + onClick: () => onSelectAll(), + isDisplayed: () => totalCount > 0, + }, + { + text: intl.formatMessage({ id: "actions.select_none" }), + onClick: () => onSelectNone(), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.invert_selection" }), + onClick: () => onInvertSelection(), + isDisplayed: () => totalCount > 0, + }, { text: intl.formatMessage({ id: "actions.view_random" }), onClick: viewRandom, }, { text: `${intl.formatMessage({ id: "actions.generate" })}…`, - onClick: (result, filter, selectedIds) => { + onClick: () => { showModal( = PatchComponent( onClose={() => closeModal()} /> ); - return Promise.resolve(); }, - isDisplayed: showWhenSelected, + isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.export" }), - onClick: onExport, - isDisplayed: showWhenSelected, + onClick: () => onExport(false), + isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.export_all" }), - onClick: onExportAll, + onClick: () => onExport(true), }, ]; - function addKeybinds( - result: GQL.FindImagesQueryResult, - filter: ListFilterModel - ) { - Mousetrap.bind("p r", () => { - viewRandom(result, filter); - }); + // render + if (sidebarStateLoading) return null; - return () => { - Mousetrap.unbind("p r"); - }; - } + const operations = ( + + ); - async function viewRandom( - result: GQL.FindImagesQueryResult, - filter: ListFilterModel - ) { - // query for a random image - if (result.data?.findImages) { - const { count } = result.data.findImages; + const pageCount = Math.ceil(totalCount / filter.itemsPerPage); - const index = Math.floor(Math.random() * count); - const filterCopy = cloneDeep(filter); - filterCopy.itemsPerPage = 1; - filterCopy.currentPage = index + 1; - const singleResult = await queryFindImages(filterCopy); - if (singleResult.data.findImages.images.length === 1) { - const { id } = singleResult.data.findImages.images[0]; - // navigate to the image player page - history.push(`/images/${id}`); - } - } - } + const content = ( + <> + - async function onExport() { - setIsExportAll(false); - setIsExportDialogOpen(true); - } + showEditFilter(c.criterionOption.type)} + onRemoveCriterion={removeCriterion} + onRemoveAll={clearAllCriteria} + /> - async function onExportAll() { - setIsExportAll(true); - setIsExportDialogOpen(true); - } +
+ setFilter(filter.changePage(page))} + /> + +
- function renderContent( - result: GQL.FindImagesQueryResult, - filter: ListFilterModel, - selectedIds: Set, - onSelectChange: ( - id: string, - selected: boolean, - shiftKey: boolean - ) => void, - onChangePage: (page: number) => void, - pageCount: number - ) { - function maybeRenderImageExportDialog() { - if (isExportDialogOpen) { - return ( - setIsExportDialogOpen(false)} - /> - ); - } - } - - function renderImages() { - if (!result.data?.findImages) return; - - return ( - + setFilter(filter.changePage(page))} onSelectChange={onSelectChange} pageCount={pageCount} selectedIds={selectedIds} @@ -478,54 +767,60 @@ export const ImageList: React.FC = PatchComponent( setSlideshowRunning={setSlideshowRunning} chapters={chapters} /> - ); - } + - return ( - <> - {maybeRenderImageExportDialog()} - {renderImages()} - - ); - } + {totalCount > filter.itemsPerPage && ( +
+
+ +
+
+ )} + + ); - function renderEditDialog( - selectedImages: GQL.SlimImageDataFragment[], - onClose: (applied: boolean) => void - ) { - return ; - } - - function renderDeleteDialog( - selectedImages: GQL.SlimImageDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ; + if (!withSidebar) { + return content; } return ( - {modal} - - + + + + setShowSidebar(false)}> + setShowSidebar(false)} + count={cachedResult.loading ? undefined : totalCount} + focus={searchFocus} + /> + + setShowSidebar(!showSidebar)} + > + {content} + + + +
); } ); diff --git a/ui/v2.5/src/components/Images/Images.tsx b/ui/v2.5/src/components/Images/Images.tsx index 91edfdf79..932bbc2c1 100644 --- a/ui/v2.5/src/components/Images/Images.tsx +++ b/ui/v2.5/src/components/Images/Images.tsx @@ -3,11 +3,11 @@ import { Route, Switch } from "react-router-dom"; import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; import Image from "./ImageDetails/Image"; -import { ImageList } from "./ImageList"; +import { FilteredImageList } from "./ImageList"; import { View } from "../List/views"; const Images: React.FC = () => { - return ; + return ; }; const ImageRoutes: React.FC = () => { diff --git a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx index f19472d64..e006d6b50 100644 --- a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx @@ -20,6 +20,7 @@ import { FilterMode, GalleryFilterType, GroupFilterType, + ImageFilterType, InputMaybe, IntCriterionInput, PerformerFilterType, @@ -524,6 +525,8 @@ interface IFilterType { performer_count?: InputMaybe; galleries_filter?: InputMaybe; gallery_count?: InputMaybe; + images_filter?: InputMaybe; + image_count?: InputMaybe; groups_filter?: InputMaybe; group_count?: InputMaybe; studios_filter?: InputMaybe; @@ -578,6 +581,17 @@ export function setObjectFilter( } out.galleries_filter = relatedFilterOutput as GalleryFilterType; break; + case FilterMode.Images: + // if empty, only get objects with galleries + if (empty) { + out.image_count = { + modifier: CriterionModifier.GreaterThan, + value: 0, + }; + break; + } + out.images_filter = relatedFilterOutput as ImageFilterType; + break; case FilterMode.Groups: // if empty, only get objects with groups if (empty) { diff --git a/ui/v2.5/src/components/List/ItemList.tsx b/ui/v2.5/src/components/List/ItemList.tsx index 67d09e721..962e3fc4c 100644 --- a/ui/v2.5/src/components/List/ItemList.tsx +++ b/ui/v2.5/src/components/List/ItemList.tsx @@ -46,16 +46,21 @@ import { useConfigurationContext } from "src/hooks/Config"; import { useZoomKeybinds } from "./ZoomSlider"; import { DisplayMode } from "src/models/list-filter/types"; -interface IFilteredItemList { +interface IFilteredItemList< + T extends QueryResult, + E extends IHasID = IHasID, + M = unknown +> { filterStateProps: IFilterStateHook; - queryResultProps: IQueryResultHook; + queryResultProps: IQueryResultHook; } // Provides the common state and behaviour for filtered item list components export function useFilteredItemList< T extends QueryResult, - E extends IHasID = IHasID ->(props: IFilteredItemList) { + E extends IHasID = IHasID, + M = unknown +>(props: IFilteredItemList) { const { configuration: config } = useConfigurationContext(); // States @@ -70,7 +75,7 @@ export function useFilteredItemList< filter, ...props.queryResultProps, }); - const { result, items, totalCount, pages } = queryResult; + const { result, items, totalCount, pages, metadataInfo } = queryResult; const listSelect = useListSelect(items); const { onSelectAll, onSelectNone, onInvertSelection } = listSelect; @@ -107,6 +112,7 @@ export function useFilteredItemList< return { filterState, queryResult, + metadataInfo, listSelect, modalState, showEditFilter, diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index d870c631f..89c32222f 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -509,23 +509,27 @@ export function useCachedQueryResult( export interface IQueryResultHook< T extends QueryResult, - E extends IHasID = IHasID + E extends IHasID = IHasID, + M = unknown > { filterHook?: (filter: ListFilterModel) => ListFilterModel; useResult: (filter: ListFilterModel) => T; + useMetadataInfo?: (filter: ListFilterModel) => M; getCount: (data: T) => number; getItems: (data: T) => E[]; } export function useQueryResult< T extends QueryResult, - E extends IHasID = IHasID + E extends IHasID = IHasID, + M = unknown >( - props: IQueryResultHook & { + props: IQueryResultHook & { filter: ListFilterModel; } ) { - const { filter, filterHook, useResult, getItems, getCount } = props; + const { filter, filterHook, useResult, useMetadataInfo, getItems, getCount } = + props; const effectiveFilter = useMemo(() => { if (filterHook) { @@ -534,7 +538,14 @@ export function useQueryResult< return filter; }, [filter, filterHook]); + // metadata filter is the effective filter with the sort, page size and page number removed + const metadataFilter = useMemo( + () => effectiveFilter.metadataInfo(), + [effectiveFilter] + ); + const result = useResult(effectiveFilter); + const metadataInfo = useMetadataInfo?.(metadataFilter); // use cached query result for pagination and metadata rendering const cachedResult = useCachedQueryResult(effectiveFilter, result); @@ -549,6 +560,7 @@ export function useQueryResult< return { effectiveFilter, + metadataInfo, result, cachedResult, items, diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx index 7b088e5be..bd1484a17 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx @@ -1,6 +1,6 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; -import { ImageList } from "src/components/Images/ImageList"; +import { FilteredImageList } from "src/components/Images/ImageList"; import { usePerformerFilterHook } from "src/core/performers"; import { View } from "src/components/List/views"; import { PatchComponent } from "src/patch"; @@ -14,7 +14,7 @@ export const PerformerImagesPanel: React.FC = PatchComponent("PerformerImagesPanel", ({ active, performer }) => { const filterHook = usePerformerFilterHook(performer); return ( - ; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; }> = PatchComponent( - "SceneList", + "SceneMarkerList", ({ markers, filter, selectedIds, onSelectChange }) => { if (markers.length === 0) { return null; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx index a81c91462..f81599ceb 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx @@ -1,7 +1,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useStudioFilterHook } from "src/core/studios"; -import { ImageList } from "src/components/Images/ImageList"; +import { FilteredImageList } from "src/components/Images/ImageList"; import { View } from "src/components/List/views"; interface IStudioImagesPanel { @@ -17,7 +17,7 @@ export const StudioImagesPanel: React.FC = ({ }) => { const filterHook = useStudioFilterHook(studio, showChildStudioContent); return ( - = ({ }) => { const filterHook = useTagFilterHook(tag, showSubTagContent); return ( - ; ExternalLinksButton: React.FC; FilteredGalleryList: React.FC; + FilteredGroupList: React.FC; + FilteredImageList: React.FC; + FilteredPerformerList: React.FC; FilteredSceneList: React.FC; + FilteredSceneMarkerList: React.FC; + FilteredStudioList: React.FC; FolderSelect: React.FC; FrontPage: React.FC; GalleryCard: React.FC; From b77abd64e2e98c9410de3a9666da62430123f5a0 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Wed, 25 Feb 2026 21:36:54 -0800 Subject: [PATCH 091/177] FR: Add Missing is-missing Filter Options Across all Object Types (#6565) Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- pkg/sqlite/gallery_filter.go | 9 +++ pkg/sqlite/group_filter.go | 18 ++++++ pkg/sqlite/image_filter.go | 9 +++ pkg/sqlite/performer_filter.go | 12 ++++ pkg/sqlite/scene_filter.go | 6 ++ pkg/sqlite/sql.go | 10 ++++ pkg/sqlite/studio_filter.go | 12 ++++ pkg/sqlite/tag_filter.go | 12 ++++ .../models/list-filter/criteria/is-missing.ts | 60 +++++++++++++++++-- 9 files changed, 142 insertions(+), 6 deletions(-) diff --git a/pkg/sqlite/gallery_filter.go b/pkg/sqlite/gallery_filter.go index f920e442a..069bb1015 100644 --- a/pkg/sqlite/gallery_filter.go +++ b/pkg/sqlite/gallery_filter.go @@ -308,7 +308,16 @@ func (qb *galleryFilterHandler) missingCriterionHandler(isMissing *string) crite case "tags": galleryRepository.tags.join(f, "tags_join", "galleries.id") f.addWhere("tags_join.gallery_id IS NULL") + case "cover": + f.addLeftJoin("galleries_images", "cover_join", "cover_join.gallery_id = galleries.id AND cover_join.cover = 1") + f.addWhere("cover_join.image_id IS NULL") default: + if err := validateIsMissing(*isMissing, []string{ + "title", "code", "rating", "details", "photographer", + }); err != nil { + f.setError(err) + return + } f.addWhere("(galleries." + *isMissing + " IS NULL OR TRIM(galleries." + *isMissing + ") = '')") } } diff --git a/pkg/sqlite/group_filter.go b/pkg/sqlite/group_filter.go index 4f3f7b41a..14f3841f4 100644 --- a/pkg/sqlite/group_filter.go +++ b/pkg/sqlite/group_filter.go @@ -119,7 +119,25 @@ func (qb *groupFilterHandler) missingCriterionHandler(isMissing *string) criteri case "scenes": f.addLeftJoin("groups_scenes", "", "groups_scenes.group_id = groups.id") f.addWhere("groups_scenes.scene_id IS NULL") + case "url": + groupsURLsTableMgr.join(f, "", "groups.id") + f.addWhere("group_urls.url IS NULL") + case "studio": + f.addWhere("groups.studio_id IS NULL") + case "performers": + f.addLeftJoin("groups_scenes", "gs_perf", "groups.id = gs_perf.group_id") + f.addLeftJoin("performers_scenes", "ps_perf", "gs_perf.scene_id = ps_perf.scene_id") + f.addWhere("ps_perf.performer_id IS NULL") + case "tags": + groupRepository.tags.join(f, "tags_join", "groups.id") + f.addWhere("tags_join.group_id IS NULL") default: + if err := validateIsMissing(*isMissing, []string{ + "aliases", "description", "director", "date", "rating", + }); err != nil { + f.setError(err) + return + } f.addWhere("(groups." + *isMissing + " IS NULL OR TRIM(groups." + *isMissing + ") = '')") } } diff --git a/pkg/sqlite/image_filter.go b/pkg/sqlite/image_filter.go index aafd2aa40..4d1d2c4b3 100644 --- a/pkg/sqlite/image_filter.go +++ b/pkg/sqlite/image_filter.go @@ -171,6 +171,9 @@ func (qb *imageFilterHandler) missingCriterionHandler(isMissing *string) criteri return func(ctx context.Context, f *filterBuilder) { if isMissing != nil && *isMissing != "" { switch *isMissing { + case "url": + imagesURLsTableMgr.join(f, "", "images.id") + f.addWhere("image_urls.url IS NULL") case "studio": f.addWhere("images.studio_id IS NULL") case "performers": @@ -183,6 +186,12 @@ func (qb *imageFilterHandler) missingCriterionHandler(isMissing *string) criteri imageRepository.tags.join(f, "tags_join", "images.id") f.addWhere("tags_join.image_id IS NULL") default: + if err := validateIsMissing(*isMissing, []string{ + "title", "details", "photographer", "date", "code", "rating", + }); err != nil { + f.setError(err) + return + } f.addWhere("(images." + *isMissing + " IS NULL OR TRIM(images." + *isMissing + ") = '')") } } diff --git a/pkg/sqlite/performer_filter.go b/pkg/sqlite/performer_filter.go index e99f3068f..fdcc283ab 100644 --- a/pkg/sqlite/performer_filter.go +++ b/pkg/sqlite/performer_filter.go @@ -316,7 +316,19 @@ func (qb *performerFilterHandler) performerIsMissingCriterionHandler(isMissing * case "aliases": performersAliasesTableMgr.join(f, "", "performers.id") f.addWhere("performer_aliases.alias IS NULL") + case "tags": + f.addLeftJoin(performersTagsTable, "tags_join", "tags_join.performer_id = performers.id") + f.addWhere("tags_join.performer_id IS NULL") default: + if err := validateIsMissing(*isMissing, []string{ + "disambiguation", "gender", "birthdate", "death_date", + "ethnicity", "country", "hair_color", "eye_color", "height", "weight", + "measurements", "fake_tits", "penis_length", "circumcised", + "career_start", "career_end", "tattoos", "piercings", "details", "rating", + }); err != nil { + f.setError(err) + return + } f.addWhere("(performers." + *isMissing + " IS NULL OR TRIM(performers." + *isMissing + ") = '')") } } diff --git a/pkg/sqlite/scene_filter.go b/pkg/sqlite/scene_filter.go index a9eb6b0ae..712c3d83d 100644 --- a/pkg/sqlite/scene_filter.go +++ b/pkg/sqlite/scene_filter.go @@ -426,6 +426,12 @@ func (qb *sceneFilterHandler) isMissingCriterionHandler(isMissing *string) crite case "cover": f.addWhere("scenes.cover_blob IS NULL") default: + if err := validateIsMissing(*isMissing, []string{ + "title", "code", "details", "director", "rating", + }); err != nil { + f.setError(err) + return + } f.addWhere("(scenes." + *isMissing + " IS NULL OR TRIM(scenes." + *isMissing + ") = '')") } } diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index 0b55af8db..70d86ab5e 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -71,6 +71,16 @@ func (o sortOptions) validateSort(sort string) error { return fmt.Errorf("invalid sort: %s", sort) } +func validateIsMissing(isMissing string, allowed []string) error { + for _, v := range allowed { + if v == isMissing { + return nil + } + } + + return fmt.Errorf("invalid is_missing field: %s", isMissing) +} + func getSortDirection(direction string) string { if direction != "ASC" && direction != "DESC" { return "ASC" diff --git a/pkg/sqlite/studio_filter.go b/pkg/sqlite/studio_filter.go index cfe3c59b6..6d5a8fe7c 100644 --- a/pkg/sqlite/studio_filter.go +++ b/pkg/sqlite/studio_filter.go @@ -150,7 +150,19 @@ func (qb *studioFilterHandler) isMissingCriterionHandler(isMissing *string) crit case "stash_id": studioRepository.stashIDs.join(f, "studio_stash_ids", "studios.id") f.addWhere("studio_stash_ids.studio_id IS NULL") + case "aliases": + studiosAliasesTableMgr.join(f, "", "studios.id") + f.addWhere("studio_aliases.alias IS NULL") + case "tags": + f.addLeftJoin(studiosTagsTable, "tags_join", "tags_join.studio_id = studios.id") + f.addWhere("tags_join.studio_id IS NULL") default: + if err := validateIsMissing(*isMissing, []string{ + "details", "rating", + }); err != nil { + f.setError(err) + return + } f.addWhere("(studios." + *isMissing + " IS NULL OR TRIM(studios." + *isMissing + ") = '')") } } diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index 4e2313080..5fd41e80a 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -198,7 +198,19 @@ func (qb *tagFilterHandler) isMissingCriterionHandler(isMissing *string) criteri switch *isMissing { case "image": f.addWhere("tags.image_blob IS NULL") + case "aliases": + tagRepository.aliases.join(f, "", "tags.id") + f.addWhere("tag_aliases.alias IS NULL") + case "stash_id": + tagRepository.stashIDs.join(f, "tag_stash_ids", "tags.id") + f.addWhere("tag_stash_ids.tag_id IS NULL") default: + if err := validateIsMissing(*isMissing, []string{ + "description", + }); err != nil { + f.setError(err) + return + } f.addWhere("(tags." + *isMissing + " IS NULL OR TRIM(tags." + *isMissing + ") = '')") } } diff --git a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts index 512616f3c..821870e47 100644 --- a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts +++ b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts @@ -26,10 +26,13 @@ export const SceneIsMissingCriterionOption = new IsMissingCriterionOption( "is_missing", [ "title", - "cover", + "code", "details", + "director", "url", "date", + "rating", + "cover", "galleries", "studio", "group", @@ -42,7 +45,19 @@ export const SceneIsMissingCriterionOption = new IsMissingCriterionOption( export const ImageIsMissingCriterionOption = new IsMissingCriterionOption( "isMissing", "is_missing", - ["title", "galleries", "studio", "performers", "tags"] + [ + "title", + "details", + "photographer", + "url", + "date", + "code", + "rating", + "galleries", + "studio", + "performers", + "tags", + ] ); export const PerformerIsMissingCriterionOption = new IsMissingCriterionOption( @@ -58,14 +73,21 @@ export const PerformerIsMissingCriterionOption = new IsMissingCriterionOption( "weight", "measurements", "fake_tits", + "penis_length", + "circumcised", "career_start", "career_end", "tattoos", "piercings", "aliases", "gender", + "birthdate", + "death_date", + "disambiguation", + "tags", "image", "details", + "rating", "stash_id", ] ); @@ -73,23 +95,49 @@ export const PerformerIsMissingCriterionOption = new IsMissingCriterionOption( export const GalleryIsMissingCriterionOption = new IsMissingCriterionOption( "isMissing", "is_missing", - ["title", "details", "url", "date", "studio", "performers", "tags", "scenes"] + [ + "title", + "code", + "details", + "photographer", + "url", + "date", + "rating", + "cover", + "studio", + "performers", + "tags", + "scenes", + ] ); export const TagIsMissingCriterionOption = new IsMissingCriterionOption( "isMissing", "is_missing", - ["image"] + ["image", "aliases", "description", "stash_id"] ); export const StudioIsMissingCriterionOption = new IsMissingCriterionOption( "isMissing", "is_missing", - ["image", "stash_id", "details"] + ["image", "stash_id", "details", "url", "aliases", "tags", "rating"] ); export const GroupIsMissingCriterionOption = new IsMissingCriterionOption( "isMissing", "is_missing", - ["front_image", "back_image", "scenes"] + [ + "aliases", + "description", + "director", + "date", + "url", + "rating", + "studio", + "performers", + "tags", + "front_image", + "back_image", + "scenes", + ] ); From e52ac14d56945c1aba0626b81cbf641dbf8b5791 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:42:53 +1100 Subject: [PATCH 092/177] Fix missing folder corruption during scanning (#6608) * Add root paths parameter to GetOrCreateFolderHierarchy Ensures that folders are only created up to the root library paths. * Create full folder hierarchy when scanning a new folder During a recursive scan, folders should be created as they are encountered (folders are handled in a single thread). This change applies only during a selective scan. Creates up to the root library folder. * Create folder hierarchy on new file scan This should only apply when scanning a specific file, as parent folders should be been created during a recursive scan. * Fix existing folders with missing parents during scan --- internal/api/resolver_mutation_file.go | 11 +++-- internal/manager/config/stash_config.go | 8 +++ internal/manager/manager_tasks.go | 3 +- pkg/file/folder.go | 61 ++++++++++++++++------- pkg/file/move.go | 14 +++++- pkg/file/scan.go | 66 ++++++++++++++++++++----- 6 files changed, 125 insertions(+), 38 deletions(-) diff --git a/internal/api/resolver_mutation_file.go b/internal/api/resolver_mutation_file.go index f6279ad16..b9e36aa76 100644 --- a/internal/api/resolver_mutation_file.go +++ b/internal/api/resolver_mutation_file.go @@ -7,6 +7,7 @@ import ( "github.com/stashapp/stash/internal/desktop" "github.com/stashapp/stash/internal/manager" + "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" @@ -19,7 +20,7 @@ func (r *mutationResolver) MoveFiles(ctx context.Context, input MoveFilesInput) if err := r.withTxn(ctx, func(ctx context.Context) error { fileStore := r.repository.File folderStore := r.repository.Folder - mover := file.NewMover(fileStore, folderStore) + mover := file.NewMover(fileStore, folderStore, manager.GetInstance().Config.GetStashPaths().Paths()) mover.RegisterHooks(ctx) var ( @@ -57,13 +58,14 @@ func (r *mutationResolver) MoveFiles(ctx context.Context, input MoveFilesInput) folderPath := *input.DestinationFolder // ensure folder path is within the library - if err := r.validateFolderPath(folderPath); err != nil { + stashPaths := manager.GetInstance().Config.GetStashPaths() + if err := r.validateFolderPath(stashPaths, folderPath); err != nil { return err } // get or create folder hierarchy var err error - folder, err = file.GetOrCreateFolderHierarchy(ctx, folderStore, folderPath) + folder, err = file.GetOrCreateFolderHierarchy(ctx, folderStore, folderPath, stashPaths.Paths()) if err != nil { return fmt.Errorf("getting or creating folder hierarchy: %w", err) } @@ -112,8 +114,7 @@ func (r *mutationResolver) MoveFiles(ctx context.Context, input MoveFilesInput) return true, nil } -func (r *mutationResolver) validateFolderPath(folderPath string) error { - paths := manager.GetInstance().Config.GetStashPaths() +func (r *mutationResolver) validateFolderPath(paths config.StashConfigs, folderPath string) error { if l := paths.GetStashFromDirPath(folderPath); l == nil { return fmt.Errorf("folder path %s must be within a stash library path", folderPath) } diff --git a/internal/manager/config/stash_config.go b/internal/manager/config/stash_config.go index 4a2cc7d60..3854c707b 100644 --- a/internal/manager/config/stash_config.go +++ b/internal/manager/config/stash_config.go @@ -38,3 +38,11 @@ func (s StashConfigs) GetStashFromDirPath(dirPath string) *StashConfig { } return nil } + +func (s StashConfigs) Paths() []string { + paths := make([]string, len(s)) + for i, c := range s { + paths[i] = c.Path + } + return paths +} diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index e97227fcf..e84fda9b9 100644 --- a/internal/manager/manager_tasks.go +++ b/internal/manager/manager_tasks.go @@ -123,7 +123,8 @@ func (s *Manager) Scan(ctx context.Context, input ScanMetadataInput) (int, error ZipFileExtensions: cfg.GetGalleryExtensions(), // ScanFilters is set in ScanJob.Execute // HandlerRequiredFilters is set in ScanJob.Execute - Rescan: input.Rescan, + RootPaths: cfg.GetStashPaths().Paths(), + Rescan: input.Rescan, } scanJob := ScanJob{ diff --git a/pkg/file/folder.go b/pkg/file/folder.go index fe260c155..e3e14186b 100644 --- a/pkg/file/folder.go +++ b/pkg/file/folder.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "path/filepath" + "slices" "strings" "time" @@ -12,8 +13,9 @@ import ( ) // GetOrCreateFolderHierarchy gets the folder for the given path, or creates a folder hierarchy for the given path if one if no existing folder is found. -// Does not create any folders in the file system -func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreator, path string) (*models.Folder, error) { +// Creates folder entries for each level of the hierarchy that doesn't already exist, up to the provided root paths. +// Does not create any folders in the file system. +func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreator, path string, rootPaths []string) (*models.Folder, error) { // get or create folder hierarchy // assume case sensitive when searching for the folder const caseSensitive = true @@ -23,17 +25,30 @@ func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreat } if folder == nil { - parentPath := filepath.Dir(path) - parent, err := GetOrCreateFolderHierarchy(ctx, fc, parentPath) - if err != nil { - return nil, err + var parentID *models.FolderID + + if !slices.Contains(rootPaths, path) { + parentPath := filepath.Dir(path) + + // safety check - don't allow parent path to be the same as the current path, + // otherwise we could end up in an infinite loop + if parentPath == path { + panic(fmt.Sprintf("parent path is the same as the current path: %s", path)) + } + + parent, err := GetOrCreateFolderHierarchy(ctx, fc, parentPath, rootPaths) + if err != nil { + return nil, err + } + + parentID = &parent.ID } now := time.Now() folder = &models.Folder{ Path: path, - ParentFolderID: &parent.ID, + ParentFolderID: parentID, DirEntry: models.DirEntry{ // leave mod time empty for now - it will be updated when the folder is scanned }, @@ -41,6 +56,8 @@ func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreat UpdatedAt: now, } + logger.Infof("%s doesn't exist. Creating new folder entry...", path) + if err = fc.Create(ctx, folder); err != nil { return nil, fmt.Errorf("creating folder %s: %w", path, err) } @@ -49,12 +66,18 @@ func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreat return folder, nil } -func transferZipHierarchy(ctx context.Context, folderStore models.FolderReaderWriter, files models.FileFinderUpdater, zipFileID models.FileID, oldPath string, newPath string) error { - if err := transferZipFolderHierarchy(ctx, folderStore, zipFileID, oldPath, newPath); err != nil { +type zipHierarchyMover struct { + folderStore models.FolderReaderWriter + files models.FileFinderUpdater + rootPaths []string +} + +func (m zipHierarchyMover) transferZipHierarchy(ctx context.Context, zipFileID models.FileID, oldPath string, newPath string) error { + if err := m.transferZipFolderHierarchy(ctx, zipFileID, oldPath, newPath); err != nil { return fmt.Errorf("moving folder hierarchy for file %s: %w", oldPath, err) } - if err := transferZipFileEntries(ctx, folderStore, files, zipFileID, oldPath, newPath); err != nil { + if err := m.transferZipFileEntries(ctx, zipFileID, oldPath, newPath); err != nil { return fmt.Errorf("moving zip file contents for file %s: %w", oldPath, err) } @@ -63,8 +86,8 @@ func transferZipHierarchy(ctx context.Context, folderStore models.FolderReaderWr // transferZipFolderHierarchy creates the folder hierarchy for zipFileID under newPath, and removes // ZipFileID from folders under oldPath. -func transferZipFolderHierarchy(ctx context.Context, folderStore models.FolderReaderWriter, zipFileID models.FileID, oldPath string, newPath string) error { - zipFolders, err := folderStore.FindByZipFileID(ctx, zipFileID) +func (m zipHierarchyMover) transferZipFolderHierarchy(ctx context.Context, zipFileID models.FileID, oldPath string, newPath string) error { + zipFolders, err := m.folderStore.FindByZipFileID(ctx, zipFileID) if err != nil { return err } @@ -83,7 +106,7 @@ func transferZipFolderHierarchy(ctx context.Context, folderStore models.FolderRe } newZfPath := filepath.Join(newPath, relZfPath) - newFolder, err := GetOrCreateFolderHierarchy(ctx, folderStore, newZfPath) + newFolder, err := GetOrCreateFolderHierarchy(ctx, m.folderStore, newZfPath, m.rootPaths) if err != nil { return err } @@ -91,14 +114,14 @@ func transferZipFolderHierarchy(ctx context.Context, folderStore models.FolderRe // add ZipFileID to new folder logger.Debugf("adding zip file %s to folder %s", zipFileID, newFolder.Path) newFolder.ZipFileID = &zipFileID - if err = folderStore.Update(ctx, newFolder); err != nil { + if err = m.folderStore.Update(ctx, newFolder); err != nil { return err } // remove ZipFileID from old folder logger.Debugf("removing zip file %s from folder %s", zipFileID, oldFolder.Path) oldFolder.ZipFileID = nil - if err = folderStore.Update(ctx, oldFolder); err != nil { + if err = m.folderStore.Update(ctx, oldFolder); err != nil { return err } } @@ -106,9 +129,9 @@ func transferZipFolderHierarchy(ctx context.Context, folderStore models.FolderRe return nil } -func transferZipFileEntries(ctx context.Context, folders models.FolderFinderCreator, files models.FileFinderUpdater, zipFileID models.FileID, oldPath, newPath string) error { +func (m zipHierarchyMover) transferZipFileEntries(ctx context.Context, zipFileID models.FileID, oldPath, newPath string) error { // move contained files if file is a zip file - zipFiles, err := files.FindByZipFileID(ctx, zipFileID) + zipFiles, err := m.files.FindByZipFileID(ctx, zipFileID) if err != nil { return fmt.Errorf("finding contained files in file %s: %w", oldPath, err) } @@ -129,7 +152,7 @@ func transferZipFileEntries(ctx context.Context, folders models.FolderFinderCrea newZfDir := filepath.Join(newPath, relZfDir) // folder should have been created by transferZipFolderHierarchy - newZfFolder, err := GetOrCreateFolderHierarchy(ctx, folders, newZfDir) + newZfFolder, err := GetOrCreateFolderHierarchy(ctx, m.folderStore, newZfDir, m.rootPaths) if err != nil { return fmt.Errorf("getting or creating folder hierarchy: %w", err) } @@ -137,7 +160,7 @@ func transferZipFileEntries(ctx context.Context, folders models.FolderFinderCrea // update file parent folder zfBase.ParentFolderID = newZfFolder.ID logger.Debugf("moving %s to folder %s", zfBase.Path, newZfFolder.Path) - if err := files.Update(ctx, zf); err != nil { + if err := m.files.Update(ctx, zf); err != nil { return fmt.Errorf("updating file %s: %w", oldZfPath, err) } } diff --git a/pkg/file/move.go b/pkg/file/move.go index ba2a496bb..06605912b 100644 --- a/pkg/file/move.go +++ b/pkg/file/move.go @@ -45,9 +45,12 @@ type Mover struct { moved map[string]string foldersCreated []string + + // needed for creating folder hierarchy when moving zip file entries + rootPaths []string } -func NewMover(fileStore models.FileFinderUpdater, folderStore models.FolderReaderWriter) *Mover { +func NewMover(fileStore models.FileFinderUpdater, folderStore models.FolderReaderWriter, rootPaths []string) *Mover { return &Mover{ Files: fileStore, Folders: folderStore, @@ -55,6 +58,7 @@ func NewMover(fileStore models.FileFinderUpdater, folderStore models.FolderReade renamerRemoverImpl: newRenamerRemoverImpl(), mkDirFn: os.Mkdir, }, + rootPaths: rootPaths, } } @@ -87,7 +91,13 @@ func (m *Mover) Move(ctx context.Context, f models.File, folder *models.Folder, return fmt.Errorf("file %s already exists", newPath) } - if err := transferZipHierarchy(ctx, m.Folders, m.Files, fBase.ID, oldPath, newPath); err != nil { + zipMover := zipHierarchyMover{ + folderStore: m.Folders, + files: m.Files, + rootPaths: m.rootPaths, + } + + if err := zipMover.transferZipHierarchy(ctx, fBase.ID, oldPath, newPath); err != nil { return fmt.Errorf("moving folder hierarchy for file %s: %w", fBase.Path, err) } diff --git a/pkg/file/scan.go b/pkg/file/scan.go index d9a58ad44..cf1b43603 100644 --- a/pkg/file/scan.go +++ b/pkg/file/scan.go @@ -5,6 +5,7 @@ import ( "fmt" "io/fs" "path/filepath" + "slices" "strings" "sync" "time" @@ -60,6 +61,10 @@ type Scanner struct { // handlers are called after a file has been scanned. FileHandlers []Handler + // RootPaths form the top-level paths for the library. + // Used to determine the root of the folder hierarchy when creating folders. + RootPaths []string + // Rescan indicates whether files should be rescanned even if they haven't changed. Rescan bool @@ -193,6 +198,10 @@ func (s *Scanner) ScanFolder(ctx context.Context, file ScannedFile) (*models.Fol return f, err } +func (s *Scanner) isRootPath(path string) bool { + return path == "." || slices.Contains(s.RootPaths, path) +} + func (s *Scanner) onNewFolder(ctx context.Context, file ScannedFile) (*models.Folder, error) { renamed, err := s.handleFolderRename(ctx, file) if err != nil { @@ -212,18 +221,16 @@ func (s *Scanner) onNewFolder(ctx context.Context, file ScannedFile) (*models.Fo UpdatedAt: now, } - dir := filepath.Dir(file.Path) - if dir != "." { - parentFolderID, err := s.getFolderID(ctx, dir) + if !s.isRootPath(file.Path) { + dir := filepath.Dir(file.Path) + + // create full folder hierarchy if parent folder doesn't exist, and set parent folder ID + parentFolder, err := GetOrCreateFolderHierarchy(ctx, s.Repository.Folder, dir, s.RootPaths) if err != nil { return nil, fmt.Errorf("getting parent folder %q: %w", dir, err) } - // if parent folder doesn't exist, assume it's a top-level folder - // this may not be true if we're using multiple goroutines - if parentFolderID != nil { - toCreate.ParentFolderID = parentFolderID - } + toCreate.ParentFolderID = &parentFolder.ID } txn.AddPostCommitHook(ctx, func(ctx context.Context) { @@ -312,6 +319,19 @@ func (s *Scanner) onExistingFolder(ctx context.Context, f ScannedFile, existing } } + // handle case where parent folder was not previously set + if existing.ParentFolderID == nil && !s.isRootPath(existing.Path) { + logger.Infof("Existing folder entry %q has no parent folder. Creating folder hierarchy and setting parent ID...", existing.Path) + + // create full folder hierarchy if parent folder doesn't exist, and set parent folder ID + parentFolder, err := GetOrCreateFolderHierarchy(ctx, s.Repository.Folder, filepath.Dir(f.Path), s.RootPaths) + if err != nil { + return nil, fmt.Errorf("getting parent folder for %q: %w", f.Path, err) + } + existing.ParentFolderID = &parentFolder.ID + update = true + } + if update { var err error if err = s.Repository.Folder.Update(ctx, existing); err != nil { @@ -393,13 +413,31 @@ func (s *Scanner) onNewFile(ctx context.Context, f ScannedFile) (*ScanFileResult baseFile.UpdatedAt = now // find the parent folder - parentFolderID, err := s.getFolderID(ctx, filepath.Dir(path)) + folderPath := filepath.Dir(path) + parentFolderID, err := s.getFolderID(ctx, folderPath) if err != nil { return nil, fmt.Errorf("getting parent folder for %q: %w", path, err) } if parentFolderID == nil { - return nil, fmt.Errorf("parent folder for %q doesn't exist", path) + // parent folders should have been created before scanning this file in a recursive scan + // assume that we are scanning specifically and only this file, + // so we should create the parent folder hierarchy if it doesn't exist + if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { + parentFolder, err := GetOrCreateFolderHierarchy(ctx, s.Repository.Folder, folderPath, s.RootPaths) + if err != nil { + return fmt.Errorf("getting parent folder for %q: %w", f.Path, err) + } + + parentFolderID = &parentFolder.ID + return nil + }); err != nil { + return nil, err + } + } + if parentFolderID == nil { + // shouldn't happen + return nil, fmt.Errorf("parent folder ID is nil for %q", path) } baseFile.ParentFolderID = *parentFolderID @@ -604,13 +642,19 @@ func (s *Scanner) handleRename(ctx context.Context, f models.File, fp []models.F fBaseCopy.Fingerprints = updatedBase.Fingerprints *updatedBase = fBaseCopy + zipMover := zipHierarchyMover{ + folderStore: s.Repository.Folder, + files: s.Repository.File, + rootPaths: s.RootPaths, + } + if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { if err := s.Repository.File.Update(ctx, updated); err != nil { return fmt.Errorf("updating file for rename %q: %w", newPath, err) } if s.IsZipFile(updatedBase.Basename) { - if err := transferZipHierarchy(ctx, s.Repository.Folder, s.Repository.File, updatedBase.ID, oldPath, newPath); err != nil { + if err := zipMover.transferZipHierarchy(ctx, updatedBase.ID, oldPath, newPath); err != nil { return fmt.Errorf("moving zip hierarchy for renamed zip file %q: %w", newPath, err) } } From 660feabced3494e36ca5add5501e5f43f30dcf54 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:43:16 +1100 Subject: [PATCH 093/177] Update minimatch and ajv dependencies (#6609) * Update minimatch * Update ajv --- ui/v2.5/pnpm-lock.yaml | 88 +++++++++++++++++++++++++++--------------- 1 file changed, 56 insertions(+), 32 deletions(-) diff --git a/ui/v2.5/pnpm-lock.yaml b/ui/v2.5/pnpm-lock.yaml index 02033c41f..46dcec4d8 100644 --- a/ui/v2.5/pnpm-lock.yaml +++ b/ui/v2.5/pnpm-lock.yaml @@ -1660,36 +1660,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.1': resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.1': resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.1': resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.1': resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.1': resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-win32-arm64@2.5.1': resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} @@ -1781,56 +1787,67 @@ packages: resolution: {integrity: sha512-JzWRR41o2U3/KMNKRuZNsDUAcAVUYhsPuMlx5RUldw0E4lvSIXFUwejtYz1HJXohUmqs/M6BBJAUBzKXZVddbg==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.53.1': resolution: {integrity: sha512-L8kRIrnfMrEoHLHtHn+4uYA52fiLDEDyezgxZtGUTiII/yb04Krq+vk3P2Try+Vya9LeCE9ZHU8CXD6J9EhzHQ==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.53.1': resolution: {integrity: sha512-ysAc0MFRV+WtQ8li8hi3EoFi7us6d1UzaS/+Dp7FYZfg3NdDljGMoVyiIp6Ucz7uhlYDBZ/zt6XI0YEZbUO11Q==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.53.1': resolution: {integrity: sha512-UV6l9MJpDbDZZ/fJvqNcvO1PcivGEf1AvKuTcHoLjVZVFeAMygnamCTDikCVMRnA+qJe+B3pSbgX2+lBMqgBhA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.53.1': resolution: {integrity: sha512-UDUtelEprkA85g95Q+nj3Xf0M4hHa4DiJ+3P3h4BuGliY4NReYYqwlc0Y8ICLjN4+uIgCEvaygYlpf0hUj90Yg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.53.1': resolution: {integrity: sha512-vrRn+BYhEtNOte/zbc2wAUQReJXxEx2URfTol6OEfY2zFEUK92pkFBSXRylDM7aHi+YqEPJt9/ABYzmcrS4SgQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.53.1': resolution: {integrity: sha512-gto/1CxHyi4A7YqZZNznQYrVlPSaodOBPKM+6xcDSCMVZN/Fzb4K+AIkNz/1yAYz9h3Ng+e2fY9H6bgawVq17w==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.53.1': resolution: {integrity: sha512-KZ6Vx7jAw3aLNjFR8eYVcQVdFa/cvBzDNRFM3z7XhNNunWjA03eUrEwJYPk0G8V7Gs08IThFKcAPS4WY/ybIrQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.53.1': resolution: {integrity: sha512-HvEixy2s/rWNgpwyKpXJcHmE7om1M89hxBTBi9Fs6zVuLU4gOrEMQNbNsN/tBVIMbLyysz/iwNiGtMOpLAOlvA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.53.1': resolution: {integrity: sha512-E/n8x2MSjAQgjj9IixO4UeEUeqXLtiA7pyoXCFYLuXpBA/t2hnbIdxHfA7kK9BFsYAoNU4st1rHYdldl8dTqGA==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.53.1': resolution: {integrity: sha512-IhJ087PbLOQXCN6Ui/3FUkI9pWNZe/Z7rEIVOzMsOs1/HSAECCvSZ7PkIbkNqL/AZn6WbZvnoVZw/qwqYMo4/w==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.53.1': resolution: {integrity: sha512-0++oPNgLJHBblreu0SFM7b3mAsBJBTY0Ksrmu9N6ZVrPiTkRgda52mWR7TKhHAsUb9noCjFvAw9l6ZO1yzaVbA==} @@ -2195,11 +2212,11 @@ packages: peerDependencies: ajv: ^6.9.1 - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} @@ -2350,6 +2367,10 @@ packages: balanced-match@2.0.0: resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + base64-blob@1.4.1: resolution: {integrity: sha512-n5Ov4cPTbLBTX1PiFbaB5AmK7LMigO9HWh5Lzx+Kcx/yx1MppeeLYtAH8aLv1m++WNoHQnr+xbGSqcZinopwlw==} @@ -2382,8 +2403,9 @@ packages: brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.3: + resolution: {integrity: sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==} + engines: {node: 18 || 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -3895,11 +3917,11 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + minimatch@9.0.8: + resolution: {integrity: sha512-reYkDYtj/b19TeqbNZCV4q9t+Yxylf/rYBsLb42SXJatTv4/ylq5lEiAmhA/IToxO7NI2UzNMghHoHuaqDkAjw==} engines: {node: '>=16 || 14 >=14.17'} minimist-options@4.1.0: @@ -6336,14 +6358,14 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: - ajv: 6.12.6 + ajv: 6.14.0 debug: 4.4.3 espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 import-fresh: 3.3.1 js-yaml: 4.1.0 - minimatch: 3.1.2 + minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color @@ -7037,7 +7059,7 @@ snapshots: dependencies: '@humanwhocodes/object-schema': 2.0.3 debug: 4.4.3 - minimatch: 3.1.2 + minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -7663,18 +7685,18 @@ snapshots: clean-stack: 2.2.0 indent-string: 4.0.0 - ajv-keywords@3.5.2(ajv@6.12.6): + ajv-keywords@3.5.2(ajv@6.14.0): dependencies: - ajv: 6.12.6 + ajv: 6.14.0 - ajv@6.12.6: + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.17.1: + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 fast-uri: 3.1.0 @@ -7887,6 +7909,8 @@ snapshots: balanced-match@2.0.0: {} + balanced-match@4.0.4: {} + base64-blob@1.4.1: dependencies: b64-to-blob: 1.2.19 @@ -7924,9 +7948,9 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.2: + brace-expansion@5.0.3: dependencies: - balanced-match: 1.0.2 + balanced-match: 4.0.4 braces@3.0.3: dependencies: @@ -8520,7 +8544,7 @@ snapshots: hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 - minimatch: 3.1.2 + minimatch: 3.1.5 object.fromentries: 2.0.8 object.groupby: 1.0.3 object.values: 1.2.1 @@ -8548,7 +8572,7 @@ snapshots: hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 - minimatch: 3.1.2 + minimatch: 3.1.5 object.fromentries: 2.0.8 safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 @@ -8569,7 +8593,7 @@ snapshots: estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 - minimatch: 3.1.2 + minimatch: 3.1.5 object.entries: 1.1.9 object.fromentries: 2.0.8 object.values: 1.2.1 @@ -8601,7 +8625,7 @@ snapshots: '@humanwhocodes/module-importer': 1.0.1 '@nodelib/fs.walk': 1.2.8 '@ungap/structured-clone': 1.3.0 - ajv: 6.12.6 + ajv: 6.14.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 @@ -8626,7 +8650,7 @@ snapshots: json-stable-stringify-without-jsonify: 1.0.1 levn: 0.4.1 lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 3.1.5 natural-compare: 1.4.0 optionator: 0.9.4 strip-ansi: 6.0.1 @@ -8874,7 +8898,7 @@ snapshots: fs.realpath: 1.0.0 inflight: 1.0.6 inherits: 2.0.4 - minimatch: 3.1.2 + minimatch: 3.1.5 once: 1.4.0 path-is-absolute: 1.0.1 @@ -8932,7 +8956,7 @@ snapshots: cosmiconfig: 8.3.6(typescript@4.8.4) graphql: 16.11.0 jiti: 2.6.1 - minimatch: 9.0.5 + minimatch: 9.0.8 string-env-interpolation: 1.0.1 tslib: 2.8.1 transitivePeerDependencies: @@ -9700,13 +9724,13 @@ snapshots: min-indent@1.0.1: {} - minimatch@3.1.2: + minimatch@3.1.5: dependencies: brace-expansion: 1.1.12 - minimatch@9.0.5: + minimatch@9.0.8: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 5.0.3 minimist-options@4.1.0: dependencies: @@ -10497,8 +10521,8 @@ snapshots: schema-utils@2.7.1: dependencies: '@types/json-schema': 7.0.15 - ajv: 6.12.6 - ajv-keywords: 3.5.2(ajv@6.12.6) + ajv: 6.14.0 + ajv-keywords: 3.5.2(ajv@6.14.0) scuid@1.1.0: {} @@ -10827,7 +10851,7 @@ snapshots: table@6.9.0: dependencies: - ajv: 8.17.1 + ajv: 8.18.0 lodash.truncate: 4.4.2 slice-ansi: 4.0.0 string-width: 4.2.3 From ead0c7fe077723dbf0ae93b650e5f7a95e217371 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:44:23 +1100 Subject: [PATCH 094/177] Add sidebar to Tag list (#6610) * Fix image export dialog * Add sidebar to TagList * Update plugin docs and types * Remove ItemList as it is no longer referenced --- ui/v2.5/src/components/Images/ImageList.tsx | 2 +- ui/v2.5/src/components/List/ItemList.tsx | 372 +-------- ui/v2.5/src/components/Tags/TagList.tsx | 874 ++++++++++++-------- ui/v2.5/src/components/Tags/Tags.tsx | 4 +- ui/v2.5/src/docs/en/Manual/UIPluginApi.md | 2 + ui/v2.5/src/pluginApi.d.ts | 1 + 6 files changed, 528 insertions(+), 727 deletions(-) diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index 8c11abdee..cc8aa48f7 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -618,7 +618,7 @@ export const FilteredImageList = PatchComponent( showModal( { - view?: View; - otherOperations?: IItemListOperation[]; - renderContent: ( - result: T, - filter: ListFilterModel, - selectedIds: Set, - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void, - onChangePage: (page: number) => void, - pageCount: number - ) => React.ReactNode; - renderMetadataByline?: (data: T, metadataInfo?: M) => React.ReactNode; - renderEditDialog?: ( - selected: E[], - onClose: (applied: boolean) => void - ) => React.ReactNode; - renderDeleteDialog?: ( - selected: E[], - onClose: (confirmed: boolean) => void - ) => React.ReactNode; - addKeybinds?: ( - result: T, - filter: ListFilterModel, - selectedIds: Set - ) => () => void; - renderToolbar?: (props: IFilteredListToolbar) => React.ReactNode; -} - -export const ItemList = ( - props: IItemListProps -) => { - const { - view, - otherOperations, - renderContent, - renderEditDialog, - renderDeleteDialog, - renderMetadataByline, - addKeybinds, - renderToolbar: providedToolbar, - } = props; - - const { filter, setFilter: updateFilter } = useFilter(); - const { effectiveFilter, result, metadataInfo, cachedResult, totalCount } = - useQueryResultContext(); - const listSelect = useListContext(); - const { - selectedIds, - getSelected, - onSelectChange, - onSelectAll, - onSelectNone, - onInvertSelection, - } = listSelect; - - // scroll to the top of the page when the page changes - useScrollToTopOnPageChange(filter.currentPage, result.loading); - - const { modal, showModal, closeModal } = useModal(); - - const metadataByline = useMemo(() => { - if (cachedResult.loading) return ""; - - return renderMetadataByline?.(cachedResult, metadataInfo) ?? ""; - }, [renderMetadataByline, cachedResult, metadataInfo]); - - const pages = Math.ceil(totalCount / filter.itemsPerPage); - - const onChangePage = useCallback( - (p: number) => { - updateFilter(filter.changePage(p)); - }, - [filter, updateFilter] - ); - - useEnsureValidPage(filter, totalCount, updateFilter); - - const showEditFilter = useCallback( - (editingCriterion?: string) => { - function onApplyEditFilter(f: ListFilterModel) { - closeModal(); - updateFilter(f); - } - - showModal( - closeModal()} - editingCriterion={editingCriterion} - /> - ); - }, - [filter, updateFilter, showModal, closeModal] - ); - - useListKeyboardShortcuts({ - currentPage: filter.currentPage, - onChangePage, - onSelectAll, - onSelectNone, - onInvertSelection, - pages, - showEditFilter, - }); - - const zoomable = - filter.displayMode === DisplayMode.Grid || - filter.displayMode === DisplayMode.Wall; - - useZoomKeybinds({ - zoomIndex: zoomable ? filter.zoomIndex : undefined, - onChangeZoom: (zoom) => updateFilter(filter.setZoom(zoom)), - }); - - useEffect(() => { - if (addKeybinds) { - const unbindExtras = addKeybinds(result, effectiveFilter, selectedIds); - return () => { - unbindExtras(); - }; - } - }, [addKeybinds, result, effectiveFilter, selectedIds]); - - const operations = useMemo(() => { - async function onOperationClicked(o: IItemListOperation) { - await o.onClick(result, effectiveFilter, selectedIds); - if (o.postRefetch) { - result.refetch(); - } - } - - return otherOperations?.map((o) => ({ - text: o.text, - onClick: () => { - onOperationClicked(o); - }, - isDisplayed: () => { - if (o.isDisplayed) { - return o.isDisplayed(result, effectiveFilter, selectedIds); - } - - return true; - }, - icon: o.icon, - buttonVariant: o.buttonVariant, - })); - }, [result, effectiveFilter, selectedIds, otherOperations]); - - function onEdit() { - if (!renderEditDialog) { - return; - } - - showModal( - renderEditDialog(getSelected(), (applied) => onEditDialogClosed(applied)) - ); - } - - function onEditDialogClosed(applied: boolean) { - if (applied) { - onSelectNone(); - } - closeModal(); - - // refetch - result.refetch(); - } - - function onDelete() { - if (!renderDeleteDialog) { - return; - } - - showModal( - renderDeleteDialog(getSelected(), (deleted) => - onDeleteDialogClosed(deleted) - ) - ); - } - - function onDeleteDialogClosed(deleted: boolean) { - if (deleted) { - onSelectNone(); - } - closeModal(); - - // refetch - result.refetch(); - } - - function onRemoveCriterion(removedCriterion: Criterion, valueIndex?: number) { - if (valueIndex === undefined) { - updateFilter( - filter.removeCriterion(removedCriterion.criterionOption.type) - ); - } else { - updateFilter( - filter.removeCustomFieldCriterion( - removedCriterion.criterionOption.type, - valueIndex - ) - ); - } - } - - function onClearAllCriteria() { - updateFilter(filter.clearCriteria()); - } - - const filterListToolbarProps: IFilteredListToolbar = { - filter, - setFilter: updateFilter, - listSelect, - showEditFilter, - view: view, - operations: operations, - zoomable: zoomable, - onEdit: renderEditDialog ? onEdit : undefined, - onDelete: renderDeleteDialog ? onDelete : undefined, - }; - - return ( -
- {providedToolbar ? ( - providedToolbar(filterListToolbarProps) - ) : ( - - )} - showEditFilter(c.criterionOption.type)} - onRemoveCriterion={onRemoveCriterion} - onRemoveAll={() => onClearAllCriteria()} - /> - {modal} - - - {renderContent( - result, - // #4780 - use effectiveFilter to ensure filterHook is applied - effectiveFilter, - selectedIds, - onSelectChange, - onChangePage, - pages - )} - -
- ); -}; - -interface IItemListContextProps< - T extends QueryResult, - E extends IHasID, - M = unknown -> { - filterMode: GQL.FilterMode; - defaultSort?: string; - defaultFilter?: ListFilterModel; - useResult: (filter: ListFilterModel) => T; - useMetadataInfo?: (filter: ListFilterModel) => M; - getCount: (data: T) => number; - getItems: (data: T) => E[]; - filterHook?: (filter: ListFilterModel) => ListFilterModel; - view?: View; - alterQuery?: boolean; - selectable?: boolean; -} - -// Provides the contexts for the ItemList component. Includes functionality to scroll -// to top on page change. -export const ItemListContext = < - T extends QueryResult, - E extends IHasID, - M = unknown ->( - props: PropsWithChildren> -) => { - const { - filterMode, - defaultSort, - defaultFilter: providedDefaultFilter, - useResult, - useMetadataInfo, - getCount, - getItems, - view, - filterHook, - alterQuery = true, - selectable, - children, - } = props; - - const { configuration: config } = useConfigurationContext(); - - const emptyFilter = useMemo( - () => - providedDefaultFilter?.clone() ?? - new ListFilterModel(filterMode, config, { - defaultSortBy: defaultSort, - }), - [config, filterMode, defaultSort, providedDefaultFilter] - ); - - const [filter, setFilterState] = useState( - () => - new ListFilterModel(filterMode, config, { defaultSortBy: defaultSort }) - ); - - const { defaultFilter } = useDefaultFilter(emptyFilter, view); - - return ( - - - - {({ items }) => ( - - {children} - - )} - - - - ); -}; - export const showWhenSelected = ( result: T, filter: ListFilterModel, diff --git a/ui/v2.5/src/components/Tags/TagList.tsx b/ui/v2.5/src/components/Tags/TagList.tsx index 61b81b727..38cc13141 100644 --- a/ui/v2.5/src/components/Tags/TagList.tsx +++ b/ui/v2.5/src/components/Tags/TagList.tsx @@ -1,9 +1,9 @@ -import React, { useState } from "react"; +import React, { useCallback, useEffect } from "react"; import cloneDeep from "lodash-es/cloneDeep"; import Mousetrap from "mousetrap"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; -import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList"; +import { useFilteredItemList } from "../List/ItemList"; import { Button } from "react-bootstrap"; import { Link, useHistory } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; @@ -11,33 +11,269 @@ import { queryFindTagsForList, mutateMetadataAutoTag, useFindTagsForList, - useTagDestroy, useTagsDestroy, } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import { FormattedMessage, FormattedNumber, useIntl } from "react-intl"; import NavUtils from "src/utils/navigation"; import { Icon } from "../Shared/Icon"; -import { ModalComponent } from "../Shared/Modal"; import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog"; import { ExportDialog } from "../Shared/ExportDialog"; import { tagRelationHook } from "../../core/tags"; import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; import { TagMergeModal } from "./TagMergeDialog"; -import { Tag } from "./TagSelect"; import { TagCardGrid } from "./TagCardGrid"; import { EditTagsDialog } from "./EditTagsDialog"; import { View } from "../List/views"; -import { IItemListOperation } from "../List/FilteredListToolbar"; -import { PatchComponent } from "src/patch"; +import { + FilteredListToolbar, + IItemListOperation, +} from "../List/FilteredListToolbar"; +import { PatchComponent, PatchContainerComponent } from "src/patch"; import { TagTagger } from "../Tagger/tags/TagTagger"; +import useFocus from "src/utils/focus"; +import { + Sidebar, + SidebarPane, + SidebarPaneContent, + SidebarStateContext, + useSidebarState, +} from "../Shared/Sidebar"; +import { useCloseEditDelete, useFilterOperations } from "../List/util"; +import { + FilteredSidebarHeader, + useFilteredSidebarKeybinds, +} from "../List/Filters/FilterSidebar"; +import { ListOperations } from "../List/ListOperationButtons"; +import cx from "classnames"; +import { FilterTags } from "../List/FilterTags"; +import { Pagination, PaginationIndex } from "../List/Pagination"; +import { LoadedContent } from "../List/PagedList"; +import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter"; +import { FavoriteTagCriterionOption } from "src/models/list-filter/criteria/favorite"; -function getItems(result: GQL.FindTagsForListQueryResult) { - return result?.data?.findTags?.tags ?? []; +const TagList: React.FC<{ + tags: GQL.TagListDataFragment[]; + filter: ListFilterModel; + selectedIds: Set; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; + onDelete: (tag: GQL.TagListDataFragment) => void; + onAutoTag: (tag: GQL.TagListDataFragment) => void; +}> = PatchComponent( + "TagList", + ({ tags, filter, selectedIds, onSelectChange, onDelete, onAutoTag }) => { + if (tags.length === 0 && filter.displayMode !== DisplayMode.Tagger) { + return null; + } + + if (filter.displayMode === DisplayMode.Grid) { + return ( + + ); + } + if (filter.displayMode === DisplayMode.List) { + const tagElements = tags.map((tag) => { + return ( +
+ {tag.name} + +
+ + + + + + + :{" "} + + + +
+
+ ); + }); + + return
{tagElements}
; + } + if (filter.displayMode === DisplayMode.Tagger) { + return ; + } + + return null; + } +); + +const TagFilterSidebarSections = PatchContainerComponent( + "FilteredTagList.SidebarSections" +); + +const SidebarContent: React.FC<{ + filter: ListFilterModel; + setFilter: (filter: ListFilterModel) => void; + filterHook?: (filter: ListFilterModel) => ListFilterModel; + view?: View; + sidebarOpen: boolean; + onClose?: () => void; + showEditFilter: (editingCriterion?: string) => void; + count?: number; + focus?: ReturnType; +}> = ({ + filter, + setFilter, + // filterHook, + view, + showEditFilter, + sidebarOpen, + onClose, + count, + focus, +}) => { + const showResultsId = + count !== undefined ? "actions.show_count_results" : "actions.show_results"; + + return ( + <> + + + + {/* */} + } + filter={filter} + setFilter={setFilter} + option={FavoriteTagCriterionOption} + sectionID="favourite" + /> + + +
+ +
+ + ); +}; + +function useViewRandom(filter: ListFilterModel, count: number) { + const history = useHistory(); + + const viewRandom = useCallback(async () => { + // query for a random tag + if (count === 0) { + return; + } + + const index = Math.floor(Math.random() * count); + const filterCopy = cloneDeep(filter); + filterCopy.itemsPerPage = 1; + filterCopy.currentPage = index + 1; + const singleResult = await queryFindTagsForList(filterCopy); + if (singleResult.data.findTags.tags.length === 1) { + const { id } = singleResult.data.findTags.tags[0]; + // navigate to the tag page + history.push(`/tags/${id}`); + } + }, [history, filter, count]); + + return viewRandom; } -function getCount(result: GQL.FindTagsForListQueryResult) { - return result?.data?.findTags?.count ?? 0; +function useAddKeybinds(filter: ListFilterModel, count: number) { + const viewRandom = useViewRandom(filter, count); + + useEffect(() => { + Mousetrap.bind("p r", () => { + viewRandom(); + }); + + return () => { + Mousetrap.unbind("p r"); + }; + }, [viewRandom]); } interface ITagList { @@ -46,105 +282,155 @@ interface ITagList { extraOperations?: IItemListOperation[]; } -export const TagList: React.FC = PatchComponent( - "TagList", - ({ filterHook, alterQuery, extraOperations = [] }) => { - const Toast = useToast(); - const [deletingTag, setDeletingTag] = - useState | null>(null); - - const filterMode = GQL.FilterMode.Tags; - const view = View.Tags; - - function getDeleteTagInput() { - const tagInput: Partial = {}; - if (deletingTag) { - tagInput.id = deletingTag.id; - } - return tagInput as GQL.TagDestroyInput; - } - const [deleteTag] = useTagDestroy(getDeleteTagInput()); - +export const FilteredTagList = PatchComponent( + "FilteredTagList", + (props: ITagList) => { const intl = useIntl(); const history = useHistory(); - const [mergeTags, setMergeTags] = useState(undefined); - const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); - const [isExportAll, setIsExportAll] = useState(false); + const Toast = useToast(); - const otherOperations = [ - ...extraOperations, - { - text: intl.formatMessage({ id: "actions.view_random" }), - onClick: viewRandom, - }, - { - text: `${intl.formatMessage({ id: "actions.merge" })}…`, - onClick: merge, - isDisplayed: showWhenSelected, - }, - { - text: intl.formatMessage({ id: "actions.export" }), - onClick: onExport, - isDisplayed: showWhenSelected, - }, - { - text: intl.formatMessage({ id: "actions.export_all" }), - onClick: onExportAll, - }, - ]; + const searchFocus = useFocus(); - function addKeybinds( - result: GQL.FindTagsForListQueryResult, - filter: ListFilterModel - ) { - Mousetrap.bind("p r", () => { - viewRandom(result, filter); + const { filterHook, alterQuery, extraOperations = [] } = props; + + const view = View.Tags; + + // States + const { + showSidebar, + setShowSidebar, + sectionOpen, + setSectionOpen, + loading: sidebarStateLoading, + } = useSidebarState(view); + + const { filterState, queryResult, modalState, listSelect, showEditFilter } = + useFilteredItemList({ + filterStateProps: { + filterMode: GQL.FilterMode.Tags, + view, + useURL: alterQuery, + }, + queryResultProps: { + useResult: useFindTagsForList, + getCount: (r) => r.data?.findTags.count ?? 0, + getItems: (r) => r.data?.findTags.tags ?? [], + filterHook, + }, + }); + + const { filter, setFilter } = filterState; + + const { effectiveFilter, result, cachedResult, items, totalCount } = + queryResult; + + const { + selectedIds, + selectedItems, + onSelectChange, + onSelectAll, + onSelectNone, + onInvertSelection, + hasSelection, + } = listSelect; + + const { modal, showModal, closeModal } = modalState; + + // Utility hooks + const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({ + filter, + setFilter, + }); + + useAddKeybinds(effectiveFilter, totalCount); + useFilteredSidebarKeybinds({ + showSidebar, + setShowSidebar, + }); + + useEffect(() => { + Mousetrap.bind("e", () => { + if (hasSelection) { + onEdit?.(); + } + }); + + Mousetrap.bind("d d", () => { + if (hasSelection) { + onDelete?.(); + } }); return () => { - Mousetrap.unbind("p r"); + Mousetrap.unbind("e"); + Mousetrap.unbind("d d"); }; + }); + + const onCloseEditDelete = useCloseEditDelete({ + closeModal, + onSelectNone, + result, + }); + + const viewRandom = useViewRandom(effectiveFilter, totalCount); + + function onExport(all: boolean) { + showModal( + closeModal()} + /> + ); } - async function viewRandom( - result: GQL.FindTagsForListQueryResult, - filter: ListFilterModel - ) { - // query for a random tag - if (result.data?.findTags) { - const { count } = result.data.findTags; - - const index = Math.floor(Math.random() * count); - const filterCopy = cloneDeep(filter); - filterCopy.itemsPerPage = 1; - filterCopy.currentPage = index + 1; - const singleResult = await queryFindTagsForList(filterCopy); - if (singleResult.data.findTags.tags.length === 1) { - const { id } = singleResult.data.findTags.tags[0]; - // navigate to the tag page - history.push(`/tags/${id}`); - } - } + function onEdit() { + showModal( + + ); } - async function merge( - result: GQL.FindTagsForListQueryResult, - filter: ListFilterModel, - selectedIds: Set - ) { - const selected = - result.data?.findTags.tags.filter((t) => selectedIds.has(t.id)) ?? []; - setMergeTags(selected); + function onDelete(tag?: GQL.TagListDataFragment) { + const itemsToDelete = tag ? [tag] : selectedItems; + + showModal( + { + itemsToDelete.forEach((t) => + tagRelationHook( + t, + { parents: t.parents ?? [], children: t.children ?? [] }, + { parents: [], children: [] } + ) + ); + }} + /> + ); } - async function onExport() { - setIsExportAll(false); - setIsExportDialogOpen(true); - } - - async function onExportAll() { - setIsExportAll(true); - setIsExportDialogOpen(true); + function onMerge() { + showModal( + { + onCloseEditDelete(); + if (mergedId) { + history.push(`/tags/${mergedId}`); + } + }} + show + /> + ); } async function onAutoTag(tag: GQL.TagListDataFragment) { @@ -157,269 +443,151 @@ export const TagList: React.FC = PatchComponent( } } - async function onDelete() { - try { - const oldRelations = { - parents: deletingTag?.parents ?? [], - children: deletingTag?.children ?? [], - }; - await deleteTag(); - tagRelationHook(deletingTag as GQL.TagListDataFragment, oldRelations, { - parents: [], - children: [], - }); - Toast.success( - intl.formatMessage( - { id: "toast.delete_past_tense" }, - { - count: 1, - singularEntity: intl.formatMessage({ id: "tag" }), - pluralEntity: intl.formatMessage({ id: "tags" }), - } - ) - ); - setDeletingTag(null); - } catch (e) { - Toast.error(e); - } - } + const convertedExtraOperations = extraOperations.map((op) => ({ + text: op.text, + onClick: () => op.onClick(result, filter, selectedIds), + isDisplayed: () => op.isDisplayed?.(result, filter, selectedIds) ?? true, + })); - function renderContent( - result: GQL.FindTagsForListQueryResult, - filter: ListFilterModel, - selectedIds: Set, - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void - ) { - function renderMergeDialog() { - if (mergeTags) { - return ( - { - setMergeTags(undefined); - if (mergedId) { - history.push(`/tags/${mergedId}`); - } - }} - show - /> - ); - } - } + const otherOperations = [ + ...convertedExtraOperations, + { + text: intl.formatMessage({ id: "actions.select_all" }), + onClick: () => onSelectAll(), + isDisplayed: () => totalCount > 0, + }, + { + text: intl.formatMessage({ id: "actions.select_none" }), + onClick: () => onSelectNone(), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.invert_selection" }), + onClick: () => onInvertSelection(), + isDisplayed: () => totalCount > 0, + }, + { + text: intl.formatMessage({ id: "actions.view_random" }), + onClick: viewRandom, + }, + { + text: `${intl.formatMessage({ id: "actions.merge" })}…`, + onClick: () => onMerge(), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.export" }), + onClick: () => onExport(false), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.export_all" }), + onClick: () => onExport(true), + }, + ]; - function maybeRenderExportDialog() { - if (isExportDialogOpen) { - return ( - setIsExportDialogOpen(false)} - /> - ); - } - } + // render + if (sidebarStateLoading) return null; - function renderTags() { - if (!result.data?.findTags) return; - - if (filter.displayMode === DisplayMode.Grid) { - return ( - - ); - } - if (filter.displayMode === DisplayMode.List) { - const deleteAlert = ( - {}} - show={!!deletingTag} - icon={faTrashAlt} - accept={{ - onClick: onDelete, - variant: "danger", - text: intl.formatMessage({ id: "actions.delete" }), - }} - cancel={{ onClick: () => setDeletingTag(null) }} - > - - - - - ); - - const tagElements = result.data.findTags.tags.map((tag) => { - return ( -
- {tag.name} - -
- - - - - - - :{" "} - - - -
-
- ); - }); - - return ( -
- {tagElements} - {deleteAlert} -
- ); - } - if (filter.displayMode === DisplayMode.Wall) { - return

TODO

; - } - if (filter.displayMode === DisplayMode.Tagger) { - return ; - } - } - return ( - <> - {renderMergeDialog()} - {maybeRenderExportDialog()} - {renderTags()} - - ); - } - - function renderEditDialog( - selectedTags: GQL.TagListDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ; - } - - function renderDeleteDialog( - selectedTags: GQL.TagListDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ( - { - selectedTags.forEach((t) => - tagRelationHook( - t, - { parents: t.parents ?? [], children: t.children ?? [] }, - { parents: [], children: [] } - ) - ); - }} - /> - ); - } + const operations = ( + + ); return ( - - - + {modal} + + + + setShowSidebar(false)}> + setShowSidebar(false)} + count={cachedResult.loading ? undefined : totalCount} + focus={searchFocus} + /> + + setShowSidebar(!showSidebar)} + > + + + showEditFilter(c.criterionOption.type)} + onRemoveCriterion={removeCriterion} + onRemoveAll={clearAllCriteria} + /> + +
+ setFilter(filter.changePage(page))} + /> + +
+ + + onDelete(tag)} + onAutoTag={(tag) => onAutoTag(tag)} + /> + + + {totalCount > filter.itemsPerPage && ( +
+
+ +
+
+ )} +
+
+
+
); } ); diff --git a/ui/v2.5/src/components/Tags/Tags.tsx b/ui/v2.5/src/components/Tags/Tags.tsx index 806a0f7a6..a4336fea9 100644 --- a/ui/v2.5/src/components/Tags/Tags.tsx +++ b/ui/v2.5/src/components/Tags/Tags.tsx @@ -4,10 +4,10 @@ import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; import Tag from "./TagDetails/Tag"; import TagCreate from "./TagDetails/TagCreate"; -import { TagList } from "./TagList"; +import { FilteredTagList } from "./TagList"; const Tags: React.FC = () => { - return ; + return ; }; const TagRoutes: React.FC = () => { diff --git a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md index 4ff8b5143..68d5676d3 100644 --- a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md +++ b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md @@ -236,6 +236,7 @@ Returns `void`. - `FilteredSceneList` - `FilteredSceneMarkerList` - `FilteredStudioList` +- `FilteredTagList` - `FolderSelect` - `FrontPage` - `GalleryCard` @@ -353,6 +354,7 @@ Returns `void`. - `TagCardGrid` - `TagIDSelect` - `TagLink` +- `TagList` - `TagRecommendationRow` - `TagSelect` - `TagSelect.sort` diff --git a/ui/v2.5/src/pluginApi.d.ts b/ui/v2.5/src/pluginApi.d.ts index 77627be10..e04d472b6 100644 --- a/ui/v2.5/src/pluginApi.d.ts +++ b/ui/v2.5/src/pluginApi.d.ts @@ -673,6 +673,7 @@ declare namespace PluginApi { FilteredSceneList: React.FC; FilteredSceneMarkerList: React.FC; FilteredStudioList: React.FC; + FilteredTagList: React.FC; FolderSelect: React.FC; FrontPage: React.FC; GalleryCard: React.FC; From d8448ba37ecf7749b4d75356b33be471ac6c8fdd Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:58:11 +1100 Subject: [PATCH 095/177] Add basename and parent_folders fields to Folder graphql interface (#6494) * Add basename field to folder * Add parent_folders field to folder * Add basename column to folder table * Add basename filter field * Create missing folder hierarchies during migration * Treat files/folders in zips where path can't be made relative as not found Addresses an issue during clean where corrupt folder entries in zip files could not be removed due to an error during the call to Rel. --- graphql/schema/types/file.graphql | 3 + graphql/schema/types/filters.graphql | 1 + internal/api/loaders/dataloaders.go | 29 +- .../folderparentfolderidsloader_gen.go | 225 ++++++++++++++ internal/api/resolver_model_folder.go | 16 + pkg/file/zip.go | 4 +- pkg/models/folder.go | 6 +- pkg/models/mocks/FolderReaderWriter.go | 23 ++ pkg/models/repository_folder.go | 1 + pkg/sqlite/database.go | 2 +- pkg/sqlite/folder.go | 87 ++++++ pkg/sqlite/folder_filter.go | 1 + pkg/sqlite/folder_filter_test.go | 11 + pkg/sqlite/folder_test.go | 74 ++++- .../migrations/84_folder_basename.up.sql | 50 +++ pkg/sqlite/migrations/84_postmigrate.go | 285 ++++++++++++++++++ pkg/sqlite/setup_test.go | 9 +- 17 files changed, 814 insertions(+), 13 deletions(-) create mode 100644 internal/api/loaders/folderparentfolderidsloader_gen.go create mode 100644 pkg/sqlite/migrations/84_folder_basename.up.sql create mode 100644 pkg/sqlite/migrations/84_postmigrate.go diff --git a/graphql/schema/types/file.graphql b/graphql/schema/types/file.graphql index 835479fad..37fb5539f 100644 --- a/graphql/schema/types/file.graphql +++ b/graphql/schema/types/file.graphql @@ -6,11 +6,14 @@ type Fingerprint { type Folder { id: ID! path: String! + basename: String! parent_folder_id: ID @deprecated(reason: "Use parent_folder instead") zip_file_id: ID @deprecated(reason: "Use zip_file instead") parent_folder: Folder + "Returns all parent folders in order from immediate parent to top-level" + parent_folders: [Folder!]! zip_file: BasicFile mod_time: Time! diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index d9814ef34..6eda473b4 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -822,6 +822,7 @@ input FolderFilterType { NOT: FolderFilterType path: StringCriterionInput + basename: StringCriterionInput parent_folder: HierarchicalMultiCriterionInput zip_file: MultiCriterionInput diff --git a/internal/api/loaders/dataloaders.go b/internal/api/loaders/dataloaders.go index dac8ba6b8..2ba650962 100644 --- a/internal/api/loaders/dataloaders.go +++ b/internal/api/loaders/dataloaders.go @@ -11,6 +11,7 @@ //go:generate go run github.com/vektah/dataloaden GroupLoader int *github.com/stashapp/stash/pkg/models.Group //go:generate go run github.com/vektah/dataloaden FileLoader github.com/stashapp/stash/pkg/models.FileID github.com/stashapp/stash/pkg/models.File //go:generate go run github.com/vektah/dataloaden FolderLoader github.com/stashapp/stash/pkg/models.FolderID *github.com/stashapp/stash/pkg/models.Folder +//go:generate go run github.com/vektah/dataloaden FolderParentFolderIDsLoader github.com/stashapp/stash/pkg/models.FolderID []github.com/stashapp/stash/pkg/models.FolderID //go:generate go run github.com/vektah/dataloaden SceneFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID //go:generate go run github.com/vektah/dataloaden ImageFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID //go:generate go run github.com/vektah/dataloaden GalleryFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID @@ -65,12 +66,16 @@ type Loaders struct { StudioByID *StudioLoader StudioCustomFields *CustomFieldsLoader - TagByID *TagLoader - TagCustomFields *CustomFieldsLoader + TagByID *TagLoader + TagCustomFields *CustomFieldsLoader + GroupByID *GroupLoader GroupCustomFields *CustomFieldsLoader - FileByID *FileLoader - FolderByID *FolderLoader + + FileByID *FileLoader + + FolderByID *FolderLoader + FolderParentFolderIDs *FolderParentFolderIDsLoader } type Middleware struct { @@ -161,6 +166,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler { maxBatch: maxBatch, fetch: m.fetchFolders(ctx), }, + FolderParentFolderIDs: &FolderParentFolderIDsLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchFoldersParentFolderIDs(ctx), + }, SceneFiles: &SceneFileIDsLoader{ wait: wait, maxBatch: maxBatch, @@ -406,6 +416,17 @@ func (m Middleware) fetchFolders(ctx context.Context) func(keys []models.FolderI } } +func (m Middleware) fetchFoldersParentFolderIDs(ctx context.Context) func(keys []models.FolderID) ([][]models.FolderID, []error) { + return func(keys []models.FolderID) (ret [][]models.FolderID, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Folder.GetManyParentFolderIDs(ctx, keys) + return err + }) + return ret, toErrorSlice(err) + } +} + func (m Middleware) fetchScenesFileIDs(ctx context.Context) func(keys []int) ([][]models.FileID, []error) { return func(keys []int) (ret [][]models.FileID, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { diff --git a/internal/api/loaders/folderparentfolderidsloader_gen.go b/internal/api/loaders/folderparentfolderidsloader_gen.go new file mode 100644 index 000000000..c9eca3a3d --- /dev/null +++ b/internal/api/loaders/folderparentfolderidsloader_gen.go @@ -0,0 +1,225 @@ +// Code generated by github.com/vektah/dataloaden, DO NOT EDIT. + +package loaders + +import ( + "sync" + "time" + + "github.com/stashapp/stash/pkg/models" +) + +// FolderParentFolderIDsLoaderConfig captures the config to create a new FolderParentFolderIDsLoader +type FolderParentFolderIDsLoaderConfig struct { + // Fetch is a method that provides the data for the loader + Fetch func(keys []models.FolderID) ([][]models.FolderID, []error) + + // Wait is how long wait before sending a batch + Wait time.Duration + + // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit + MaxBatch int +} + +// NewFolderParentFolderIDsLoader creates a new FolderParentFolderIDsLoader given a fetch, wait, and maxBatch +func NewFolderParentFolderIDsLoader(config FolderParentFolderIDsLoaderConfig) *FolderParentFolderIDsLoader { + return &FolderParentFolderIDsLoader{ + fetch: config.Fetch, + wait: config.Wait, + maxBatch: config.MaxBatch, + } +} + +// FolderParentFolderIDsLoader batches and caches requests +type FolderParentFolderIDsLoader struct { + // this method provides the data for the loader + fetch func(keys []models.FolderID) ([][]models.FolderID, []error) + + // how long to done before sending a batch + wait time.Duration + + // this will limit the maximum number of keys to send in one batch, 0 = no limit + maxBatch int + + // INTERNAL + + // lazily created cache + cache map[models.FolderID][]models.FolderID + + // the current batch. keys will continue to be collected until timeout is hit, + // then everything will be sent to the fetch method and out to the listeners + batch *folderParentFolderIDsLoaderBatch + + // mutex to prevent races + mu sync.Mutex +} + +type folderParentFolderIDsLoaderBatch struct { + keys []models.FolderID + data [][]models.FolderID + error []error + closing bool + done chan struct{} +} + +// Load a FolderID by key, batching and caching will be applied automatically +func (l *FolderParentFolderIDsLoader) Load(key models.FolderID) ([]models.FolderID, error) { + return l.LoadThunk(key)() +} + +// LoadThunk returns a function that when called will block waiting for a FolderID. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *FolderParentFolderIDsLoader) LoadThunk(key models.FolderID) func() ([]models.FolderID, error) { + l.mu.Lock() + if it, ok := l.cache[key]; ok { + l.mu.Unlock() + return func() ([]models.FolderID, error) { + return it, nil + } + } + if l.batch == nil { + l.batch = &folderParentFolderIDsLoaderBatch{done: make(chan struct{})} + } + batch := l.batch + pos := batch.keyIndex(l, key) + l.mu.Unlock() + + return func() ([]models.FolderID, error) { + <-batch.done + + var data []models.FolderID + if pos < len(batch.data) { + data = batch.data[pos] + } + + var err error + // its convenient to be able to return a single error for everything + if len(batch.error) == 1 { + err = batch.error[0] + } else if batch.error != nil { + err = batch.error[pos] + } + + if err == nil { + l.mu.Lock() + l.unsafeSet(key, data) + l.mu.Unlock() + } + + return data, err + } +} + +// LoadAll fetches many keys at once. It will be broken into appropriate sized +// sub batches depending on how the loader is configured +func (l *FolderParentFolderIDsLoader) LoadAll(keys []models.FolderID) ([][]models.FolderID, []error) { + results := make([]func() ([]models.FolderID, error), len(keys)) + + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + + folderIDs := make([][]models.FolderID, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + folderIDs[i], errors[i] = thunk() + } + return folderIDs, errors +} + +// LoadAllThunk returns a function that when called will block waiting for a FolderIDs. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *FolderParentFolderIDsLoader) LoadAllThunk(keys []models.FolderID) func() ([][]models.FolderID, []error) { + results := make([]func() ([]models.FolderID, error), len(keys)) + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + return func() ([][]models.FolderID, []error) { + folderIDs := make([][]models.FolderID, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + folderIDs[i], errors[i] = thunk() + } + return folderIDs, errors + } +} + +// Prime the cache with the provided key and value. If the key already exists, no change is made +// and false is returned. +// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) +func (l *FolderParentFolderIDsLoader) Prime(key models.FolderID, value []models.FolderID) bool { + l.mu.Lock() + var found bool + if _, found = l.cache[key]; !found { + // make a copy when writing to the cache, its easy to pass a pointer in from a loop var + // and end up with the whole cache pointing to the same value. + cpy := make([]models.FolderID, len(value)) + copy(cpy, value) + l.unsafeSet(key, cpy) + } + l.mu.Unlock() + return !found +} + +// Clear the value at key from the cache, if it exists +func (l *FolderParentFolderIDsLoader) Clear(key models.FolderID) { + l.mu.Lock() + delete(l.cache, key) + l.mu.Unlock() +} + +func (l *FolderParentFolderIDsLoader) unsafeSet(key models.FolderID, value []models.FolderID) { + if l.cache == nil { + l.cache = map[models.FolderID][]models.FolderID{} + } + l.cache[key] = value +} + +// keyIndex will return the location of the key in the batch, if its not found +// it will add the key to the batch +func (b *folderParentFolderIDsLoaderBatch) keyIndex(l *FolderParentFolderIDsLoader, key models.FolderID) int { + for i, existingKey := range b.keys { + if key == existingKey { + return i + } + } + + pos := len(b.keys) + b.keys = append(b.keys, key) + if pos == 0 { + go b.startTimer(l) + } + + if l.maxBatch != 0 && pos >= l.maxBatch-1 { + if !b.closing { + b.closing = true + l.batch = nil + go b.end(l) + } + } + + return pos +} + +func (b *folderParentFolderIDsLoaderBatch) startTimer(l *FolderParentFolderIDsLoader) { + time.Sleep(l.wait) + l.mu.Lock() + + // we must have hit a batch limit and are already finalizing this batch + if b.closing { + l.mu.Unlock() + return + } + + l.batch = nil + l.mu.Unlock() + + b.end(l) +} + +func (b *folderParentFolderIDsLoaderBatch) end(l *FolderParentFolderIDsLoader) { + b.data, b.error = l.fetch(b.keys) + close(b.done) +} diff --git a/internal/api/resolver_model_folder.go b/internal/api/resolver_model_folder.go index ee6bbfd05..c203a3f82 100644 --- a/internal/api/resolver_model_folder.go +++ b/internal/api/resolver_model_folder.go @@ -2,11 +2,16 @@ package api import ( "context" + "path/filepath" "github.com/stashapp/stash/internal/api/loaders" "github.com/stashapp/stash/pkg/models" ) +func (r *folderResolver) Basename(ctx context.Context, obj *models.Folder) (string, error) { + return filepath.Base(obj.Path), nil +} + func (r *folderResolver) ParentFolder(ctx context.Context, obj *models.Folder) (*models.Folder, error) { if obj.ParentFolderID == nil { return nil, nil @@ -15,6 +20,17 @@ func (r *folderResolver) ParentFolder(ctx context.Context, obj *models.Folder) ( return loaders.From(ctx).FolderByID.Load(*obj.ParentFolderID) } +func (r *folderResolver) ParentFolders(ctx context.Context, obj *models.Folder) ([]*models.Folder, error) { + ids, err := loaders.From(ctx).FolderParentFolderIDs.Load(obj.ID) + if err != nil { + return nil, err + } + + var errs []error + ret, errs := loaders.From(ctx).FolderByID.LoadAll(ids) + return ret, firstError(errs) +} + func (r *folderResolver) ZipFile(ctx context.Context, obj *models.Folder) (*BasicFile, error) { return zipFileResolver(ctx, obj.ZipFileID) } diff --git a/pkg/file/zip.go b/pkg/file/zip.go index 5afcd5329..6d00c7e35 100644 --- a/pkg/file/zip.go +++ b/pkg/file/zip.go @@ -99,7 +99,9 @@ func (f *zipFS) rel(name string) (string, error) { relName, err := filepath.Rel(f.zipPath, name) if err != nil { - return "", fmt.Errorf("internal error getting relative path: %w", err) + // if the path is not relative to the zip path, then it's not found in the zip file, + // so treat this as a file not found + return "", fs.ErrNotExist } // convert relName to use slash, since zip files do so regardless diff --git a/pkg/models/folder.go b/pkg/models/folder.go index ada9e17b7..e9e9a3971 100644 --- a/pkg/models/folder.go +++ b/pkg/models/folder.go @@ -18,10 +18,8 @@ type FolderQueryOptions struct { type FolderFilterType struct { OperatorFilter[FolderFilterType] - Path *StringCriterionInput `json:"path,omitempty"` - Basename *StringCriterionInput `json:"basename,omitempty"` - // Filter by parent directory path - Dir *StringCriterionInput `json:"dir,omitempty"` + Path *StringCriterionInput `json:"path,omitempty"` + Basename *StringCriterionInput `json:"basename,omitempty"` ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder,omitempty"` ZipFile *MultiCriterionInput `json:"zip_file,omitempty"` // Filter by modification time diff --git a/pkg/models/mocks/FolderReaderWriter.go b/pkg/models/mocks/FolderReaderWriter.go index 7bca013fe..5d4d95027 100644 --- a/pkg/models/mocks/FolderReaderWriter.go +++ b/pkg/models/mocks/FolderReaderWriter.go @@ -201,6 +201,29 @@ func (_m *FolderReaderWriter) FindMany(ctx context.Context, id []models.FolderID return r0, r1 } +// GetManyParentFolderIDs provides a mock function with given fields: ctx, folderIDs +func (_m *FolderReaderWriter) GetManyParentFolderIDs(ctx context.Context, folderIDs []models.FolderID) ([][]models.FolderID, error) { + ret := _m.Called(ctx, folderIDs) + + var r0 [][]models.FolderID + if rf, ok := ret.Get(0).(func(context.Context, []models.FolderID) [][]models.FolderID); ok { + r0 = rf(ctx, folderIDs) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([][]models.FolderID) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, []models.FolderID) error); ok { + r1 = rf(ctx, folderIDs) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Query provides a mock function with given fields: ctx, options func (_m *FolderReaderWriter) Query(ctx context.Context, options models.FolderQueryOptions) (*models.FolderQueryResult, error) { ret := _m.Called(ctx, options) diff --git a/pkg/models/repository_folder.go b/pkg/models/repository_folder.go index 3d0fdb822..539d51cb9 100644 --- a/pkg/models/repository_folder.go +++ b/pkg/models/repository_folder.go @@ -15,6 +15,7 @@ type FolderFinder interface { FindByPath(ctx context.Context, path string, caseSensitive bool) (*Folder, error) FindByZipFileID(ctx context.Context, zipFileID FileID) ([]*Folder, error) FindByParentFolderID(ctx context.Context, parentFolderID FolderID) ([]*Folder, error) + GetManyParentFolderIDs(ctx context.Context, folderIDs []FolderID) ([][]FolderID, error) } type FolderQueryer interface { diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 003c6eebc..f8c2cdef7 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 83 +var appSchemaVersion uint = 84 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/folder.go b/pkg/sqlite/folder.go index f250f7861..73a065cff 100644 --- a/pkg/sqlite/folder.go +++ b/pkg/sqlite/folder.go @@ -20,6 +20,7 @@ const folderIDColumn = "folder_id" type folderRow struct { ID models.FolderID `db:"id" goqu:"skipinsert"` + Basename string `db:"basename"` Path string `db:"path"` ZipFileID null.Int `db:"zip_file_id"` ParentFolderID null.Int `db:"parent_folder_id"` @@ -30,6 +31,8 @@ type folderRow struct { func (r *folderRow) fromFolder(o models.Folder) { r.ID = o.ID + // derive basename from path + r.Basename = filepath.Base(o.Path) r.Path = o.Path r.ZipFileID = nullIntFromFileIDPtr(o.ZipFileID) r.ParentFolderID = nullIntFromFolderIDPtr(o.ParentFolderID) @@ -322,6 +325,90 @@ func (qb *FolderStore) FindByParentFolderID(ctx context.Context, parentFolderID return ret, nil } +func (qb *FolderStore) GetManyParentFolderIDs(ctx context.Context, folderIDs []models.FolderID) ([][]models.FolderID, error) { + table := qb.table() + + // SQL recursive query to get all parent folder IDs for each folder ID + /* + WITH RECURSIVE parent_folders AS ( + SELECT id, parent_folder_id + FROM folders + WHERE id IN (folderIDs) + + UNION ALL + + SELECT f.id, f.parent_folder_id + FROM folders f + INNER JOIN parent_folders pf ON f.id = pf.parent_folder_id + ) + SELECT id, parent_folder_id FROM parent_folders; + */ + const parentFolders = "parent_folders" + const parentFolderID = "parent_folder_id" + const parentID = "parent_id" + const foldersAlias = "f" + + const parentFoldersAlias = "pf" + foldersAliasedI := table.As(foldersAlias) + parentFoldersI := goqu.T(parentFolders).As(parentFoldersAlias) + + q := dialect.From(parentFolders).Prepared(true). + WithRecursive(parentFolders, + dialect.From(table).Select(table.Col(idColumn), table.Col(parentFolderID).As(parentID)). + Where(table.Col(idColumn).In(folderIDs)). + Union( + dialect.From(foldersAliasedI).InnerJoin( + parentFoldersI, + goqu.On(foldersAliasedI.Col(idColumn).Eq(parentFoldersI.Col(parentID))), + ).Select(foldersAliasedI.Col(idColumn), foldersAliasedI.Col(parentFolderID).As(parentID)), + ), + ).Select(idColumn, parentID) + + type resultRow struct { + FolderID models.FolderID `db:"id"` + ParentFolderID null.Int `db:"parent_id"` + } + + folderMap := make(map[models.FolderID]models.FolderID) + + if err := queryFunc(ctx, q, false, func(r *sqlx.Rows) error { + var row resultRow + if err := r.StructScan(&row); err != nil { + return err + } + + if row.ParentFolderID.Valid { + folderMap[row.FolderID] = models.FolderID(row.ParentFolderID.Int64) + } else { + folderMap[row.FolderID] = 0 + } + + return nil + }); err != nil { + return nil, err + } + + ret := make([][]models.FolderID, len(folderIDs)) + + for i, folderID := range folderIDs { + var parents []models.FolderID + currentID := folderID + + for { + parentID, exists := folderMap[currentID] + if !exists || parentID == 0 { + break + } + parents = append(parents, parentID) + currentID = parentID + } + + ret[i] = parents + } + + return ret, nil +} + func (qb *FolderStore) allInPaths(q *goqu.SelectDataset, p []string) *goqu.SelectDataset { table := qb.table() diff --git a/pkg/sqlite/folder_filter.go b/pkg/sqlite/folder_filter.go index 6b2bd96e9..e0145bcca 100644 --- a/pkg/sqlite/folder_filter.go +++ b/pkg/sqlite/folder_filter.go @@ -65,6 +65,7 @@ func (qb *folderFilterHandler) criterionHandler() criterionHandler { folderFilter := qb.folderFilter return compoundHandler{ stringCriterionHandler(folderFilter.Path, qb.table.Col("path")), + stringCriterionHandler(folderFilter.Basename, qb.table.Col("basename")), ×tampCriterionHandler{folderFilter.ModTime, qb.table.Col("mod_time"), nil}, qb.parentFolderCriterionHandler(folderFilter.ParentFolder), diff --git a/pkg/sqlite/folder_filter_test.go b/pkg/sqlite/folder_filter_test.go index c1c7d7a37..c08208f30 100644 --- a/pkg/sqlite/folder_filter_test.go +++ b/pkg/sqlite/folder_filter_test.go @@ -33,6 +33,17 @@ func TestFolderQuery(t *testing.T) { includeIdxs: []int{folderIdxWithSubFolder, folderIdxWithParentFolder}, excludeIdxs: []int{folderIdxInZip}, }, + { + name: "basename", + filter: &models.FolderFilterType{ + Basename: &models.StringCriterionInput{ + Value: getFolderBasename(folderIdxWithParentFolder, nil), + Modifier: models.CriterionModifierIncludes, + }, + }, + includeIdxs: []int{folderIdxWithParentFolder}, + excludeIdxs: []int{folderIdxInZip}, + }, { name: "parent folder", filter: &models.FolderFilterType{ diff --git a/pkg/sqlite/folder_test.go b/pkg/sqlite/folder_test.go index 15b2b96b8..072a1167f 100644 --- a/pkg/sqlite/folder_test.go +++ b/pkg/sqlite/folder_test.go @@ -186,8 +186,6 @@ func Test_FolderStore_Update(t *testing.T) { } assert.Equal(copy, *s) - - return }) } } @@ -239,3 +237,75 @@ func Test_FolderStore_FindByPath(t *testing.T) { }) } } + +func Test_FolderStore_GetManyParentFolderIDs(t *testing.T) { + var empty []models.FolderID + emptyResult := [][]models.FolderID{empty} + tests := []struct { + name string + parentFolderIDs []models.FolderID + want [][]models.FolderID + wantErr bool + }{ + { + "valid with parent folders", + []models.FolderID{folderIDs[folderIdxWithParentFolder]}, + [][]models.FolderID{ + { + folderIDs[folderIdxWithSubFolder], + folderIDs[folderIdxRoot], + }, + }, + false, + }, + { + "valid multiple folders", + []models.FolderID{ + folderIDs[folderIdxWithParentFolder], + folderIDs[folderIdxWithSceneFiles], + }, + [][]models.FolderID{ + { + folderIDs[folderIdxWithSubFolder], + folderIDs[folderIdxRoot], + }, + { + folderIDs[folderIdxForObjectFiles], + folderIDs[folderIdxRoot], + }, + }, + false, + }, + { + "valid without parent folders", + []models.FolderID{folderIDs[folderIdxRoot]}, + emptyResult, + false, + }, + { + "invalid folder id", + []models.FolderID{invalidFolderID}, + emptyResult, + // does not error, just returns empty result + false, + }, + } + + qb := db.Folder + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + got, err := qb.GetManyParentFolderIDs(ctx, tt.parentFolderIDs) + if (err != nil) != tt.wantErr { + assert.Errorf(err, "FolderStore.GetManyParentFolderIDs() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + + assert.Equal(got, tt.want) + }) + } +} diff --git a/pkg/sqlite/migrations/84_folder_basename.up.sql b/pkg/sqlite/migrations/84_folder_basename.up.sql new file mode 100644 index 000000000..5cfd5c2d9 --- /dev/null +++ b/pkg/sqlite/migrations/84_folder_basename.up.sql @@ -0,0 +1,50 @@ +-- we cannot add basename column directly because we require it to be NOT NULL +-- recreate folders table with basename column +PRAGMA foreign_keys=OFF; + +CREATE TABLE `folders_new` ( + `id` integer not null primary key autoincrement, + `basename` varchar(255) NOT NULL, + `path` varchar(255) NOT NULL, + `parent_folder_id` integer, + `zip_file_id` integer REFERENCES `files`(`id`), + `mod_time` datetime not null, + `created_at` datetime not null, + `updated_at` datetime not null, + foreign key(`parent_folder_id`) references `folders`(`id`) on delete SET NULL +); + +-- copy data from old table to new table, setting basename to path temporarily +INSERT INTO `folders_new` ( + `id`, + `basename`, + `path`, + `parent_folder_id`, + `zip_file_id`, + `mod_time`, + `created_at`, + `updated_at` +) SELECT + `id`, + `path`, + `path`, + `parent_folder_id`, + `zip_file_id`, + `mod_time`, + `created_at`, + `updated_at` +FROM `folders`; + +DROP INDEX IF EXISTS `index_folders_on_parent_folder_id`; +DROP INDEX IF EXISTS `index_folders_on_path_unique`; +DROP INDEX IF EXISTS `index_folders_on_zip_file_id`; +DROP TABLE `folders`; + +ALTER TABLE `folders_new` RENAME TO `folders`; + +CREATE UNIQUE INDEX `index_folders_on_path_unique` on `folders` (`path`); +CREATE UNIQUE INDEX `index_folders_on_parent_folder_id_basename_unique` on `folders` (`parent_folder_id`, `basename`); +CREATE INDEX `index_folders_on_zip_file_id` on `folders` (`zip_file_id`) WHERE `zip_file_id` IS NOT NULL; +CREATE INDEX `index_folders_on_basename` on `folders` (`basename`); + +PRAGMA foreign_keys=ON; \ No newline at end of file diff --git a/pkg/sqlite/migrations/84_postmigrate.go b/pkg/sqlite/migrations/84_postmigrate.go new file mode 100644 index 000000000..71b0feeb0 --- /dev/null +++ b/pkg/sqlite/migrations/84_postmigrate.go @@ -0,0 +1,285 @@ +package migrations + +import ( + "context" + "database/sql" + "errors" + "fmt" + "path/filepath" + "slices" + "time" + + "github.com/jmoiron/sqlx" + "github.com/stashapp/stash/internal/manager/config" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/sqlite" + "gopkg.in/guregu/null.v4" +) + +func post84(ctx context.Context, db *sqlx.DB) error { + logger.Info("Running post-migration for schema version 76") + + m := schema84Migrator{ + migrator: migrator{ + db: db, + }, + folderCache: make(map[string]folderInfo), + } + + rootPaths := config.GetInstance().GetStashPaths().Paths() + + if err := m.createMissingFolderHierarchies(ctx, rootPaths); err != nil { + return fmt.Errorf("creating missing folder hierarchies: %w", err) + } + + if err := m.migrateFolders(ctx); err != nil { + return fmt.Errorf("migrating folders: %w", err) + } + + return nil +} + +type schema84Migrator struct { + migrator + folderCache map[string]folderInfo +} + +func (m *schema84Migrator) createMissingFolderHierarchies(ctx context.Context, rootPaths []string) error { + // before we set the basenames, we need to address any folders that are missing their + // parent folders. + const ( + limit = 1000 + logEvery = 10000 + ) + + lastID := 0 + count := 0 + logged := false + + for { + gotSome := false + + if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { + query := "SELECT `folders`.`id`, `folders`.`path` FROM `folders` WHERE `folders`.`parent_folder_id` IS NULL " + + if lastID != 0 { + query += fmt.Sprintf("AND `folders`.`id` > %d ", lastID) + } + + query += fmt.Sprintf("ORDER BY `folders`.`id` LIMIT %d", limit) + + rows, err := tx.Query(query) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + // log once if we find any folders with missing parent folders + if !logged { + logger.Info("Migrating folders with missing parents...") + logged = true + } + + var id int + var p string + + err := rows.Scan(&id, &p) + if err != nil { + return err + } + + lastID = id + gotSome = true + count++ + + // don't try to create parent folders for root paths + if slices.Contains(rootPaths, p) { + continue + } + + parentDir := filepath.Dir(p) + if parentDir == p { + // this can happen if the path is something like "C:\", where the parent directory is the same as the current directory + continue + } + + parentID, err := m.getOrCreateFolderHierarchy(tx, parentDir, rootPaths) + if err != nil { + return fmt.Errorf("error creating parent folder for folder %d %q: %w", id, p, err) + } + + if parentID == nil { + continue + } + + // now set the parent folder ID for the current folder + logger.Debugf("Migrating folder %d %q: setting parent folder ID to %d", id, p, *parentID) + + _, err = tx.Exec("UPDATE `folders` SET `parent_folder_id` = ? WHERE `id` = ?", *parentID, id) + if err != nil { + return fmt.Errorf("error setting parent folder for folder %d %q: %w", id, p, err) + } + } + + return rows.Err() + }); err != nil { + return err + } + + if !gotSome { + break + } + + if count%logEvery == 0 { + logger.Infof("Migrated %d folders", count) + } + } + + return nil +} + +func (m *schema84Migrator) findFolderByPath(tx *sqlx.Tx, path string) (*int, error) { + query := "SELECT `folders`.`id` FROM `folders` WHERE `folders`.`path` = ?" + + var id int + if err := tx.Get(&id, query, path); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + + return nil, err + } + + return &id, nil +} + +// this is a copy of the GetOrCreateFolderHierarchy function from pkg/file/folder.go, +// but modified to use low-level SQL queries instead of the models.FolderFinderCreator interface, to avoid +func (m *schema84Migrator) getOrCreateFolderHierarchy(tx *sqlx.Tx, path string, rootPaths []string) (*int, error) { + // get or create folder hierarchy + folderID, err := m.findFolderByPath(tx, path) + if err != nil { + return nil, err + } + + if folderID == nil { + var parentID *int + + if !slices.Contains(rootPaths, path) { + parentPath := filepath.Dir(path) + + // it's possible that the parent path is the same as the current path, if there are folders outside + // of the root paths. In that case, we should just return nil for the parent ID. + if parentPath == path { + return nil, nil + } + + parentID, err = m.getOrCreateFolderHierarchy(tx, parentPath, rootPaths) + if err != nil { + return nil, err + } + } + + logger.Debugf("%s doesn't exist. Creating new folder entry...", path) + + // we need to set basename to path, which will be addressed in the next step + const insertSQL = "INSERT INTO `folders` (`path`,`basename`,`parent_folder_id`,`mod_time`,`created_at`,`updated_at`) VALUES (?,?,?,?,?,?)" + + var parentFolderID null.Int + if parentID != nil { + parentFolderID = null.IntFrom(int64(*parentID)) + } + + now := time.Now() + result, err := tx.Exec(insertSQL, path, path, parentFolderID, time.Time{}, now, now) + if err != nil { + return nil, fmt.Errorf("creating folder %s: %w", path, err) + } + + id, err := result.LastInsertId() + if err != nil { + return nil, fmt.Errorf("creating folder %s: %w", path, err) + } + + idInt := int(id) + folderID = &idInt + } + + return folderID, nil +} + +func (m *schema84Migrator) migrateFolders(ctx context.Context) error { + const ( + limit = 1000 + logEvery = 10000 + ) + + lastID := 0 + count := 0 + logged := false + + for { + gotSome := false + + if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { + query := "SELECT `folders`.`id`, `folders`.`path` FROM `folders` " + + if lastID != 0 { + query += fmt.Sprintf("WHERE `folders`.`id` > %d ", lastID) + } + + query += fmt.Sprintf("ORDER BY `folders`.`id` LIMIT %d", limit) + + rows, err := tx.Query(query) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + if !logged { + logger.Infof("Migrating folders to set basenames...") + logged = true + } + + var id int + var p string + + err := rows.Scan(&id, &p) + if err != nil { + return err + } + + lastID = id + gotSome = true + count++ + + basename := filepath.Base(p) + logger.Debugf("Migrating folder %d %q: setting basename to %q", id, p, basename) + _, err = tx.Exec("UPDATE `folders` SET `basename` = ? WHERE `id` = ?", basename, id) + if err != nil { + return fmt.Errorf("error migrating folder %d %q: %w", id, p, err) + } + } + + return rows.Err() + }); err != nil { + return err + } + + if !gotSome { + break + } + + if count%logEvery == 0 { + logger.Infof("Migrated %d folders", count) + } + } + + return nil +} + +func init() { + sqlite.RegisterPostMigration(84, post84) +} diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 2848a0a14..d8baae3b8 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -31,7 +31,8 @@ const ( ) const ( - folderIdxWithSubFolder = iota + folderIdxRoot = iota + folderIdxWithSubFolder folderIdxWithParentFolder folderIdxWithFiles folderIdxInZip @@ -359,6 +360,8 @@ func (m linkMap) reverseLookup(idx int) []int { var ( folderParentFolders = map[int]int{ + folderIdxWithSubFolder: folderIdxRoot, + folderIdxForObjectFiles: folderIdxRoot, folderIdxWithParentFolder: folderIdxWithSubFolder, folderIdxWithSceneFiles: folderIdxForObjectFiles, folderIdxWithImageFiles: folderIdxForObjectFiles, @@ -785,6 +788,10 @@ func getFolderPath(index int, parentFolderIdx *int) string { return path } +func getFolderBasename(index int, parentFolderIdx *int) string { + return filepath.Base(getFolderPath(index, parentFolderIdx)) +} + func getFolderModTime(index int) time.Time { return time.Date(2000, 1, (index%10)+1, 0, 0, 0, 0, time.UTC) } From 3b8f6bd94c4f9efddd5f517ee9a0f1b22b285abd Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:11:13 -0800 Subject: [PATCH 096/177] update logs and fix UNIQUE constraint failure (#6617) --- pkg/sqlite/migrations/84_postmigrate.go | 102 +++++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/pkg/sqlite/migrations/84_postmigrate.go b/pkg/sqlite/migrations/84_postmigrate.go index 71b0feeb0..3be0dd22e 100644 --- a/pkg/sqlite/migrations/84_postmigrate.go +++ b/pkg/sqlite/migrations/84_postmigrate.go @@ -17,7 +17,7 @@ import ( ) func post84(ctx context.Context, db *sqlx.DB) error { - logger.Info("Running post-migration for schema version 76") + logger.Info("Running post-migration for schema version 84") m := schema84Migrator{ migrator: migrator{ @@ -32,6 +32,10 @@ func post84(ctx context.Context, db *sqlx.DB) error { return fmt.Errorf("creating missing folder hierarchies: %w", err) } + if err := m.fixIncorrectParents(ctx, rootPaths); err != nil { + return fmt.Errorf("fixing incorrect parent folders: %w", err) + } + if err := m.migrateFolders(ctx); err != nil { return fmt.Errorf("migrating folders: %w", err) } @@ -209,6 +213,102 @@ func (m *schema84Migrator) getOrCreateFolderHierarchy(tx *sqlx.Tx, path string, return folderID, nil } +func (m *schema84Migrator) fixIncorrectParents(ctx context.Context, rootPaths []string) error { + const ( + limit = 1000 + logEvery = 10000 + ) + + lastID := 0 + count := 0 + fixed := 0 + logged := false + + for { + gotSome := false + + if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { + query := "SELECT f.id, f.path, f.parent_folder_id, pf.path AS parent_path " + + "FROM folders f " + + "JOIN folders pf ON f.parent_folder_id = pf.id " + + if lastID != 0 { + query += fmt.Sprintf("WHERE f.id > %d ", lastID) + } + + query += fmt.Sprintf("ORDER BY f.id LIMIT %d", limit) + + rows, err := tx.Query(query) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var id int + var p string + var parentFolderID int + var parentPath string + + err := rows.Scan(&id, &p, &parentFolderID, &parentPath) + if err != nil { + return err + } + + lastID = id + gotSome = true + count++ + + expectedParent := filepath.Dir(p) + if expectedParent == parentPath { + continue + } + + if !logged { + logger.Info("Fixing folders with incorrect parent folder assignments...") + logged = true + } + + correctParentID, err := m.getOrCreateFolderHierarchy(tx, expectedParent, rootPaths) + if err != nil { + return fmt.Errorf("error getting/creating correct parent for folder %d %q: %w", id, p, err) + } + + if correctParentID == nil { + continue + } + + logger.Debugf("Fixing folder %d %q: changing parent_folder_id from %d to %d", id, p, parentFolderID, *correctParentID) + + _, err = tx.Exec("UPDATE `folders` SET `parent_folder_id` = ? WHERE `id` = ?", *correctParentID, id) + if err != nil { + return fmt.Errorf("error fixing parent folder for folder %d %q: %w", id, p, err) + } + + fixed++ + } + + return rows.Err() + }); err != nil { + return err + } + + if !gotSome { + break + } + + if count%logEvery == 0 { + logger.Infof("Checked %d folders", count) + } + } + + if fixed > 0 { + logger.Infof("Fixed %d folders with incorrect parent assignments", fixed) + } + + return nil +} + func (m *schema84Migrator) migrateFolders(ctx context.Context) error { const ( limit = 1000 From c7e1c3da69bf51ea39e54836b080fcf487bfac06 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Sat, 28 Feb 2026 10:51:02 +1100 Subject: [PATCH 097/177] Fix panic when library path has trailing path separator (#6619) * Replace panic with warning if creating a folder hierarchy where parent is equal to current * Clean stash paths so that comparison works correctly when creating folder hierarchies --- internal/manager/config/stash_config.go | 3 ++- pkg/file/folder.go | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/manager/config/stash_config.go b/internal/manager/config/stash_config.go index 3854c707b..7a103631c 100644 --- a/internal/manager/config/stash_config.go +++ b/internal/manager/config/stash_config.go @@ -42,7 +42,8 @@ func (s StashConfigs) GetStashFromDirPath(dirPath string) *StashConfig { func (s StashConfigs) Paths() []string { paths := make([]string, len(s)) for i, c := range s { - paths[i] = c.Path + // #6618 - clean the path to ensure comparison works correctly + paths[i] = filepath.Clean(c.Path) } return paths } diff --git a/pkg/file/folder.go b/pkg/file/folder.go index e3e14186b..249f73a7a 100644 --- a/pkg/file/folder.go +++ b/pkg/file/folder.go @@ -33,7 +33,10 @@ func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreat // safety check - don't allow parent path to be the same as the current path, // otherwise we could end up in an infinite loop if parentPath == path { - panic(fmt.Sprintf("parent path is the same as the current path: %s", path)) + // #6618 - log a warning and return nil for the parent ID, + // which will cause the folder to be created with no parent + logger.Warnf("parent path is the same as the current path: %s", path) + return nil, nil } parent, err := GetOrCreateFolderHierarchy(ctx, fc, parentPath, rootPaths) From c874bd560e24692d11585aff3b1397b09e8d1a4c Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:05:13 -0800 Subject: [PATCH 098/177] Fix: Custom Field Filtering (#6614) * add tests * Refactor queryBuilder: split args into per-clause fields --- pkg/sqlite/criterion_handlers.go | 2 +- pkg/sqlite/file.go | 2 +- pkg/sqlite/folder.go | 2 +- pkg/sqlite/image.go | 2 +- pkg/sqlite/query.go | 38 +++++++++++++------- pkg/sqlite/scene.go | 2 +- pkg/sqlite/tag_test.go | 59 ++++++++++++++++++++++++++++++++ 7 files changed, 89 insertions(+), 18 deletions(-) diff --git a/pkg/sqlite/criterion_handlers.go b/pkg/sqlite/criterion_handlers.go index 943704cfe..ae245f1b5 100644 --- a/pkg/sqlite/criterion_handlers.go +++ b/pkg/sqlite/criterion_handlers.go @@ -1129,7 +1129,7 @@ func (h *relatedFilterHandler) handle(ctx context.Context, f *filterBuilder) { return } - f.addWhere(fmt.Sprintf("%s IN ("+subQuery.toSQL(false)+")", h.relatedIDCol), subQuery.args...) + f.addWhere(fmt.Sprintf("%s IN ("+subQuery.toSQL(false)+")", h.relatedIDCol), subQuery.allArgs()...) } type phashDistanceCriterionHandler struct { diff --git a/pkg/sqlite/file.go b/pkg/sqlite/file.go index 1be5648b4..ba925a448 100644 --- a/pkg/sqlite/file.go +++ b/pkg/sqlite/file.go @@ -975,7 +975,7 @@ func (qb *FileStore) queryGroupedFields(ctx context.Context, options models.File Megapixels float64 Size int64 }{} - if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil { + if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil { return nil, err } diff --git a/pkg/sqlite/folder.go b/pkg/sqlite/folder.go index 73a065cff..fdeb00913 100644 --- a/pkg/sqlite/folder.go +++ b/pkg/sqlite/folder.go @@ -600,7 +600,7 @@ func (qb *FolderStore) queryGroupedFields(ctx context.Context, options models.Fo Megapixels float64 Size int64 }{} - if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil { + if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil { return nil, err } diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index da1c67a10..b92a1c073 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -926,7 +926,7 @@ func (qb *ImageStore) queryGroupedFields(ctx context.Context, options models.Ima Megapixels null.Float Size null.Float }{} - if err := imageRepository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil { + if err := imageRepository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil { return nil, err } diff --git a/pkg/sqlite/query.go b/pkg/sqlite/query.go index 99c1f4e5f..80c7fcd40 100644 --- a/pkg/sqlite/query.go +++ b/pkg/sqlite/query.go @@ -17,13 +17,26 @@ type queryBuilder struct { joins joins whereClauses []string havingClauses []string - args []interface{} withClauses []string recursiveWith bool + withArgs []interface{} + joinArgs []interface{} + whereArgs []interface{} + havingArgs []interface{} + sortAndPagination string } +func (qb queryBuilder) allArgs() []interface{} { + var args []interface{} + args = append(args, qb.withArgs...) + args = append(args, qb.joinArgs...) + args = append(args, qb.whereArgs...) + args = append(args, qb.havingArgs...) + return args +} + func (qb queryBuilder) body(includeSortPagination bool) string { return fmt.Sprintf("SELECT %s FROM %s%s", strings.Join(qb.columns, ", "), qb.from, qb.joins.toSQL(includeSortPagination)) } @@ -55,13 +68,13 @@ func (qb queryBuilder) toSQL(includeSortPagination bool) string { func (qb queryBuilder) findIDs(ctx context.Context) ([]int, error) { const includeSortPagination = true sql := qb.toSQL(includeSortPagination) - return qb.repository.runIdsQuery(ctx, sql, qb.args) + return qb.repository.runIdsQuery(ctx, sql, qb.allArgs()) } func (qb queryBuilder) executeFind(ctx context.Context) ([]int, int, error) { const includeSortPagination = true body := qb.body(includeSortPagination) - return qb.repository.executeFindQuery(ctx, body, qb.args, qb.sortAndPagination, qb.whereClauses, qb.havingClauses, qb.withClauses, qb.recursiveWith) + return qb.repository.executeFindQuery(ctx, body, qb.allArgs(), qb.sortAndPagination, qb.whereClauses, qb.havingClauses, qb.withClauses, qb.recursiveWith) } func (qb queryBuilder) executeCount(ctx context.Context) (int, error) { @@ -79,7 +92,7 @@ func (qb queryBuilder) executeCount(ctx context.Context) (int, error) { body = qb.repository.buildQueryBody(body, qb.whereClauses, qb.havingClauses) countQuery := withClause + qb.repository.buildCountQuery(body) - return qb.repository.runCountQuery(ctx, countQuery, qb.args) + return qb.repository.runCountQuery(ctx, countQuery, qb.allArgs()) } func (qb *queryBuilder) addWhere(clauses ...string) { @@ -109,7 +122,11 @@ func (qb *queryBuilder) addWith(recursive bool, clauses ...string) { } func (qb *queryBuilder) addArg(args ...interface{}) { - qb.args = append(qb.args, args...) + qb.whereArgs = append(qb.whereArgs, args...) +} + +func (qb *queryBuilder) addHavingArg(args ...interface{}) { + qb.havingArgs = append(qb.havingArgs, args...) } func (qb *queryBuilder) hasJoin(alias string) bool { @@ -148,7 +165,7 @@ func (qb *queryBuilder) joinSort(table, as, onClause string) { func (qb *queryBuilder) addJoins(joins ...join) { for _, j := range joins { if qb.joins.addUnique(j) { - qb.args = append(qb.args, j.args...) + qb.joinArgs = append(qb.joinArgs, j.args...) } } } @@ -163,20 +180,16 @@ func (qb *queryBuilder) addFilter(f *filterBuilder) error { if len(clause) > 0 { qb.addWith(f.recursiveWith, clause) } - if len(args) > 0 { - // WITH clause always comes first and thus precedes alk args - qb.args = append(args, qb.args...) + qb.withArgs = append(qb.withArgs, args...) } - // add joins here to insert args qb.addJoins(f.getAllJoins()...) clause, args = f.generateWhereClauses() if len(clause) > 0 { qb.addWhere(clause) } - if len(args) > 0 { qb.addArg(args...) } @@ -185,9 +198,8 @@ func (qb *queryBuilder) addFilter(f *filterBuilder) error { if len(clause) > 0 { qb.addHaving(clause) } - if len(args) > 0 { - qb.addArg(args...) + qb.addHavingArg(args...) } return nil diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 3049681b2..c2093431d 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -1097,7 +1097,7 @@ func (qb *SceneStore) queryGroupedFields(ctx context.Context, options models.Sce Duration null.Float Size null.Float }{} - if err := sceneRepository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil { + if err := sceneRepository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil { return nil, err } diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index b673de3f9..179969fd6 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -1889,6 +1889,65 @@ func TestTagQueryCustomFields(t *testing.T) { } }) } + + // Test combining text search (findFilter.Q) with custom field filters. + // This verifies that positional args are bound in the correct order + // when JOINs (from custom fields) and WHERE (from text search) both + // have parameterized placeholders. + runWithRollbackTxn(t, "equals with text search", func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + tagName := getTagStringValue(tagIdxWithGallery, "Name") + q := tagName + findFilter := &models.FindFilterType{Q: &q} + + tagFilter := &models.TagFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierEquals, + Value: []any{getTagStringValue(tagIdxWithGallery, "custom")}, + }, + }, + } + + tags, _, err := db.Tag.Query(ctx, tagFilter, findFilter) + if err != nil { + t.Errorf("TagStore.Query() error = %v", err) + return + } + + ids := tagsToIDs(tags) + assert.Contains(ids, tagIDs[tagIdxWithGallery]) + assert.Len(tags, 1) + }) + + runWithRollbackTxn(t, "is_null with text search", func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + tagName := getTagStringValue(tagIdxWithGallery, "Name") + q := tagName + findFilter := &models.FindFilterType{Q: &q} + + tagFilter := &models.TagFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "not existing", + Modifier: models.CriterionModifierIsNull, + }, + }, + } + + tags, _, err := db.Tag.Query(ctx, tagFilter, findFilter) + if err != nil { + t.Errorf("TagStore.Query() error = %v", err) + return + } + + ids := tagsToIDs(tags) + assert.Contains(ids, tagIDs[tagIdxWithGallery]) + assert.Len(tags, 1) + }) } // TODO Destroy From b46fbb2e7a1002a5532129addd4e028b86a77c35 Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Mon, 2 Mar 2026 05:30:38 +0200 Subject: [PATCH 099/177] Update capitalization for sprite generation heading (#6623) --- ui/v2.5/src/locales/en-GB.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 8c9aaf3f0..a25f3c765 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -441,7 +441,7 @@ "heading": "Scrapers path" }, "scraping": "Scraping", - "sprite_generation_head": "Sprite generation", + "sprite_generation_head": "Sprite Generation", "sprite_interval_desc": "Time between each generated sprite in seconds.", "sprite_interval_head": "Sprite interval", "sprite_maximum_desc": "Maximum number of sprites to be generated for a scene. Set to 0 to disable the limit.", From 681ccbf380a580daf1fa81b90ad95351e569360b Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:44:20 +1100 Subject: [PATCH 100/177] Fix caption handling during scan and check before correcting path (#6634) * Handle case where folder entry exists for corrected path in correctSubFolderHierarchy * Log scan start * Handle caption files during scan --- internal/manager/task_scan.go | 115 +++++++++++++++++++--------------- pkg/file/move.go | 19 ++++++ pkg/file/scan.go | 4 ++ pkg/file/video/caption.go | 15 ++++- pkg/utils/mutex.go | 25 ++++++++ 5 files changed, 128 insertions(+), 50 deletions(-) diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index d09765577..77a492134 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -26,6 +26,7 @@ import ( "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/scene/generate" "github.com/stashapp/stash/pkg/txn" + "github.com/stashapp/stash/pkg/utils" ) type ScanJob struct { @@ -35,6 +36,8 @@ type ScanJob struct { fileQueue chan file.ScannedFile count int + + unmatchedCaptionFiles utils.MutexField[[]string] } func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error { @@ -73,6 +76,8 @@ func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error { j.scanner.ScanFilters = []file.PathFilter{newScanFilter(c, repo, minModTime)} j.scanner.HandlerRequiredFilters = []file.Filter{newHandlerRequiredFilter(cfg, repo)} + logger.Infof("Starting scan of %d paths with %d parallel tasks", len(paths), nTasks) + j.runJob(ctx, paths, nTasks, progress) taskQueue.Close() @@ -83,7 +88,7 @@ func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error { } elapsed := time.Since(start) - logger.Info(fmt.Sprintf("Scan finished (%s)", elapsed)) + logger.Infof("Scan finished (%s)", elapsed) j.subscriptions.notify() return nil @@ -172,6 +177,22 @@ func (j *ScanJob) queueFileFunc(ctx context.Context, f models.FS, zipFile *file. return fs.SkipDir } + // we don't include caption files in the file scan, but we do need + // to handle them + if fsutil.MatchExtension(path, video.CaptionExts) { + fileRepo := j.scanner.Repository.File + matched := video.AssociateCaptions(ctx, path, j.scanner.Repository.TxnManager, fileRepo, fileRepo) + + if !matched { + logger.Debugf("No matching video file found for caption file %s", path) + j.unmatchedCaptionFiles.SetFunc(func(files []string) []string { + return append(files, path) + }) + } + + return nil + } + logger.Debugf("Skipping file %s", path) return nil } @@ -309,6 +330,45 @@ func (j *ScanJob) handleFile(ctx context.Context, f file.ScannedFile, progress * return err } + // if this is a new video file, match it with any unmatched caption files + if r.New && len(j.unmatchedCaptionFiles.Get()) > 0 { + videoFile, _ := r.File.(*models.VideoFile) + + if videoFile != nil { + // try to match any unmatched caption files to this video file + for _, captionPath := range j.unmatchedCaptionFiles.Get() { + if video.MatchesCaption(videoFile.Path, captionPath) { + video.AssociateCaptions(ctx, captionPath, j.scanner.Repository.TxnManager, j.scanner.Repository.File, j.scanner.Repository.File) + + // remove from the unmatched list + j.unmatchedCaptionFiles.SetFunc(func(files []string) []string { + newFiles := make([]string, 0, len(files)-1) + for _, f := range files { + if f != captionPath { + newFiles = append(newFiles, f) + } + } + return newFiles + }) + } + } + } + } + + // clean captions - scene handler handles this as well, but + // unchanged files aren't processed by the scene handler + if r.IsUnchanged() { + videoFile, _ := r.File.(*models.VideoFile) + + if videoFile != nil { + txnMgr := j.scanner.Repository.TxnManager + fileRepo := j.scanner.Repository.File + if err := video.CleanCaptions(ctx, videoFile, txnMgr, fileRepo); err != nil { + logger.Errorf("Error cleaning captions: %v", err) + } + } + } + // handle rename should have already handled the contents of the zip file // so shouldn't need to scan it again @@ -378,11 +438,10 @@ type sceneFinder interface { // handlerRequiredFilter returns true if a File's handler needs to be executed despite the file not being updated. type handlerRequiredFilter struct { extensionConfig - txnManager txn.Manager - SceneFinder sceneFinder - ImageFinder fileCounter - GalleryFinder galleryFinder - CaptionUpdater video.CaptionUpdater + txnManager txn.Manager + SceneFinder sceneFinder + ImageFinder fileCounter + GalleryFinder galleryFinder FolderCache *lru.LRU[bool] @@ -398,7 +457,6 @@ func newHandlerRequiredFilter(c *config.Config, repo models.Repository) *handler SceneFinder: repo.Scene, ImageFinder: repo.Image, GalleryFinder: repo.Gallery, - CaptionUpdater: repo.File, FolderCache: lru.New[bool](processes * 2), videoFileNamingAlgorithm: c.GetVideoFileNamingAlgorithm(), } @@ -473,42 +531,12 @@ func (f *handlerRequiredFilter) Accept(ctx context.Context, ff models.File) bool } } - if isVideoFile { - // TODO - check if the cover exists - // hash := scene.GetHash(ff, f.videoFileNamingAlgorithm) - // ssPath := instance.Paths.Scene.GetScreenshotPath(hash) - // if exists, _ := fsutil.FileExists(ssPath); !exists { - // // if not, check if the file is a primary file for a scene - // scenes, err := f.SceneFinder.FindByPrimaryFileID(ctx, ff.Base().ID) - // if err != nil { - // // just ignore - // return false - // } - - // if len(scenes) > 0 { - // // if it is, then it needs to be re-generated - // return true - // } - // } - - // clean captions - scene handler handles this as well, but - // unchanged files aren't processed by the scene handler - videoFile, _ := ff.(*models.VideoFile) - if videoFile != nil { - if err := video.CleanCaptions(ctx, videoFile, f.txnManager, f.CaptionUpdater); err != nil { - logger.Errorf("Error cleaning captions: %v", err) - } - } - } - return false } type scanFilter struct { extensionConfig - txnManager txn.Manager - FileFinder models.FileFinder - CaptionUpdater video.CaptionUpdater + txnManager txn.Manager stashPaths config.StashConfigs generatedPath string @@ -521,8 +549,6 @@ func newScanFilter(c *config.Config, repo models.Repository, minModTime time.Tim return &scanFilter{ extensionConfig: newExtensionConfig(c), txnManager: repo.TxnManager, - FileFinder: repo.File, - CaptionUpdater: repo.File, stashPaths: c.GetStashPaths(), generatedPath: c.GetGeneratedPath(), videoExcludeRegex: generateRegexps(c.GetExcludes()), @@ -552,15 +578,6 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) isImageFile := useAsImage(path) isZipFile := fsutil.MatchExtension(path, f.zipExt) - // handle caption files - if fsutil.MatchExtension(path, video.CaptionExts) { - // we don't include caption files in the file scan, but we do need - // to handle them - video.AssociateCaptions(ctx, path, f.txnManager, f.FileFinder, f.CaptionUpdater) - - return false - } - if !info.IsDir() && !isVideoFile && !isImageFile && !isZipFile { logger.Debugf("Skipping %s as it does not match any known file extensions", path) return false diff --git a/pkg/file/move.go b/pkg/file/move.go index 06605912b..1f0a5012c 100644 --- a/pkg/file/move.go +++ b/pkg/file/move.go @@ -205,6 +205,25 @@ func correctSubFolderHierarchy(ctx context.Context, rw models.FolderReaderWriter logger.Debugf("updating folder %s to %s", oldPath, correctPath) + // #6427 - ensure folder entry with new path doesn't already exist + const caseSensitive = true + existing, err := rw.FindByPath(ctx, correctPath, caseSensitive) + if err != nil { + return fmt.Errorf("finding folder by path %s: %w", correctPath, err) + } + + if existing != nil { + // this should no longer be possible, but if it does happen, log a warning + // and skip updating this folder and its subfolders + logger.Warnf("folder with path %s already exists, setting parent_folder_id of %s to NULL and skipping", correctPath, oldPath) + f.ParentFolderID = nil + if err := rw.Update(ctx, f); err != nil { + return fmt.Errorf("updating folder parent id to NULL for folder %s: %w", oldPath, err) + } + + continue + } + f.Path = correctPath if err := rw.Update(ctx, f); err != nil { return fmt.Errorf("updating folder path %s -> %s: %w", oldPath, f.Path, err) diff --git a/pkg/file/scan.go b/pkg/file/scan.go index cf1b43603..467fa7f22 100644 --- a/pkg/file/scan.go +++ b/pkg/file/scan.go @@ -349,6 +349,10 @@ type ScanFileResult struct { Updated bool } +func (r ScanFileResult) IsUnchanged() bool { + return !r.New && !r.Renamed && !r.Updated +} + // ScanFile scans the provided file into the database, returning the scan result. func (s *Scanner) ScanFile(ctx context.Context, f ScannedFile) (*ScanFileResult, error) { var r *ScanFileResult diff --git a/pkg/file/video/caption.go b/pkg/file/video/caption.go index 43723864f..a9e216acd 100644 --- a/pkg/file/video/caption.go +++ b/pkg/file/video/caption.go @@ -90,11 +90,20 @@ type CaptionUpdater interface { UpdateCaptions(ctx context.Context, fileID models.FileID, captions []*models.VideoCaption) error } +// MatchesCaption returns true if the caption file matches the video file based on the filename +func MatchesCaption(videoPath, captionPath string) bool { + captionPrefix := getCaptionPrefix(captionPath) + videoPrefix := strings.TrimSuffix(videoPath, filepath.Ext(videoPath)) + "." + return captionPrefix == videoPrefix +} + // associates captions to scene/s with the same basename -func AssociateCaptions(ctx context.Context, captionPath string, txnMgr txn.Manager, fqb models.FileFinder, w CaptionUpdater) { +// returns true if the caption file was matched to a video file and processed, false otherwise +func AssociateCaptions(ctx context.Context, captionPath string, txnMgr txn.Manager, fqb models.FileFinder, w CaptionUpdater) bool { captionLang := getCaptionsLangFromPath(captionPath) captionPrefix := getCaptionPrefix(captionPath) + matched := false if err := txn.WithTxn(ctx, txnMgr, func(ctx context.Context) error { var err error files, er := fqb.FindAllByPath(ctx, captionPrefix+"*", true) @@ -117,6 +126,8 @@ func AssociateCaptions(ctx context.Context, captionPath string, txnMgr txn.Manag path := f.Base().Path logger.Debugf("Matched captions to file %s", path) + matched = true + captions, er := w.GetCaptions(ctx, fileID) if er == nil { fileExt := filepath.Ext(captionPath) @@ -139,6 +150,8 @@ func AssociateCaptions(ctx context.Context, captionPath string, txnMgr txn.Manag }); err != nil { logger.Error(err.Error()) } + + return matched } // CleanCaptions removes non existent/accessible language codes from captions diff --git a/pkg/utils/mutex.go b/pkg/utils/mutex.go index 212200214..47439e32b 100644 --- a/pkg/utils/mutex.go +++ b/pkg/utils/mutex.go @@ -1,5 +1,7 @@ package utils +import "sync" + // MutexManager manages access to mutexes using a mutex type and key. type MutexManager struct { mapChan chan map[string]<-chan struct{} @@ -62,3 +64,26 @@ func (csm *MutexManager) Claim(mutexType string, key string, done <-chan struct{ csm.mapChan <- m }() } + +type MutexField[T any] struct { + mutex sync.RWMutex + value T +} + +func (mf *MutexField[T]) Get() T { + mf.mutex.RLock() + defer mf.mutex.RUnlock() + return mf.value +} + +func (mf *MutexField[T]) Set(value T) { + mf.mutex.Lock() + defer mf.mutex.Unlock() + mf.value = value +} + +func (mf *MutexField[T]) SetFunc(f func(T) T) { + mf.mutex.Lock() + defer mf.mutex.Unlock() + mf.value = f(mf.value) +} From bc75d47f15eece4648a547bda9ffa4759f68da77 Mon Sep 17 00:00:00 2001 From: dev-null-life Date: Sun, 1 Mar 2026 21:45:33 -0600 Subject: [PATCH 101/177] Fix edit modal not opening inside gallery view (#6629) * Fix edit modal not opening inside gallery view The modal element was only rendered in the sidebar layout branch, but gallery images use the non-sidebar path which returned content without the modal. Also stabilize onEdit/onDelete with useCallback and add missing dependency array to the Mousetrap useEffect. Closes #6624 * Render modal once above sidebar conditional Move {modal} above the withSidebar ternary so it is rendered exactly once, avoiding the duplication that caused the original bug. Co-Authored-By: Claude Opus 4.6 --- ui/v2.5/src/components/Images/ImageList.tsx | 116 ++++++++++---------- 1 file changed, 61 insertions(+), 55 deletions(-) diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index cc8aa48f7..f47990c4c 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -587,25 +587,6 @@ export const FilteredImageList = PatchComponent( setShowSidebar, }); - useEffect(() => { - Mousetrap.bind("e", () => { - if (hasSelection) { - onEdit?.(); - } - }); - - Mousetrap.bind("d d", () => { - if (hasSelection) { - onDelete?.(); - } - }); - - return () => { - Mousetrap.unbind("e"); - Mousetrap.unbind("d d"); - }; - }); - const onCloseEditDelete = useCloseEditDelete({ closeModal, onSelectNone, @@ -628,23 +609,42 @@ export const FilteredImageList = PatchComponent( ); } - function onEdit() { + const onEdit = useCallback(() => { showModal( ); - } + }, [showModal, selectedItems, onCloseEditDelete]); - function onDelete() { + const onDelete = useCallback(() => { showModal( ); - } + }, [showModal, selectedItems, onCloseEditDelete]); + + useEffect(() => { + Mousetrap.bind("e", () => { + if (hasSelection) { + onEdit?.(); + } + }); + + Mousetrap.bind("d d", () => { + if (hasSelection) { + onDelete?.(); + } + }); + + return () => { + Mousetrap.unbind("e"); + Mousetrap.unbind("d d"); + }; + }, [hasSelection, onEdit, onDelete]); const convertedExtraOperations: IListFilterOperation[] = providedOperations.map((o) => ({ @@ -786,41 +786,47 @@ export const FilteredImageList = PatchComponent( ); - if (!withSidebar) { - return content; - } - return ( -
+ <> {modal} - - - - setShowSidebar(false)}> - setShowSidebar(false)} - count={cachedResult.loading ? undefined : totalCount} - focus={searchFocus} - /> - - setShowSidebar(!showSidebar)} + {!withSidebar ? ( + content + ) : ( +
+ - {content} - - - -
+ + setShowSidebar(false)} + > + setShowSidebar(false)} + count={cachedResult.loading ? undefined : totalCount} + focus={searchFocus} + /> + + setShowSidebar(!showSidebar)} + > + {content} + + +
+
+ )} + ); } ); From 09e2b2bd4e516d52e65d66d8ce2e04100da93b59 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:45:37 +1100 Subject: [PATCH 102/177] Wrap CleanCaptions with database. Refactor AssociateCaptions. --- internal/manager/task_scan.go | 4 +++- pkg/file/video/caption.go | 32 ++++++++++++++++++-------------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index 77a492134..5d1f063c2 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -363,7 +363,9 @@ func (j *ScanJob) handleFile(ctx context.Context, f file.ScannedFile, progress * if videoFile != nil { txnMgr := j.scanner.Repository.TxnManager fileRepo := j.scanner.Repository.File - if err := video.CleanCaptions(ctx, videoFile, txnMgr, fileRepo); err != nil { + if err := txn.WithDatabase(ctx, txnMgr, func(ctx context.Context) error { + return video.CleanCaptions(ctx, videoFile, txnMgr, fileRepo) + }); err != nil { logger.Errorf("Error cleaning captions: %v", err) } } diff --git a/pkg/file/video/caption.go b/pkg/file/video/caption.go index a9e216acd..46317d90c 100644 --- a/pkg/file/video/caption.go +++ b/pkg/file/video/caption.go @@ -129,21 +129,25 @@ func AssociateCaptions(ctx context.Context, captionPath string, txnMgr txn.Manag matched = true captions, er := w.GetCaptions(ctx, fileID) - if er == nil { - fileExt := filepath.Ext(captionPath) - ext := fileExt[1:] - if !IsLangInCaptions(captionLang, ext, captions) { // only update captions if language code is not present - newCaption := &models.VideoCaption{ - LanguageCode: captionLang, - Filename: filepath.Base(captionPath), - CaptionType: ext, - } - captions = append(captions, newCaption) - er = w.UpdateCaptions(ctx, fileID, captions) - if er == nil { - logger.Debugf("Updated captions for file %s. Added %s", path, captionLang) - } + if er != nil { + return fmt.Errorf("getting captions for file %s: %w", path, er) + } + + fileExt := filepath.Ext(captionPath) + ext := fileExt[1:] + if !IsLangInCaptions(captionLang, ext, captions) { // only update captions if language code is not present + newCaption := &models.VideoCaption{ + LanguageCode: captionLang, + Filename: filepath.Base(captionPath), + CaptionType: ext, } + captions = append(captions, newCaption) + er = w.UpdateCaptions(ctx, fileID, captions) + if er != nil { + return fmt.Errorf("updating captions for file %s: %w", path, er) + } + + logger.Debugf("Updated captions for file %s. Added %s", path, captionLang) } } return err From 784795660ba07e1f6fecd1dd0b9ebbc2cbf535f7 Mon Sep 17 00:00:00 2001 From: dev-null-life Date: Sun, 1 Mar 2026 22:47:23 -0600 Subject: [PATCH 103/177] Skip scanning zip contents when fingerprint is unchanged (#6633) * Skip scanning zip contents when fingerprint is unchanged When a zip-based gallery's modification time changes but its content hash (oshash/md5) remains the same, skip walking and rescanning every file inside the zip. This avoids expensive per-file fingerprint recalculation when zip metadata changes without actual content changes. Closes #6512 * Log a debug message when skipping a zip scan due to unchanged fingerprint --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- internal/manager/task_scan.go | 8 ++++++-- pkg/file/scan.go | 17 +++++++++++------ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index 5d1f063c2..cf675a5af 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -372,9 +372,11 @@ func (j *ScanJob) handleFile(ctx context.Context, f file.ScannedFile, progress * } // handle rename should have already handled the contents of the zip file - // so shouldn't need to scan it again + // so shouldn't need to scan it again. + // Only scan zip contents if the file is new, the fingerprint changed, + // or if a force rescan was requested. - if (r.New || r.Updated) && j.scanner.IsZipFile(f.Info.Name()) { + if j.scanner.IsZipFile(f.Info.Name()) && (r.New || r.FingerprintChanged || j.scanner.Rescan) { ff := r.File f.BaseFile = ff.Base() @@ -386,6 +388,8 @@ func (j *ScanJob) handleFile(ctx context.Context, f file.ScannedFile, progress * if err := j.scanZipFile(zipCtx, f, progress); err != nil { logger.Errorf("Error scanning zip file %q: %v", f.Path, err) } + } else if r.Updated && j.scanner.IsZipFile(f.Info.Name()) { + logger.Debugf("Skipping zip file scan for %q: fingerprint unchanged", f.Path) } return nil diff --git a/pkg/file/scan.go b/pkg/file/scan.go index 467fa7f22..8ff51b359 100644 --- a/pkg/file/scan.go +++ b/pkg/file/scan.go @@ -343,10 +343,11 @@ func (s *Scanner) onExistingFolder(ctx context.Context, f ScannedFile, existing } type ScanFileResult struct { - File models.File - New bool - Renamed bool - Updated bool + File models.File + New bool + Renamed bool + Updated bool + FingerprintChanged bool } func (r ScanFileResult) IsUnchanged() bool { @@ -791,6 +792,9 @@ func (s *Scanner) onExistingFile(ctx context.Context, f ScannedFile, existing mo return nil, err } + oldFingerprints := existing.Base().Fingerprints + fingerprintChanged := fp.ContentsChanged(oldFingerprints) + s.removeOutdatedFingerprints(existing, fp) existing.SetFingerprints(fp) @@ -814,8 +818,9 @@ func (s *Scanner) onExistingFile(ctx context.Context, f ScannedFile, existing mo return nil, err } return &ScanFileResult{ - File: existing, - Updated: true, + File: existing, + Updated: true, + FingerprintChanged: fingerprintChanged, }, nil } From b8dff736963094712368a94512a65e1ce2065f74 Mon Sep 17 00:00:00 2001 From: dev-null-life Date: Sun, 1 Mar 2026 22:47:43 -0600 Subject: [PATCH 104/177] Fix datepicker button border radius in input groups (#6630) Add missing .input-group-append .btn border-radius rule to zero out the left-side radius, matching the existing .input-group-prepend rule. Fixes #6518 Co-authored-by: Claude Opus 4.6 --- ui/v2.5/src/components/Shared/styles.scss | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 1251ffb9b..f2881fc55 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -446,6 +446,13 @@ button.collapse-button { } } +.input-group-append { + .btn { + border-bottom-left-radius: 0; + border-top-left-radius: 0; + } +} + .ModalComponent .modal-footer { justify-content: space-between; } From 52bd9392fbb5ea4a5307de0c06363ffef0f583fb Mon Sep 17 00:00:00 2001 From: Abdu Dihan Date: Mon, 2 Mar 2026 04:53:02 +0000 Subject: [PATCH 105/177] Fix stale browser-cached thumbnails after file content changes during scan. (#6622) * Fix stale thumbnails after file content changes When a file's content changed (e.g. after renaming files in a gallery), the scan handler updated fingerprints but did not bump the entity's updated_at timestamp. Since thumbnail URLs use updated_at as a cache buster and are served with immutable/1-year cache headers, browsers would indefinitely serve the old cached thumbnail. Update image, scene, and gallery scan handlers to call UpdatePartial (which sets updated_at to now) whenever file content changes, not only when a new file association is created. --- pkg/gallery/scan.go | 9 +-- pkg/gallery/scan_test.go | 108 +++++++++++++++++++++++++++++++ pkg/image/scan.go | 6 +- pkg/image/scan_test.go | 120 +++++++++++++++++++++++++++++++++++ pkg/models/mocks/database.go | 11 ++++ pkg/scene/scan.go | 6 +- pkg/scene/scan_test.go | 114 +++++++++++++++++++++++++++++++++ 7 files changed, 363 insertions(+), 11 deletions(-) create mode 100644 pkg/gallery/scan_test.go create mode 100644 pkg/image/scan_test.go create mode 100644 pkg/scene/scan_test.go diff --git a/pkg/gallery/scan.go b/pkg/gallery/scan.go index 2064355cd..b3e5d2c3c 100644 --- a/pkg/gallery/scan.go +++ b/pkg/gallery/scan.go @@ -135,13 +135,14 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models. if err := h.CreatorUpdater.AddFileID(ctx, i.ID, f.Base().ID); err != nil { return fmt.Errorf("adding file to gallery: %w", err) } - // update updated_at time - if _, err := h.CreatorUpdater.UpdatePartial(ctx, i.ID, models.NewGalleryPartial()); err != nil { - return fmt.Errorf("updating gallery: %w", err) - } } if !found || updateExisting { + // update updated_at time when file association or content changes + if _, err := h.CreatorUpdater.UpdatePartial(ctx, i.ID, models.NewGalleryPartial()); err != nil { + return fmt.Errorf("updating gallery: %w", err) + } + h.PluginCache.RegisterPostHooks(ctx, i.ID, hook.GalleryUpdatePost, nil, nil) } } diff --git a/pkg/gallery/scan_test.go b/pkg/gallery/scan_test.go new file mode 100644 index 000000000..4a89206e3 --- /dev/null +++ b/pkg/gallery/scan_test.go @@ -0,0 +1,108 @@ +package gallery + +import ( + "context" + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stashapp/stash/pkg/plugin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) { + const ( + testGalleryID = 1 + testFileID = 100 + ) + + existingFile := &models.BaseFile{ID: models.FileID(testFileID), Path: "test.zip"} + + makeGallery := func() *models.Gallery { + return &models.Gallery{ + ID: testGalleryID, + Files: models.NewRelatedFiles([]models.File{existingFile}), + } + } + + tests := []struct { + name string + updateExisting bool + expectUpdate bool + }{ + { + name: "calls UpdatePartial when file content changed", + updateExisting: true, + expectUpdate: true, + }, + { + name: "skips UpdatePartial when file unchanged and already associated", + updateExisting: false, + expectUpdate: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db := mocks.NewDatabase() + db.Gallery.On("GetFiles", mock.Anything, testGalleryID).Return([]models.File{existingFile}, nil) + + if tt.expectUpdate { + db.Gallery.On("UpdatePartial", mock.Anything, testGalleryID, mock.Anything). + Return(&models.Gallery{ID: testGalleryID}, nil) + } + + h := &ScanHandler{ + CreatorUpdater: db.Gallery, + PluginCache: &plugin.Cache{}, + } + + db.WithTxnCtx(func(ctx context.Context) { + err := h.associateExisting(ctx, []*models.Gallery{makeGallery()}, existingFile, tt.updateExisting) + assert.NoError(t, err) + }) + + if tt.expectUpdate { + db.Gallery.AssertCalled(t, "UpdatePartial", mock.Anything, testGalleryID, mock.Anything) + } else { + db.Gallery.AssertNotCalled(t, "UpdatePartial", mock.Anything, mock.Anything, mock.Anything) + } + }) + } +} + +func TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) { + const ( + testGalleryID = 1 + existFileID = 100 + newFileID = 200 + ) + + existingFile := &models.BaseFile{ID: models.FileID(existFileID), Path: "existing.zip"} + newFile := &models.BaseFile{ID: models.FileID(newFileID), Path: "new.zip"} + + gallery := &models.Gallery{ + ID: testGalleryID, + Files: models.NewRelatedFiles([]models.File{existingFile}), + } + + db := mocks.NewDatabase() + db.Gallery.On("GetFiles", mock.Anything, testGalleryID).Return([]models.File{existingFile}, nil) + db.Gallery.On("AddFileID", mock.Anything, testGalleryID, models.FileID(newFileID)).Return(nil) + db.Gallery.On("UpdatePartial", mock.Anything, testGalleryID, mock.Anything). + Return(&models.Gallery{ID: testGalleryID}, nil) + + h := &ScanHandler{ + CreatorUpdater: db.Gallery, + PluginCache: &plugin.Cache{}, + } + + db.WithTxnCtx(func(ctx context.Context) { + err := h.associateExisting(ctx, []*models.Gallery{gallery}, newFile, false) + assert.NoError(t, err) + }) + + db.Gallery.AssertCalled(t, "AddFileID", mock.Anything, testGalleryID, models.FileID(newFileID)) + db.Gallery.AssertCalled(t, "UpdatePartial", mock.Anything, testGalleryID, mock.Anything) +} diff --git a/pkg/image/scan.go b/pkg/image/scan.go index 317e3605f..99b31f698 100644 --- a/pkg/image/scan.go +++ b/pkg/image/scan.go @@ -210,8 +210,8 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models. changed = true } - if changed { - // always update updated_at time + if changed || updateExisting { + // update updated_at time when file association or content changes imagePartial := models.NewImagePartial() imagePartial.GalleryIDs = galleryIDs @@ -229,9 +229,7 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models. return fmt.Errorf("updating gallery updated at timestamp: %w", err) } } - } - if changed || updateExisting { h.PluginCache.RegisterPostHooks(ctx, i.ID, hook.ImageUpdatePost, nil, nil) } } diff --git a/pkg/image/scan_test.go b/pkg/image/scan_test.go new file mode 100644 index 000000000..f48c188ee --- /dev/null +++ b/pkg/image/scan_test.go @@ -0,0 +1,120 @@ +package image + +import ( + "context" + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stashapp/stash/pkg/plugin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type mockScanConfig struct{} + +func (m *mockScanConfig) GetCreateGalleriesFromFolders() bool { return false } + +func TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) { + const ( + testImageID = 1 + testFileID = 100 + ) + + existingFile := &models.BaseFile{ID: models.FileID(testFileID), Path: "/images/test.jpg"} + + makeImage := func() *models.Image { + return &models.Image{ + ID: testImageID, + Files: models.NewRelatedFiles([]models.File{existingFile}), + GalleryIDs: models.NewRelatedIDs([]int{}), + } + } + + tests := []struct { + name string + updateExisting bool + expectUpdate bool + }{ + { + name: "calls UpdatePartial when file content changed", + updateExisting: true, + expectUpdate: true, + }, + { + name: "skips UpdatePartial when file unchanged and already associated", + updateExisting: false, + expectUpdate: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db := mocks.NewDatabase() + db.Image.On("GetFiles", mock.Anything, testImageID).Return([]models.File{existingFile}, nil) + db.Image.On("GetGalleryIDs", mock.Anything, testImageID).Return([]int{}, nil) + + if tt.expectUpdate { + db.Image.On("UpdatePartial", mock.Anything, testImageID, mock.Anything). + Return(&models.Image{ID: testImageID}, nil) + } + + h := &ScanHandler{ + CreatorUpdater: db.Image, + GalleryFinder: db.Gallery, + ScanConfig: &mockScanConfig{}, + PluginCache: &plugin.Cache{}, + } + + db.WithTxnCtx(func(ctx context.Context) { + err := h.associateExisting(ctx, []*models.Image{makeImage()}, existingFile, tt.updateExisting) + assert.NoError(t, err) + }) + + if tt.expectUpdate { + db.Image.AssertCalled(t, "UpdatePartial", mock.Anything, testImageID, mock.Anything) + } else { + db.Image.AssertNotCalled(t, "UpdatePartial", mock.Anything, mock.Anything, mock.Anything) + } + }) + } +} + +func TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) { + const ( + testImageID = 1 + existFileID = 100 + newFileID = 200 + ) + + existingFile := &models.BaseFile{ID: models.FileID(existFileID), Path: "/images/existing.jpg"} + newFile := &models.BaseFile{ID: models.FileID(newFileID), Path: "/images/new.jpg"} + + image := &models.Image{ + ID: testImageID, + Files: models.NewRelatedFiles([]models.File{existingFile}), + GalleryIDs: models.NewRelatedIDs([]int{}), + } + + db := mocks.NewDatabase() + db.Image.On("GetFiles", mock.Anything, testImageID).Return([]models.File{existingFile}, nil) + db.Image.On("GetGalleryIDs", mock.Anything, testImageID).Return([]int{}, nil) + db.Image.On("AddFileID", mock.Anything, testImageID, models.FileID(newFileID)).Return(nil) + db.Image.On("UpdatePartial", mock.Anything, testImageID, mock.Anything). + Return(&models.Image{ID: testImageID}, nil) + + h := &ScanHandler{ + CreatorUpdater: db.Image, + GalleryFinder: db.Gallery, + ScanConfig: &mockScanConfig{}, + PluginCache: &plugin.Cache{}, + } + + db.WithTxnCtx(func(ctx context.Context) { + err := h.associateExisting(ctx, []*models.Image{image}, newFile, false) + assert.NoError(t, err) + }) + + db.Image.AssertCalled(t, "AddFileID", mock.Anything, testImageID, models.FileID(newFileID)) + db.Image.AssertCalled(t, "UpdatePartial", mock.Anything, testImageID, mock.Anything) +} diff --git a/pkg/models/mocks/database.go b/pkg/models/mocks/database.go index ec4177b30..88f106e19 100644 --- a/pkg/models/mocks/database.go +++ b/pkg/models/mocks/database.go @@ -3,6 +3,7 @@ package mocks import ( "context" + "errors" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/txn" @@ -89,6 +90,16 @@ func (db *Database) AssertExpectations(t mock.TestingT) { db.SavedFilter.AssertExpectations(t) } +// WithTxnCtx runs fn with a context that has a transaction hook manager registered, +// so code that calls txn.AddPostCommitHook (e.g. plugin cache) won't nil-panic. +// Always rolls back to avoid executing the registered hooks. +func (db *Database) WithTxnCtx(fn func(ctx context.Context)) { + _ = txn.WithTxn(context.Background(), db, func(ctx context.Context) error { + fn(ctx) + return errors.New("rollback") + }) +} + func (db *Database) Repository() models.Repository { return models.Repository{ TxnManager: db, diff --git a/pkg/scene/scan.go b/pkg/scene/scan.go index e1038fbc3..c70c44a9e 100644 --- a/pkg/scene/scan.go +++ b/pkg/scene/scan.go @@ -160,15 +160,15 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models. if err := h.CreatorUpdater.AddFileID(ctx, s.ID, f.ID); err != nil { return fmt.Errorf("adding file to scene: %w", err) } + } - // update updated_at time + if !found || updateExisting { + // update updated_at time when file association or content changes scenePartial := models.NewScenePartial() if _, err := h.CreatorUpdater.UpdatePartial(ctx, s.ID, scenePartial); err != nil { return fmt.Errorf("updating scene: %w", err) } - } - if !found || updateExisting { h.PluginCache.RegisterPostHooks(ctx, s.ID, hook.SceneUpdatePost, nil, nil) } } diff --git a/pkg/scene/scan_test.go b/pkg/scene/scan_test.go new file mode 100644 index 000000000..71729bb57 --- /dev/null +++ b/pkg/scene/scan_test.go @@ -0,0 +1,114 @@ +package scene + +import ( + "context" + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stashapp/stash/pkg/plugin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) { + const ( + testSceneID = 1 + testFileID = 100 + ) + + existingFile := &models.VideoFile{ + BaseFile: &models.BaseFile{ID: models.FileID(testFileID), Path: "test.mp4"}, + } + + makeScene := func() *models.Scene { + return &models.Scene{ + ID: testSceneID, + Files: models.NewRelatedVideoFiles([]*models.VideoFile{existingFile}), + } + } + + tests := []struct { + name string + updateExisting bool + expectUpdate bool + }{ + { + name: "calls UpdatePartial when file content changed", + updateExisting: true, + expectUpdate: true, + }, + { + name: "skips UpdatePartial when file unchanged and already associated", + updateExisting: false, + expectUpdate: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db := mocks.NewDatabase() + db.Scene.On("GetFiles", mock.Anything, testSceneID).Return([]*models.VideoFile{existingFile}, nil) + + if tt.expectUpdate { + db.Scene.On("UpdatePartial", mock.Anything, testSceneID, mock.Anything). + Return(&models.Scene{ID: testSceneID}, nil) + } + + h := &ScanHandler{ + CreatorUpdater: db.Scene, + PluginCache: &plugin.Cache{}, + } + + db.WithTxnCtx(func(ctx context.Context) { + err := h.associateExisting(ctx, []*models.Scene{makeScene()}, existingFile, tt.updateExisting) + assert.NoError(t, err) + }) + + if tt.expectUpdate { + db.Scene.AssertCalled(t, "UpdatePartial", mock.Anything, testSceneID, mock.Anything) + } else { + db.Scene.AssertNotCalled(t, "UpdatePartial", mock.Anything, mock.Anything, mock.Anything) + } + }) + } +} + +func TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) { + const ( + testSceneID = 1 + existFileID = 100 + newFileID = 200 + ) + + existingFile := &models.VideoFile{ + BaseFile: &models.BaseFile{ID: models.FileID(existFileID), Path: "existing.mp4"}, + } + newFile := &models.VideoFile{ + BaseFile: &models.BaseFile{ID: models.FileID(newFileID), Path: "new.mp4"}, + } + + scene := &models.Scene{ + ID: testSceneID, + Files: models.NewRelatedVideoFiles([]*models.VideoFile{existingFile}), + } + + db := mocks.NewDatabase() + db.Scene.On("GetFiles", mock.Anything, testSceneID).Return([]*models.VideoFile{existingFile}, nil) + db.Scene.On("AddFileID", mock.Anything, testSceneID, models.FileID(newFileID)).Return(nil) + db.Scene.On("UpdatePartial", mock.Anything, testSceneID, mock.Anything). + Return(&models.Scene{ID: testSceneID}, nil) + + h := &ScanHandler{ + CreatorUpdater: db.Scene, + PluginCache: &plugin.Cache{}, + } + + db.WithTxnCtx(func(ctx context.Context) { + err := h.associateExisting(ctx, []*models.Scene{scene}, newFile, false) + assert.NoError(t, err) + }) + + db.Scene.AssertCalled(t, "AddFileID", mock.Anything, testSceneID, models.FileID(newFileID)) + db.Scene.AssertCalled(t, "UpdatePartial", mock.Anything, testSceneID, mock.Anything) +} From 99a0d01371eb39674c3e01346d1cbe0a4f6be2a4 Mon Sep 17 00:00:00 2001 From: puc9 <51006296+puc9@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:11:55 -0800 Subject: [PATCH 106/177] Fix new panic in IsFsPathCaseSensitive: Use filepath operations to check for file system case sensitivity (#6635) * Use filepath operations to check for file system case sensitivity --- pkg/fsutil/fs.go | 13 ++----------- pkg/fsutil/fs_test.go | 11 +++++++++++ 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/pkg/fsutil/fs.go b/pkg/fsutil/fs.go index 10666bb63..032bec53c 100644 --- a/pkg/fsutil/fs.go +++ b/pkg/fsutil/fs.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path/filepath" - "strings" "unicode" ) @@ -27,18 +26,10 @@ func IsFsPathCaseSensitive(path string) (bool, error) { if err != nil { // cannot be case flipped return false, err } - i := strings.LastIndex(path, base) - if i < 0 { // shouldn't happen - return false, fmt.Errorf("could not case flip path %s", path) - } - flipped := []rune(path) - for _, c := range fBase { // replace base of path with the flipped one ( we need to flip the base or last dir part ) - flipped[i] = c - i++ - } + flippedPath := filepath.Join(filepath.Dir(path), fBase) - fiCase, err := os.Stat(string(flipped)) + fiCase, err := os.Stat(flippedPath) if err != nil { // cannot stat the case flipped path return true, nil // fs of path should be case sensitive } diff --git a/pkg/fsutil/fs_test.go b/pkg/fsutil/fs_test.go index 522e95fa6..155e76ba5 100644 --- a/pkg/fsutil/fs_test.go +++ b/pkg/fsutil/fs_test.go @@ -41,4 +41,15 @@ func TestIsFsPathCaseSensitive_UnicodeByteLength(t *testing.T) { } // assert.True(t, r, "expected fs to be case sensitive") + + // Ensure that subfolders of a folder with multi-byte chars is not causing a panic + path3 := filepath.Join(dir, "NoPanic ❤️") + makeDir(path3) + path4 := filepath.Join(path3, "Test") + makeDir(path4) + + _, err = IsFsPathCaseSensitive(path4) + if err != nil { + t.Fatal(err) + } } From b9baa7ea9f0b737745fc500f7584442aeb116ebd Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 3 Mar 2026 08:26:04 +1100 Subject: [PATCH 107/177] Fix gallery image list styling --- ui/v2.5/src/components/Galleries/styles.scss | 5 +++++ ui/v2.5/src/components/Images/ImageList.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Galleries/styles.scss b/ui/v2.5/src/components/Galleries/styles.scss index ac9330e9a..b59da415e 100644 --- a/ui/v2.5/src/components/Galleries/styles.scss +++ b/ui/v2.5/src/components/Galleries/styles.scss @@ -182,6 +182,11 @@ $galleryTabWidth: 450px; width: 100%; } +@media (min-width: 1200px) { + .gallery-container .image-list .filtered-list-toolbar.has-selection { + top: 0; + } +} @media (min-width: 1200px), (max-width: 575px) { .gallery-performers { .performer-card { diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index f47990c4c..50956b497 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -790,7 +790,7 @@ export const FilteredImageList = PatchComponent( <> {modal} {!withSidebar ? ( - content +
{content}
) : (
- ); - }); - - return
{tagElements}
; + return ( + + ); } if (filter.displayMode === DisplayMode.Tagger) { return ; @@ -287,7 +199,6 @@ export const FilteredTagList = PatchComponent( (props: ITagList) => { const intl = useIntl(); const history = useHistory(); - const Toast = useToast(); const searchFocus = useFocus(); @@ -433,16 +344,6 @@ export const FilteredTagList = PatchComponent( ); } - async function onAutoTag(tag: GQL.TagListDataFragment) { - if (!tag) return; - try { - await mutateMetadataAutoTag({ tags: [tag.id] }); - Toast.success(intl.formatMessage({ id: "toast.started_auto_tagging" })); - } catch (e) { - Toast.error(e); - } - } - const convertedExtraOperations = extraOperations.map((op) => ({ text: op.text, onClick: () => op.onClick(result, filter, selectedIds), @@ -566,8 +467,6 @@ export const FilteredTagList = PatchComponent( tags={items} selectedIds={selectedIds} onSelectChange={onSelectChange} - onDelete={(tag) => onDelete(tag)} - onAutoTag={(tag) => onAutoTag(tag)} /> diff --git a/ui/v2.5/src/components/Tags/TagListTable.tsx b/ui/v2.5/src/components/Tags/TagListTable.tsx new file mode 100644 index 000000000..f593c0d1f --- /dev/null +++ b/ui/v2.5/src/components/Tags/TagListTable.tsx @@ -0,0 +1,230 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ + +import React from "react"; +import { useIntl } from "react-intl"; +import { Button } from "react-bootstrap"; +import { Link } from "react-router-dom"; +import * as GQL from "src/core/generated-graphql"; +import { Icon } from "../Shared/Icon"; +import NavUtils from "src/utils/navigation"; +import { faHeart } from "@fortawesome/free-solid-svg-icons"; +import { useTagUpdate } from "src/core/StashService"; +import { useTableColumns } from "src/hooks/useTableColumns"; +import cx from "classnames"; +import { IColumn, ListTable } from "../List/ListTable"; + +interface ITagListTableProps { + tags: GQL.TagListDataFragment[]; + selectedIds: Set; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; +} + +const TABLE_NAME = "tags"; + +export const TagListTable: React.FC = ( + props: ITagListTableProps +) => { + const intl = useIntl(); + + const [updateTag] = useTagUpdate(); + + function setFavorite(v: boolean, tagId: string) { + if (tagId) { + updateTag({ + variables: { + input: { + id: tagId, + favorite: v, + }, + }, + }); + } + } + + const ImageCell = (tag: GQL.TagListDataFragment) => ( + + {tag.name + + ); + + const NameCell = (tag: GQL.TagListDataFragment) => ( + +
+ {tag.name} +
+ + ); + + const AliasesCell = (tag: GQL.TagListDataFragment) => { + let aliases = tag.aliases ? tag.aliases.join(", ") : ""; + return ( + + {aliases} + + ); + }; + + const FavoriteCell = (tag: GQL.TagListDataFragment) => ( + + ); + + const SceneCountCell = (tag: GQL.TagListDataFragment) => ( + + {tag.scene_count} + + ); + + const GalleryCountCell = (tag: GQL.TagListDataFragment) => ( + + {tag.gallery_count} + + ); + + const ImageCountCell = (tag: GQL.TagListDataFragment) => ( + + {tag.image_count} + + ); + + const GroupCountCell = (tag: GQL.TagListDataFragment) => ( + + {tag.group_count} + + ); + + const StudioCountCell = (tag: GQL.TagListDataFragment) => ( + + {tag.studio_count} + + ); + + const PerformerCountCell = (tag: GQL.TagListDataFragment) => ( + + {tag.performer_count} + + ); + + interface IColumnSpec { + value: string; + label: string; + defaultShow?: boolean; + mandatory?: boolean; + render?: (tag: GQL.TagListDataFragment, index: number) => React.ReactNode; + } + + const allColumns: IColumnSpec[] = [ + { + value: "image", + label: intl.formatMessage({ id: "image" }), + defaultShow: true, + render: ImageCell, + }, + { + value: "name", + label: intl.formatMessage({ id: "name" }), + mandatory: true, + defaultShow: true, + render: NameCell, + }, + { + value: "aliases", + label: intl.formatMessage({ id: "aliases" }), + defaultShow: true, + render: AliasesCell, + }, + { + value: "favourite", + label: intl.formatMessage({ id: "favourite" }), + defaultShow: true, + render: FavoriteCell, + }, + { + value: "scene_count", + label: intl.formatMessage({ id: "scenes" }), + defaultShow: true, + render: SceneCountCell, + }, + { + value: "gallery_count", + label: intl.formatMessage({ id: "galleries" }), + defaultShow: true, + render: GalleryCountCell, + }, + { + value: "image_count", + label: intl.formatMessage({ id: "images" }), + defaultShow: true, + render: ImageCountCell, + }, + { + value: "group_count", + label: intl.formatMessage({ id: "groups" }), + defaultShow: true, + render: GroupCountCell, + }, + { + value: "performer_count", + label: intl.formatMessage({ id: "performers" }), + defaultShow: true, + render: PerformerCountCell, + }, + { + value: "studio_count", + label: intl.formatMessage({ id: "studios" }), + defaultShow: true, + render: StudioCountCell, + }, + ]; + + const defaultColumns = allColumns + .filter((col) => col.defaultShow) + .map((col) => col.value); + + const { selectedColumns, saveColumns } = useTableColumns( + TABLE_NAME, + defaultColumns + ); + + const columnRenderFuncs: Record< + string, + (tag: GQL.TagListDataFragment, index: number) => React.ReactNode + > = {}; + allColumns.forEach((col) => { + if (col.render) { + columnRenderFuncs[col.value] = col.render; + } + }); + + function renderCell( + column: IColumn, + tag: GQL.TagListDataFragment, + index: number + ) { + const render = columnRenderFuncs[column.value]; + + if (render) return render(tag, index); + } + + return ( + saveColumns(c)} + selectedIds={props.selectedIds} + onSelectChange={props.onSelectChange} + renderCell={renderCell} + /> + ); +}; From b47134112a8c7fb078fd70438e916e890daba2a0 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 19 Mar 2026 08:51:04 +1100 Subject: [PATCH 138/177] Focus search field when clicking on scraper menu (#6704) * Focus search field when opening scraper menu * Improve styling of search header in scraper menu --- ui/v2.5/src/components/Shared/ScraperMenu.tsx | 36 +++++++++++-------- ui/v2.5/src/components/Shared/styles.scss | 7 ++-- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/ui/v2.5/src/components/Shared/ScraperMenu.tsx b/ui/v2.5/src/components/Shared/ScraperMenu.tsx index 4cc38b6f8..9bdb84d45 100644 --- a/ui/v2.5/src/components/Shared/ScraperMenu.tsx +++ b/ui/v2.5/src/components/Shared/ScraperMenu.tsx @@ -6,6 +6,8 @@ import { stashboxDisplayName } from "src/utils/stashbox"; import { ScraperSourceInput, StashBox } from "src/core/generated-graphql"; import { faSyncAlt } from "@fortawesome/free-solid-svg-icons"; import { ClearableInput } from "./ClearableInput"; +import useFocus from "src/utils/focus"; +import ScreenUtils from "src/utils/screen"; export const ScraperMenu: React.FC<{ toggle: React.ReactNode; @@ -25,6 +27,10 @@ export const ScraperMenu: React.FC<{ const intl = useIntl(); const [filter, setFilter] = useState(""); + const focusOnOpen = !ScreenUtils.isTouch(); + const focusRef = useFocus(); + const [, setFocus] = focusRef; + const filteredStashboxes = useMemo(() => { if (!stashBoxes) return []; if (!filter) return stashBoxes; @@ -48,25 +54,27 @@ export const ScraperMenu: React.FC<{ { + if (focusOnOpen && v) setTimeout(() => setFocus(true), 0); + }} > {toggle}
-
- - -
+ +
{filteredStashboxes.map((s, index) => ( diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 709712231..21e6eb696 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -766,15 +766,14 @@ button.btn.favorite-button { .scraper-filter-container { background-color: $secondary; border-bottom: solid 1px $textfield-bg; + display: flex; padding: 5px; position: sticky; top: 0; z-index: 1; - .btn-group { - border: solid 1px $textfield-bg; - border-radius: 5px; - width: 100%; + .clearable-input-group { + flex-grow: 1; } .clearable-text-field { From 8f3188ff743d2f02e1900a3715ce2c70d120f126 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 19 Mar 2026 08:54:44 +1100 Subject: [PATCH 139/177] Make gallery/scene association during scan more consistent (#6705) --- internal/manager/task_scan.go | 12 ++++---- pkg/gallery/scan.go | 1 - pkg/image/scan.go | 39 ++++++++++++++++++++++-- pkg/models/mocks/GalleryReaderWriter.go | 14 +++++++++ pkg/models/repository_gallery.go | 1 + pkg/scene/scan.go | 40 ++++++++++++++++++++++++- pkg/sqlite/gallery.go | 4 +++ 7 files changed, 102 insertions(+), 9 deletions(-) diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index 53e6944b5..22849124c 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -660,8 +660,9 @@ func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progre &file.FilteredHandler{ Filter: file.FilterFunc(imageFileFilter), Handler: &image.ScanHandler{ - CreatorUpdater: r.Image, - GalleryFinder: r.Gallery, + CreatorUpdater: r.Image, + GalleryFinder: r.Gallery, + SceneFinderUpdater: r.Scene, ScanGenerator: &imageGenerators{ input: options, taskQueue: taskQueue, @@ -690,9 +691,10 @@ func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progre &file.FilteredHandler{ Filter: file.FilterFunc(videoFileFilter), Handler: &scene.ScanHandler{ - CreatorUpdater: r.Scene, - CaptionUpdater: r.File, - PluginCache: pluginCache, + CreatorUpdater: r.Scene, + GalleryFinderUpdater: r.Gallery, + CaptionUpdater: r.File, + PluginCache: pluginCache, ScanGenerator: &sceneGenerators{ input: options, taskQueue: taskQueue, diff --git a/pkg/gallery/scan.go b/pkg/gallery/scan.go index b3e5d2c3c..7689bb9b6 100644 --- a/pkg/gallery/scan.go +++ b/pkg/gallery/scan.go @@ -24,7 +24,6 @@ type ScanCreatorUpdater interface { type ScanSceneFinderUpdater interface { FindByPath(ctx context.Context, p string) ([]*models.Scene, error) - Update(ctx context.Context, updatedScene *models.Scene) error AddGalleryIDs(ctx context.Context, sceneID int, galleryIDs []int) error } diff --git a/pkg/image/scan.go b/pkg/image/scan.go index 99b31f698..682641e66 100644 --- a/pkg/image/scan.go +++ b/pkg/image/scan.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "slices" + "strings" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" @@ -39,6 +40,11 @@ type GalleryFinderCreator interface { UpdatePartial(ctx context.Context, id int, updatedGallery models.GalleryPartial) (*models.Gallery, error) } +type ScanSceneFinderUpdater interface { + FindByPath(ctx context.Context, p string) ([]*models.Scene, error) + AddGalleryIDs(ctx context.Context, sceneID int, galleryIDs []int) error +} + type ScanConfig interface { GetCreateGalleriesFromFolders() bool } @@ -48,8 +54,9 @@ type ScanGenerator interface { } type ScanHandler struct { - CreatorUpdater ScanCreatorUpdater - GalleryFinder GalleryFinderCreator + CreatorUpdater ScanCreatorUpdater + GalleryFinder GalleryFinderCreator + SceneFinderUpdater ScanSceneFinderUpdater ScanGenerator ScanGenerator @@ -322,11 +329,39 @@ func (h *ScanHandler) getOrCreateZipBasedGallery(ctx context.Context, zipFile mo return nil, fmt.Errorf("creating zip-based gallery: %w", err) } + // try to associate with scene + if err := h.associateScene(ctx, &newGallery, zipFile); err != nil { + return nil, fmt.Errorf("associating scene: %w", err) + } + h.PluginCache.RegisterPostHooks(ctx, newGallery.ID, hook.GalleryCreatePost, nil, nil) return &newGallery, nil } +func (h *ScanHandler) associateScene(ctx context.Context, existing *models.Gallery, zipFile models.File) error { + galleryIDs := []int{existing.ID} + + path := zipFile.Base().Path + withoutExt := strings.TrimSuffix(path, filepath.Ext(path)) + ".*" + + // find scenes with a file that matches + scenes, err := h.SceneFinderUpdater.FindByPath(ctx, withoutExt) + if err != nil { + return err + } + + for _, scene := range scenes { + // found related Scene + logger.Infof("associate: Gallery %s is related to scene: %d", path, scene.ID) + if err := h.SceneFinderUpdater.AddGalleryIDs(ctx, scene.ID, galleryIDs); err != nil { + return err + } + } + + return nil +} + func (h *ScanHandler) getOrCreateGallery(ctx context.Context, f models.File) (*models.Gallery, error) { // don't create folder-based galleries for files in zip file if f.Base().ZipFile != nil { diff --git a/pkg/models/mocks/GalleryReaderWriter.go b/pkg/models/mocks/GalleryReaderWriter.go index f20d9f76e..e835ea2bc 100644 --- a/pkg/models/mocks/GalleryReaderWriter.go +++ b/pkg/models/mocks/GalleryReaderWriter.go @@ -49,6 +49,20 @@ func (_m *GalleryReaderWriter) AddImages(ctx context.Context, galleryID int, ima return r0 } +// AddSceneIDs provides a mock function with given fields: ctx, galleryID, sceneIDs +func (_m *GalleryReaderWriter) AddSceneIDs(ctx context.Context, galleryID int, sceneIDs []int) error { + ret := _m.Called(ctx, galleryID, sceneIDs) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, []int) error); ok { + r0 = rf(ctx, galleryID, sceneIDs) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // All provides a mock function with given fields: ctx func (_m *GalleryReaderWriter) All(ctx context.Context) ([]*models.Gallery, error) { ret := _m.Called(ctx) diff --git a/pkg/models/repository_gallery.go b/pkg/models/repository_gallery.go index b8f1452f3..8fc3b29d5 100644 --- a/pkg/models/repository_gallery.go +++ b/pkg/models/repository_gallery.go @@ -83,6 +83,7 @@ type GalleryWriter interface { CustomFieldsWriter + AddSceneIDs(ctx context.Context, galleryID int, sceneIDs []int) error AddFileID(ctx context.Context, id int, fileID FileID) error AddImages(ctx context.Context, galleryID int, imageIDs ...int) error RemoveImages(ctx context.Context, galleryID int, imageIDs ...int) error diff --git a/pkg/scene/scan.go b/pkg/scene/scan.go index c70c44a9e..c9cc2c567 100644 --- a/pkg/scene/scan.go +++ b/pkg/scene/scan.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "path/filepath" + "strings" "github.com/stashapp/stash/pkg/file/video" "github.com/stashapp/stash/pkg/logger" @@ -32,12 +34,18 @@ type ScanCreatorUpdater interface { AddFileID(ctx context.Context, id int, fileID models.FileID) error } +type ScanGalleryFinderUpdater interface { + FindByPath(ctx context.Context, p string) ([]*models.Gallery, error) + AddSceneIDs(ctx context.Context, galleryID int, sceneIDs []int) error +} + type ScanGenerator interface { Generate(ctx context.Context, s *models.Scene, f *models.VideoFile) error } type ScanHandler struct { - CreatorUpdater ScanCreatorUpdater + CreatorUpdater ScanCreatorUpdater + GalleryFinderUpdater ScanGalleryFinderUpdater ScanGenerator ScanGenerator CaptionUpdater video.CaptionUpdater @@ -127,6 +135,10 @@ func (h *ScanHandler) Handle(ctx context.Context, f models.File, oldFile models. } } + if err := h.associateGallery(ctx, existing, f); err != nil { + return err + } + // do this after the commit so that cover generation doesn't hold up the transaction txn.AddPostCommitHook(ctx, func(ctx context.Context) { for _, s := range existing { @@ -175,3 +187,29 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models. return nil } + +func (h *ScanHandler) associateGallery(ctx context.Context, existing []*models.Scene, f models.File) error { + sceneIDs := make([]int, len(existing)) + for i, s := range existing { + sceneIDs[i] = s.ID + } + + path := f.Base().Path + zipPath := strings.TrimSuffix(path, filepath.Ext(path)) + ".zip" + + // find galleries with a file that matches + galleries, err := h.GalleryFinderUpdater.FindByPath(ctx, zipPath) + if err != nil { + return err + } + + for _, gallery := range galleries { + // found related Scene + logger.Infof("associate: Scene %s is related to gallery: %d", path, gallery.ID) + if err := h.GalleryFinderUpdater.AddSceneIDs(ctx, gallery.ID, sceneIDs); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 305b1fe09..ad7a94b04 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -926,3 +926,7 @@ func (qb *GalleryStore) ResetCover(ctx context.Context, galleryID int) error { func (qb *GalleryStore) GetSceneIDs(ctx context.Context, id int) ([]int, error) { return galleryRepository.scenes.getIDs(ctx, id) } + +func (qb *GalleryStore) AddSceneIDs(ctx context.Context, galleryID int, sceneIDs []int) error { + return galleriesScenesTableMgr.insertJoins(ctx, galleryID, sceneIDs) +} From 4167224107e8749347601854b5c8da953a0ae0f0 Mon Sep 17 00:00:00 2001 From: Stash-KennyG <138793998+Stash-KennyG@users.noreply.github.com> Date: Wed, 18 Mar 2026 20:03:36 -0400 Subject: [PATCH 140/177] Feature: Add StashID guid consideration into select boxes (#6709) * Add GUID search for performers in PerformerSelect component * Refactor and apply to all objects with stash ids --------- Co-authored-by: KennyG Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- .../components/Performers/PerformerSelect.tsx | 26 ++++++++--- ui/v2.5/src/components/Scenes/SceneSelect.tsx | 44 +++++++++++++------ .../src/components/Shared/FilterSelect.tsx | 7 +++ .../src/components/Studios/StudioSelect.tsx | 41 ++++++++++++----- ui/v2.5/src/components/Tags/TagSelect.tsx | 41 ++++++++++++----- ui/v2.5/src/models/list-filter/utils.ts | 12 +++++ ui/v2.5/src/utils/stashIds.ts | 6 ++- 7 files changed, 136 insertions(+), 41 deletions(-) create mode 100644 ui/v2.5/src/models/list-filter/utils.ts diff --git a/ui/v2.5/src/components/Performers/PerformerSelect.tsx b/ui/v2.5/src/components/Performers/PerformerSelect.tsx index f10519897..133ffd854 100644 --- a/ui/v2.5/src/components/Performers/PerformerSelect.tsx +++ b/ui/v2.5/src/components/Performers/PerformerSelect.tsx @@ -23,6 +23,7 @@ import { IFilterProps, IFilterValueProps, Option as SelectOption, + toOption, } from "../Shared/FilterSelect"; import { useCompare } from "src/hooks/state"; import { Link } from "react-router-dom"; @@ -32,6 +33,8 @@ import { TruncatedText } from "../Shared/TruncatedText"; import TextUtils from "src/utils/text"; import { PerformerPopover } from "./PerformerPopover"; import { Placement } from "react-bootstrap/esm/Overlay"; +import { isUUID } from "src/utils/stashIds"; +import { filterByStashID } from "src/models/list-filter/utils"; export type SelectObject = { id: string; @@ -91,19 +94,32 @@ const _PerformerSelect: React.FC< async function loadPerformers(input: string): Promise { const filter = new ListFilterModel(GQL.FilterMode.Performers); - filter.searchTerm = input; filter.currentPage = 1; filter.itemsPerPage = maxOptionsShown; filter.sortBy = "name"; filter.sortDirection = GQL.SortDirectionEnum.Asc; + + // If the input looks like a GUID, search for stash_id first and return match immediately + if (isUUID(input)) { + filterByStashID(filter, input); + + const query = await queryFindPerformersForSelect(filter); + const matches = query.data.findPerformers.performers.slice(); + if (matches.length > 0) { + // Matches found, return them immediately. + return matches.map(toOption); + } + // If no stash_id matches found, continue with standard name/alias search. + filter.criteria = []; // Clear stash_id criterion to search by name/alias below. + } + + filter.searchTerm = input; + const query = await queryFindPerformersForSelect(filter); return performerSelectSort( input, query.data.findPerformers.performers.slice() - ).map((performer) => ({ - value: performer.id, - object: performer, - })); + ).map(toOption); } const PerformerOption: React.FC> = ( diff --git a/ui/v2.5/src/components/Scenes/SceneSelect.tsx b/ui/v2.5/src/components/Scenes/SceneSelect.tsx index 8ab32b753..fed72dd53 100644 --- a/ui/v2.5/src/components/Scenes/SceneSelect.tsx +++ b/ui/v2.5/src/components/Scenes/SceneSelect.tsx @@ -22,6 +22,7 @@ import { IFilterProps, IFilterValueProps, Option as SelectOption, + toOption, } from "../Shared/FilterSelect"; import { useCompare } from "src/hooks/state"; import { Placement } from "react-bootstrap/esm/Overlay"; @@ -33,6 +34,8 @@ import { CriterionValue, } from "src/models/list-filter/criteria/criterion"; import { TruncatedText } from "../Shared/TruncatedText"; +import { isUUID } from "src/utils/stashIds"; +import { filterByStashID } from "src/models/list-filter/utils"; export type Scene = Pick & { studio?: Pick | null; @@ -73,29 +76,44 @@ const _SceneSelect: React.FC< const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]); + function filterExcluded(scene: Scene) { + // HACK - we should probably exclude these in the backend query, but + // this will do in the short-term + return !exclude.includes(scene.id.toString()); + } + async function loadScenes(input: string): Promise { const filter = new ListFilterModel(GQL.FilterMode.Scenes); - filter.searchTerm = input; filter.currentPage = 1; filter.itemsPerPage = maxOptionsShown; filter.sortBy = "title"; filter.sortDirection = GQL.SortDirectionEnum.Asc; - if (props.extraCriteria) { - filter.criteria = [...props.extraCriteria]; + filter.criteria = [...(props.extraCriteria ?? [])]; + + if (isUUID(input)) { + const oldCriteria = filter.criteria; + + filterByStashID(filter, input); + + const query = await queryFindScenesForSelect(filter); + const matches = query.data.findScenes.scenes.filter(filterExcluded); + + if (matches.length > 0) { + // Matches found, return them immediately. + return matches.map(toOption); + } + + // If no stash_id matches found, continue with standard name/alias search. + filter.criteria = oldCriteria; // Clear stash_id criterion to search by name/alias below. } - const query = await queryFindScenesForSelect(filter); - let ret = query.data.findScenes.scenes.filter((scene) => { - // HACK - we should probably exclude these in the backend query, but - // this will do in the short-term - return !exclude.includes(scene.id.toString()); - }); + filter.searchTerm = input; - return sceneSelectSort(input, ret).map((scene) => ({ - value: scene.id, - object: scene, - })); + const query = await queryFindScenesForSelect(filter); + const ret = query.data.findScenes.scenes.filter(filterExcluded); + + return sceneSelectSort(input, ret).map(toOption); } const SceneOption: React.FC> = (optionProps) => { diff --git a/ui/v2.5/src/components/Shared/FilterSelect.tsx b/ui/v2.5/src/components/Shared/FilterSelect.tsx index e1c117aac..fbe786522 100644 --- a/ui/v2.5/src/components/Shared/FilterSelect.tsx +++ b/ui/v2.5/src/components/Shared/FilterSelect.tsx @@ -256,3 +256,10 @@ export interface IFilterIDProps { ids?: string[]; onSelect?: (item: T[]) => void; } + +export function toOption(item: T): Option { + return { + value: item.id, + object: item, + }; +} diff --git a/ui/v2.5/src/components/Studios/StudioSelect.tsx b/ui/v2.5/src/components/Studios/StudioSelect.tsx index 7305aa60d..b80834c84 100644 --- a/ui/v2.5/src/components/Studios/StudioSelect.tsx +++ b/ui/v2.5/src/components/Studios/StudioSelect.tsx @@ -23,11 +23,14 @@ import { IFilterProps, IFilterValueProps, Option as SelectOption, + toOption, } from "../Shared/FilterSelect"; import { useCompare } from "src/hooks/state"; import { Placement } from "react-bootstrap/esm/Overlay"; import { sortByRelevance } from "src/utils/query"; import { PatchComponent, PatchFunction } from "src/patch"; +import { isUUID } from "src/utils/stashIds"; +import { filterByStashID } from "src/models/list-filter/utils"; export type SelectObject = { id: string; @@ -74,24 +77,40 @@ const _StudioSelect: React.FC< const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]); + function filterExcluded(studio: Studio) { + // HACK - we should probably exclude these in the backend query, but + // this will do in the short-term + return !exclude.includes(studio.id.toString()); + } + async function loadStudios(input: string): Promise { const filter = new ListFilterModel(GQL.FilterMode.Studios); - filter.searchTerm = input; filter.currentPage = 1; filter.itemsPerPage = maxOptionsShown; filter.sortBy = "name"; filter.sortDirection = GQL.SortDirectionEnum.Asc; - const query = await queryFindStudiosForSelect(filter); - let ret = query.data.findStudios.studios.filter((studio) => { - // HACK - we should probably exclude these in the backend query, but - // this will do in the short-term - return !exclude.includes(studio.id.toString()); - }); - return studioSelectSort(input, ret).map((studio) => ({ - value: studio.id, - object: studio, - })); + if (isUUID(input)) { + filterByStashID(filter, input); + + const query = await queryFindStudiosForSelect(filter); + const matches = query.data.findStudios.studios.filter(filterExcluded); + + if (matches.length > 0) { + // Matches found, return them immediately. + return matches.map(toOption); + } + + // If no stash_id matches found, continue with standard name/alias search. + filter.criteria = []; // Clear stash_id criterion to search by name/alias below. + } + + filter.searchTerm = input; + + const query = await queryFindStudiosForSelect(filter); + const ret = query.data.findStudios.studios.filter(filterExcluded); + + return studioSelectSort(input, ret).map(toOption); } const StudioOption: React.FC> = ( diff --git a/ui/v2.5/src/components/Tags/TagSelect.tsx b/ui/v2.5/src/components/Tags/TagSelect.tsx index c9ed83fea..b79915261 100644 --- a/ui/v2.5/src/components/Tags/TagSelect.tsx +++ b/ui/v2.5/src/components/Tags/TagSelect.tsx @@ -23,12 +23,15 @@ import { IFilterProps, IFilterValueProps, Option as SelectOption, + toOption, } from "../Shared/FilterSelect"; import { useCompare } from "src/hooks/state"; import { TagPopover } from "./TagPopover"; import { Placement } from "react-bootstrap/esm/Overlay"; import { sortByRelevance } from "src/utils/query"; import { PatchComponent, PatchFunction } from "src/patch"; +import { isUUID } from "src/utils/stashIds"; +import { filterByStashID } from "src/models/list-filter/utils"; export type SelectObject = { id: string; @@ -75,24 +78,40 @@ const _TagSelect: React.FC = (props) => { const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]); + function filterExcluded(tag: Tag) { + // HACK - we should probably exclude these in the backend query, but + // this will do in the short-term + return !exclude.includes(tag.id.toString()); + } + async function loadTags(input: string): Promise { const filter = new ListFilterModel(GQL.FilterMode.Tags); - filter.searchTerm = input; filter.currentPage = 1; filter.itemsPerPage = maxOptionsShown; filter.sortBy = "name"; filter.sortDirection = GQL.SortDirectionEnum.Asc; - const query = await queryFindTagsForSelect(filter); - let ret = query.data.findTags.tags.filter((tag) => { - // HACK - we should probably exclude these in the backend query, but - // this will do in the short-term - return !exclude.includes(tag.id.toString()); - }); - return tagSelectSort(input, ret).map((tag) => ({ - value: tag.id, - object: tag, - })); + if (isUUID(input)) { + filterByStashID(filter, input); + + const query = await queryFindTagsForSelect(filter); + const matches = query.data.findTags.tags.filter(filterExcluded); + + if (matches.length > 0) { + // Matches found, return them immediately. + return matches.map(toOption); + } + + // If no stash_id matches found, continue with standard name/alias search. + filter.criteria = []; // Clear stash_id criterion to search by name/alias below. + } + + filter.searchTerm = input; + + const query = await queryFindTagsForSelect(filter); + const ret = query.data.findTags.tags.filter(filterExcluded); + + return tagSelectSort(input, ret).map(toOption); } const TagOption: React.FC> = (optionProps) => { diff --git a/ui/v2.5/src/models/list-filter/utils.ts b/ui/v2.5/src/models/list-filter/utils.ts new file mode 100644 index 000000000..5c63b1214 --- /dev/null +++ b/ui/v2.5/src/models/list-filter/utils.ts @@ -0,0 +1,12 @@ +import { CriterionModifier } from "src/core/generated-graphql"; +import { ModifierCriterion } from "./criteria/criterion"; +import { ListFilterModel } from "./filter"; + +export function filterByStashID(filter: ListFilterModel, stashID: string) { + const stashCriterion = filter.makeCriterion( + "stash_id_endpoint" + ) as ModifierCriterion<{ endpoint: string; stashID: string }>; + stashCriterion.modifier = CriterionModifier.Equals; + stashCriterion.value = { endpoint: "", stashID: stashID.trim() }; + filter.criteria = [stashCriterion]; +} diff --git a/ui/v2.5/src/utils/stashIds.ts b/ui/v2.5/src/utils/stashIds.ts index 10e3835b8..635db3600 100644 --- a/ui/v2.5/src/utils/stashIds.ts +++ b/ui/v2.5/src/utils/stashIds.ts @@ -13,6 +13,10 @@ export const getStashIDs = ( const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[47][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +export function isUUID(input: string): boolean { + return UUID_PATTERN.test(input.trim()); +} + /** * Separates a list of inputs into names and StashIDs based on UUID pattern matching * @param inputs - Array of strings that could be either names or StashIDs @@ -25,7 +29,7 @@ export const separateNamesAndStashIds = ( const stashIds: string[] = []; inputs.forEach((input) => { - if (UUID_PATTERN.test(input)) { + if (isUUID(input)) { stashIds.push(input); } else { names.push(input); From c583e88caf026fe619c2cca888b3aa1e34eec380 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:10:42 +1100 Subject: [PATCH 141/177] Replace "Source" with "Combined" in merge dialogs (#6711) --- ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx | 2 +- ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx | 2 +- ui/v2.5/src/components/Tags/TagMergeDialog.tsx | 2 +- ui/v2.5/src/locales/en-GB.json | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx index 0d42dd6ed..ce5b50b0c 100644 --- a/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx @@ -700,7 +700,7 @@ const PerformerMergeDetails: React.FC = ({ : intl.formatMessage({ id: "dialogs.merge.destination" }); const sourceLabel = !hasValues ? "" - : intl.formatMessage({ id: "dialogs.merge.source" }); + : intl.formatMessage({ id: "dialogs.merge.combined" }); return ( = ({ : intl.formatMessage({ id: "dialogs.merge.destination" }); const sourceLabel = !hasValues ? "" - : intl.formatMessage({ id: "dialogs.merge.source" }); + : intl.formatMessage({ id: "dialogs.merge.combined" }); return ( = ({ : intl.formatMessage({ id: "dialogs.merge.destination" }); const sourceLabel = !hasValues ? "" - : intl.formatMessage({ id: "dialogs.merge.source" }); + : intl.formatMessage({ id: "dialogs.merge.combined" }); return ( Date: Thu, 19 Mar 2026 13:16:20 +1100 Subject: [PATCH 142/177] Make hover volume configurable (#6712) --- ui/v2.5/src/components/Scenes/SceneCard.tsx | 8 ++- .../src/components/Scenes/SceneWallPanel.tsx | 59 ++++++++++++------- .../SettingsInterfacePanel.tsx | 35 ++++++++--- ui/v2.5/src/core/config.ts | 3 + ui/v2.5/src/locales/en-GB.json | 11 +++- 5 files changed, 85 insertions(+), 31 deletions(-) diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index 0a80880f1..55124e9b0 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -30,12 +30,14 @@ import { StudioOverlay } from "../Shared/GridCard/StudioOverlay"; import { GroupTag } from "../Groups/GroupTag"; import { FileSize } from "../Shared/FileSize"; import { OCounterButton } from "../Shared/CountButton"; +import { defaultPreviewVolume } from "src/core/config"; interface IScenePreviewProps { isPortrait: boolean; image?: string; video?: string; soundActive: boolean; + volume?: number; vttPath?: string; onScrubberClick?: (timestamp: number) => void; disabled?: boolean; @@ -49,6 +51,7 @@ export const ScenePreview: React.FC = ({ vttPath, onScrubberClick, disabled, + volume, }) => { const videoEl = useRef(null); @@ -67,8 +70,8 @@ export const ScenePreview: React.FC = ({ useEffect(() => { if (videoEl?.current?.volume) - videoEl.current.volume = soundActive ? 0.05 : 0; - }, [soundActive]); + videoEl.current.volume = soundActive ? (volume ?? 0) / 100 : 0; + }, [volume, soundActive]); return (
@@ -431,6 +434,7 @@ const SceneCardImage = PatchComponent( video={props.scene.paths.preview ?? undefined} isPortrait={isPortrait()} soundActive={configuration?.interface?.soundOnPreview ?? false} + volume={configuration?.ui.previewVolume ?? defaultPreviewVolume} vttPath={props.scene.paths.vtt ?? undefined} onScrubberClick={onScrubberClick} disabled={props.selecting} diff --git a/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx b/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx index d960db31f..d49d9b73e 100644 --- a/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Form } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import { SceneQueue } from "src/models/sceneQueue"; @@ -15,6 +21,7 @@ import TextUtils from "src/utils/text"; import { useIntl } from "react-intl"; import { useDragMoveSelect } from "../Shared/GridCard/dragMoveSelect"; import cx from "classnames"; +import { defaultPreviewVolume } from "src/core/config"; interface IScenePhoto { scene: GQL.SlimSceneDataFragment; @@ -42,6 +49,7 @@ export const SceneWallItem: React.FC< const { configuration } = useConfigurationContext(); const playSound = configuration?.interface.soundOnPreview ?? false; + const volume = configuration?.ui.previewVolume ?? defaultPreviewVolume; const showTitle = configuration?.interface.wallShowTitle ?? false; const height = Math.min(props.maxHeight, props.photo.height); @@ -75,7 +83,31 @@ export const SceneWallItem: React.FC< }; const video = props.photo.src.includes("preview"); - const ImagePreview = video ? "video" : "img"; + const previewProps = { + loading: "lazy", + loop: video, + muted: !video || !playSound || !active, + autoPlay: video, + playsInline: video, + key: props.photo.key, + src: props.photo.src, + width, + height, + alt: props.photo.alt, + onMouseEnter: () => setActive(true), + onMouseLeave: () => setActive(false), + onClick: handleClick, + onError: () => { + props.photo.onError?.(props.photo); + }, + }; + + const videoEl = useRef(null); + + useEffect(() => { + if (video && videoEl?.current?.volume) + videoEl.current.volume = playSound ? volume / 100 : 0; + }, [video, playSound, volume]); const { scene } = props.photo; const title = objectTitle(scene); @@ -111,24 +143,11 @@ export const SceneWallItem: React.FC< }} /> )} - setActive(true)} - onMouseLeave={() => setActive(false)} - onClick={handleClick} - onError={() => { - props.photo.onError?.(props.photo); - }} - /> + {video ? ( +
@@ -125,3 +83,23 @@ const TaggerConfig: React.FC = ({ }; export default TaggerConfig; + +export const ConfigButton: React.FC<{ + onClick: () => void; + showConfig: boolean; +}> = ({ onClick, showConfig }) => { + const intl = useIntl(); + + const showHideConfigId = showConfig + ? "actions.hide_configuration" + : "actions.show_configuration"; + + return ( + + ); +}; diff --git a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx index 8106d6a44..4391ba783 100755 --- a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx +++ b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx @@ -15,11 +15,10 @@ import { evictQueries, performerMutationImpactedQueries, } from "src/core/StashService"; -import { Manual } from "src/components/Help/Manual"; import { useConfigurationContext } from "src/hooks/Config"; import StashSearchResult from "./StashSearchResult"; -import TaggerConfig from "../TaggerConfig"; +import TaggerConfig, { ConfigButton } from "../TaggerConfig"; import { ITaggerConfig, PERFORMER_FIELDS } from "../constants"; import PerformerModal from "../PerformerModal"; import { useUpdatePerformer } from "../queries"; @@ -28,6 +27,7 @@ import { mergeStashIDs } from "src/utils/stashbox"; import { separateNamesAndStashIds } from "src/utils/stashIds"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { useTaggerConfig } from "../config"; +import { StashBoxSelectorField } from "../StashBoxSelector"; type JobFragment = Pick< GQL.Job, @@ -620,11 +620,9 @@ interface ITaggerProps { export const PerformerTagger: React.FC = ({ performers }) => { const jobsSubscribe = useJobsSubscribe(); - const intl = useIntl(); const { configuration: stashConfig } = useConfigurationContext(); const { config, setConfig } = useTaggerConfig(); const [showConfig, setShowConfig] = useState(false); - const [showManual, setShowManual] = useState(false); const [batchJobID, setBatchJobID] = useState(); const [batchJob, setBatchJob] = useState(); @@ -742,76 +740,80 @@ export const PerformerTagger: React.FC = ({ performers }) => { } } - const showHideConfigId = showConfig - ? "actions.hide_configuration" - : "actions.show_configuration"; + if (selectedEndpointIndex === -1 || !selectedEndpoint) { + return ( +
+

+ +

+
+ + el.scrollIntoView({ behavior: "smooth", block: "center" }) + } + > + + + ), + }} + /> +
+
+ ); + } return ( <> - setShowManual(false)} - defaultActiveTab="Tagger.md" - /> {renderStatus()}
- {selectedEndpointIndex !== -1 && selectedEndpoint ? ( - <> -
- - -
- - - setConfig({ ...config, excludedPerformerFields: fields }) - } - fields={PERFORMER_FIELDS} - entityName="performers" - /> - - - ) : ( -
-

- -

-
- Please see{" "} - - el.scrollIntoView({ behavior: "smooth", block: "center" }) +
+
+
+ + setConfig({ ...config, selectedEndpoint: endpoint }) } - > - Settings. - -
+ /> +
+
+
+ setShowConfig(!showConfig)} + /> +
+
- )} + + + setConfig({ ...config, excludedPerformerFields: fields }) + } + fields={PERFORMER_FIELDS} + entityName="performers" + /> +
Date: Mon, 2 Mar 2026 14:11:28 -0800 Subject: [PATCH 108/177] Add Selective generate (#6621) --- graphql/schema/types/metadata.graphql | 2 + internal/manager/manager_tasks.go | 22 +++++++ internal/manager/task_generate.go | 32 +++++++--- pkg/image/query.go | 31 ++++++++++ .../Settings/Tasks/LibraryTasks.tsx | 61 ++++++++++++++++--- ui/v2.5/src/locales/en-GB.json | 1 + 6 files changed, 133 insertions(+), 16 deletions(-) diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index 27cbb86fb..e2601150b 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -26,6 +26,8 @@ input GenerateMetadataInput { imageIDs: [ID!] "gallery ids to generate for" galleryIDs: [ID!] + "paths to run generate on, in addition to the other ID lists" + paths: [String!] "overwrite existing media" overwrite: Boolean diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index e84fda9b9..c9e840519 100644 --- a/internal/manager/manager_tasks.go +++ b/internal/manager/manager_tasks.go @@ -74,6 +74,28 @@ func getScanPaths(inputPaths []string) []*config.StashConfig { return ret } +// Filters the input array for paths that are within the paths managed by stash +func filterStashPaths(inputPaths []string) []string { + if len(inputPaths) == 0 { + return inputPaths + } + + stashPaths := config.GetInstance().GetStashPaths() + + var ret []string + for _, p := range inputPaths { + s := stashPaths.GetStashFromDirPath(p) + if s == nil { + logger.Warnf("%s is not in the configured stash paths", p) + continue + } + + ret = append(ret, p) + } + + return ret +} + // ScanSubscribe subscribes to a notification that is triggered when a // scan or clean is complete. func (s *Manager) ScanSubscribe(ctx context.Context) <-chan bool { diff --git a/internal/manager/task_generate.go b/internal/manager/task_generate.go index cc991d5d6..f2aab2b3c 100644 --- a/internal/manager/task_generate.go +++ b/internal/manager/task_generate.go @@ -43,6 +43,8 @@ type GenerateMetadataInput struct { GalleryIDs []string `json:"galleryIDs"` // overwrite existing media Overwrite bool `json:"overwrite"` + // paths to run generate on, in addition to the other ID lists + Paths []string `json:"paths"` } type GeneratePreviewOptionsInput struct { @@ -133,8 +135,13 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error r := j.repository if err := r.WithReadTxn(ctx, func(ctx context.Context) error { qb := r.Scene - if len(j.input.SceneIDs) == 0 && len(j.input.MarkerIDs) == 0 && len(j.input.ImageIDs) == 0 && len(j.input.GalleryIDs) == 0 { - j.queueTasks(ctx, g, queue) + if len(j.input.SceneIDs) == 0 && + len(j.input.MarkerIDs) == 0 && + len(j.input.ImageIDs) == 0 && + len(j.input.GalleryIDs) == 0 && + len(j.input.Paths) == 0 { + + j.queueTasks(ctx, g, nil, queue) } else { if len(j.input.SceneIDs) > 0 { scenes, err = qb.FindMany(ctx, sceneIDs) @@ -183,6 +190,11 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error } } } + + if len(j.input.Paths) > 0 { + paths := filterStashPaths(j.input.Paths) + j.queueTasks(ctx, g, paths, queue) + } } return nil @@ -276,17 +288,18 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error return nil } -func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) { +func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, paths []string, queue chan<- Task) { j.totals = totalsGenerate{} - j.queueScenesTasks(ctx, g, queue) - j.queueImagesTasks(ctx, g, queue) + j.queueScenesTasks(ctx, g, paths, queue) + j.queueImagesTasks(ctx, g, paths, queue) } -func (j *GenerateJob) queueScenesTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) { +func (j *GenerateJob) queueScenesTasks(ctx context.Context, g *generate.Generator, paths []string, queue chan<- Task) { const batchSize = 1000 findFilter := models.BatchFindFilter(batchSize) + sceneFilter := scene.FilterFromPaths(paths) r := j.repository @@ -295,7 +308,7 @@ func (j *GenerateJob) queueScenesTasks(ctx context.Context, g *generate.Generato return } - scenes, err := scene.Query(ctx, r.Scene, nil, findFilter) + scenes, err := scene.Query(ctx, r.Scene, sceneFilter, findFilter) if err != nil { logger.Errorf("Error encountered queuing files to scan: %s", err.Error()) return @@ -322,10 +335,11 @@ func (j *GenerateJob) queueScenesTasks(ctx context.Context, g *generate.Generato } } -func (j *GenerateJob) queueImagesTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) { +func (j *GenerateJob) queueImagesTasks(ctx context.Context, g *generate.Generator, paths []string, queue chan<- Task) { const batchSize = 1000 findFilter := models.BatchFindFilter(batchSize) + imageFilter := image.FilterFromPaths(paths) r := j.repository @@ -334,7 +348,7 @@ func (j *GenerateJob) queueImagesTasks(ctx context.Context, g *generate.Generato return } - images, err := image.Query(ctx, r.Image, nil, findFilter) + images, err := image.Query(ctx, r.Image, imageFilter, findFilter) if err != nil { logger.Errorf("Error encountered queuing files to scan: %s", err.Error()) return diff --git a/pkg/image/query.go b/pkg/image/query.go index b9b9e6628..958c9de9b 100644 --- a/pkg/image/query.go +++ b/pkg/image/query.go @@ -2,7 +2,9 @@ package image import ( "context" + "path/filepath" "strconv" + "strings" "github.com/stashapp/stash/pkg/models" ) @@ -46,6 +48,35 @@ func Query(ctx context.Context, qb Queryer, imageFilter *models.ImageFilterType, return images, nil } +// FilterFromPaths creates a ImageFilterType that filters using the provided +// paths. +func FilterFromPaths(paths []string) *models.ImageFilterType { + ret := &models.ImageFilterType{} + or := ret + sep := string(filepath.Separator) + + for _, p := range paths { + if !strings.HasSuffix(p, sep) { + p += sep + } + + if ret.Path == nil { + or = ret + } else { + newOr := &models.ImageFilterType{} + or.Or = newOr + or = newOr + } + + or.Path = &models.StringCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: p + "%", + } + } + + return ret +} + func CountByPerformerID(ctx context.Context, r QueryCounter, id int) (int, error) { filter := &models.ImageFilterType{ Performers: &models.MultiCriterionInput{ diff --git a/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx b/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx index 605e37933..6e9c13ea4 100644 --- a/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx @@ -79,6 +79,7 @@ export const LibraryTasks: React.FC = () => { scan: false, autoTag: false, identify: false, + generate: false, }); function getDefaultScanOptions(): GQL.ScanMetadataInput { @@ -265,6 +266,41 @@ export const LibraryTasks: React.FC = () => { ); } + function renderGenerateDialog() { + if (!dialogOpen.generate) { + return; + } + + return ; + } + + function onGenerateDialogClosed(paths?: string[]) { + if (paths) { + runGenerate(paths); + } + + setDialogOpen({ generate: false }); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async function runGenerate(paths?: string[]) { + try { + await mutateMetadataGenerate({ + ...generateOptions, + paths, + }); + + Toast.success( + intl.formatMessage( + { id: "config.tasks.added_job_to_queue" }, + { operation_name: intl.formatMessage({ id: "actions.generate" }) } + ) + ); + } catch (e) { + Toast.error(e); + } + } + async function onGenerateClicked() { try { // insert preview options here instead of loading them @@ -307,6 +343,7 @@ export const LibraryTasks: React.FC = () => { {renderScanDialog()} {renderAutoTagDialog()} {maybeRenderIdentifyDialog()} + {renderGenerateDialog()} { subHeadingID: "config.tasks.generate_desc", }} topLevel={ - + <> + + + } collapsible > diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index a25f3c765..56b11f3f9 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -123,6 +123,7 @@ "invert_selection": "Invert Selection", "selective_auto_tag": "Selective auto tag", "selective_clean": "Selective clean", + "selective_generate": "Selective generate", "selective_scan": "Selective scan", "set_as_default": "Set as default", "set_back_image": "Back image…", From cd0980201c6eda6046f8a61f8a34cf98603fed0f Mon Sep 17 00:00:00 2001 From: Matt Stone Date: Wed, 4 Mar 2026 07:17:14 +1000 Subject: [PATCH 109/177] feat: Add .stashignore support for gitignore-style scan exclusions (#6485) Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- go.mod | 1 + go.sum | 2 + internal/manager/scan_stashignore_test.go | 268 +++++++++++ internal/manager/task_clean.go | 11 +- internal/manager/task_scan.go | 8 + pkg/file/stashignore.go | 255 +++++++++++ pkg/file/stashignore_test.go | 523 ++++++++++++++++++++++ ui/v2.5/src/docs/en/Manual/Tasks.md | 33 ++ 8 files changed, 1099 insertions(+), 2 deletions(-) create mode 100644 internal/manager/scan_stashignore_test.go create mode 100644 pkg/file/stashignore.go create mode 100644 pkg/file/stashignore_test.go diff --git a/go.mod b/go.mod index db0d6fe34..348036710 100644 --- a/go.mod +++ b/go.mod @@ -44,6 +44,7 @@ require ( github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/remeh/sizedwaitgroup v1.0.0 github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd + github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cast v1.6.0 github.com/spf13/pflag v1.0.6 diff --git a/go.sum b/go.sum index dbe82cf99..4e19720f5 100644 --- a/go.sum +++ b/go.sum @@ -537,6 +537,8 @@ github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= github.com/sagikazarmark/crypt v0.4.0/go.mod h1:ALv2SRj7GxYV4HO9elxH9nS6M9gW+xDNxqmyJ6RfDFM= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= diff --git a/internal/manager/scan_stashignore_test.go b/internal/manager/scan_stashignore_test.go new file mode 100644 index 000000000..fafd246e8 --- /dev/null +++ b/internal/manager/scan_stashignore_test.go @@ -0,0 +1,268 @@ +//go:build integration +// +build integration + +package manager + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "testing" + + "github.com/stashapp/stash/pkg/file" + + // Necessary to register custom migrations. + _ "github.com/stashapp/stash/pkg/sqlite/migrations" +) + +// stashIgnorePathFilter wraps StashIgnoreFilter to implement PathFilter for testing. +// It provides a fixed library root for the filter. +type stashIgnorePathFilter struct { + filter *file.StashIgnoreFilter + libraryRoot string +} + +func (f *stashIgnorePathFilter) Accept(ctx context.Context, path string, info fs.FileInfo) bool { + return f.filter.Accept(ctx, path, info, f.libraryRoot) +} + +// createTestFileOnDisk creates a file with some content. +func createTestFileOnDisk(t *testing.T, dir, name string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("failed to create directory for %s: %v", path, err) + } + // Write some content so the file has a non-zero size. + if err := os.WriteFile(path, []byte("test content for "+name), 0644); err != nil { + t.Fatalf("failed to create file %s: %v", path, err) + } + return path +} + +// createStashIgnoreFile creates a .stashignore file with the given content. +func createStashIgnoreFile(t *testing.T, dir, content string) { + t.Helper() + path := filepath.Join(dir, ".stashignore") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to create .stashignore: %v", err) + } +} + +func TestScannerWithStashIgnore(t *testing.T) { + // Create temp directory structure. + tmpDir := t.TempDir() + + // Create test files. + createTestFileOnDisk(t, tmpDir, "video1.mp4") + createTestFileOnDisk(t, tmpDir, "video2.mp4") + createTestFileOnDisk(t, tmpDir, "ignore_me.mp4") + createTestFileOnDisk(t, tmpDir, "subdir/video3.mp4") + createTestFileOnDisk(t, tmpDir, "subdir/skip_this.mp4") + createTestFileOnDisk(t, tmpDir, "excluded_dir/video4.mp4") + createTestFileOnDisk(t, tmpDir, "temp/processing.mp4") + + // Create .stashignore file. + stashignore := `# Ignore specific files +ignore_me.mp4 +subdir/skip_this.mp4 + +# Ignore directories +excluded_dir/ +temp/ +` + createStashIgnoreFile(t, tmpDir, stashignore) + + // Create stashignore filter with library root. + stashIgnoreFilter := &stashIgnorePathFilter{ + filter: file.NewStashIgnoreFilter(), + libraryRoot: tmpDir, + } + + // Create scanner. + scanner := &file.Scanner{ + ScanFilters: []file.PathFilter{stashIgnoreFilter}, + } + + testScenarios := []struct { + path string + accepted bool + }{ + {filepath.Join(tmpDir, "video1.mp4"), true}, + {filepath.Join(tmpDir, "video2.mp4"), true}, + {filepath.Join(tmpDir, "ignore_me.mp4"), false}, + {filepath.Join(tmpDir, "subdir/video3.mp4"), true}, + {filepath.Join(tmpDir, "subdir/skip_this.mp4"), false}, + {filepath.Join(tmpDir, "excluded_dir/video4.mp4"), false}, + {filepath.Join(tmpDir, "temp/processing.mp4"), false}, + } + + ctx := context.Background() + + for _, scenario := range testScenarios { + info, err := os.Stat(scenario.path) + if err != nil { + t.Fatalf("failed to stat file %s: %v", scenario.path, err) + } + accepted := scanner.AcceptEntry(ctx, scenario.path, info) + + if accepted != scenario.accepted { + t.Errorf("unexpected accept result for %s: expected %v, got %v", + scenario.path, scenario.accepted, accepted) + } + } +} + +func TestScannerWithNestedStashIgnore(t *testing.T) { + // Create temp directory structure. + tmpDir := t.TempDir() + + // Create test files. + createTestFileOnDisk(t, tmpDir, "root.mp4") + createTestFileOnDisk(t, tmpDir, "root.tmp") + createTestFileOnDisk(t, tmpDir, "subdir/sub.mp4") + createTestFileOnDisk(t, tmpDir, "subdir/sub.log") + createTestFileOnDisk(t, tmpDir, "subdir/sub.tmp") + + // Root .stashignore excludes *.tmp. + createStashIgnoreFile(t, tmpDir, "*.tmp\n") + + // Subdir .stashignore excludes *.log. + createStashIgnoreFile(t, filepath.Join(tmpDir, "subdir"), "*.log\n") + + // Create stashignore filter with library root. + stashIgnoreFilter := &stashIgnorePathFilter{ + filter: file.NewStashIgnoreFilter(), + libraryRoot: tmpDir, + } + + // Create scanner. + scanner := &file.Scanner{ + ScanFilters: []file.PathFilter{stashIgnoreFilter}, + } + + testScenarios := []struct { + path string + accepted bool + }{ + {filepath.Join(tmpDir, "root.mp4"), true}, + {filepath.Join(tmpDir, "root.tmp"), false}, + {filepath.Join(tmpDir, "subdir/sub.mp4"), true}, + {filepath.Join(tmpDir, "subdir/sub.log"), false}, + {filepath.Join(tmpDir, "subdir/sub.tmp"), false}, + } + + ctx := context.Background() + + for _, scenario := range testScenarios { + info, err := os.Stat(scenario.path) + if err != nil { + t.Fatalf("failed to stat file %s: %v", scenario.path, err) + } + accepted := scanner.AcceptEntry(ctx, scenario.path, info) + + if accepted != scenario.accepted { + t.Errorf("unexpected accept result for %s: expected %v, got %v", + scenario.path, scenario.accepted, accepted) + } + } +} + +func TestScannerWithoutStashIgnore(t *testing.T) { + // Create temp directory structure (no .stashignore). + tmpDir := t.TempDir() + + // Create test files. + createTestFileOnDisk(t, tmpDir, "video1.mp4") + createTestFileOnDisk(t, tmpDir, "video2.mp4") + createTestFileOnDisk(t, tmpDir, "subdir/video3.mp4") + + // Create stashignore filter with library root (but no .stashignore file exists). + stashIgnoreFilter := &stashIgnorePathFilter{ + filter: file.NewStashIgnoreFilter(), + libraryRoot: tmpDir, + } + + // Create scanner. + scanner := &file.Scanner{ + ScanFilters: []file.PathFilter{stashIgnoreFilter}, + } + + testScenarios := []struct { + path string + accepted bool + }{ + {filepath.Join(tmpDir, "video1.mp4"), true}, + {filepath.Join(tmpDir, "video2.mp4"), true}, + {filepath.Join(tmpDir, "subdir/video3.mp4"), true}, + } + + ctx := context.Background() + + for _, scenario := range testScenarios { + info, err := os.Stat(scenario.path) + if err != nil { + t.Fatalf("failed to stat file %s: %v", scenario.path, err) + } + accepted := scanner.AcceptEntry(ctx, scenario.path, info) + + if accepted != scenario.accepted { + t.Errorf("unexpected accept result for %s: expected %v, got %v", + scenario.path, scenario.accepted, accepted) + } + } +} + +func TestScannerWithNegationPattern(t *testing.T) { + // Create temp directory structure. + tmpDir := t.TempDir() + + // Create test files. + createTestFileOnDisk(t, tmpDir, "file1.tmp") + createTestFileOnDisk(t, tmpDir, "file2.tmp") + createTestFileOnDisk(t, tmpDir, "keep_this.tmp") + createTestFileOnDisk(t, tmpDir, "video.mp4") + + // Create .stashignore with negation. + stashignore := `*.tmp +!keep_this.tmp +` + createStashIgnoreFile(t, tmpDir, stashignore) + + // Create stashignore filter with library root. + stashIgnoreFilter := &stashIgnorePathFilter{ + filter: file.NewStashIgnoreFilter(), + libraryRoot: tmpDir, + } + + // Create scanner. + scanner := &file.Scanner{ + ScanFilters: []file.PathFilter{stashIgnoreFilter}, + } + + testScenarios := []struct { + path string + accepted bool + }{ + {filepath.Join(tmpDir, "file1.tmp"), false}, + {filepath.Join(tmpDir, "file2.tmp"), false}, + {filepath.Join(tmpDir, "keep_this.tmp"), true}, + {filepath.Join(tmpDir, "video.mp4"), true}, + } + + ctx := context.Background() + + for _, scenario := range testScenarios { + info, err := os.Stat(scenario.path) + if err != nil { + t.Fatalf("failed to stat file %s: %v", scenario.path, err) + } + accepted := scanner.AcceptEntry(ctx, scenario.path, info) + + if accepted != scenario.accepted { + t.Errorf("unexpected accept result for %s: expected %v, got %v", + scenario.path, scenario.accepted, accepted) + } + } +} diff --git a/internal/manager/task_clean.go b/internal/manager/task_clean.go index ddd86e2f2..9a20b3990 100644 --- a/internal/manager/task_clean.go +++ b/internal/manager/task_clean.go @@ -154,6 +154,7 @@ func newCleanFilter(c *config.Config) *cleanFilter { generatedPath: c.GetGeneratedPath(), videoExcludeRegex: generateRegexps(c.GetExcludes()), imageExcludeRegex: generateRegexps(c.GetImageExcludes()), + stashIgnoreFilter: file.NewStashIgnoreFilter(), }, } } @@ -173,12 +174,18 @@ func (f *cleanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) } if stash == nil { - logger.Infof("%s not in any stash library directories. Marking to clean: \"%s\"", fileOrFolder, path) + logger.Infof("%s not in any stash library directories. Marking to clean: %q", fileOrFolder, path) return false } if fsutil.IsPathInDir(generatedPath, path) { - logger.Infof("%s is in generated path. Marking to clean: \"%s\"", fileOrFolder, path) + logger.Infof("%s is in generated path. Marking to clean: %q", fileOrFolder, path) + return false + } + + // Check .stashignore files, bounded to the library root. + if !f.stashIgnoreFilter.Accept(ctx, path, info, stash.Path) { + logger.Infof("%s is excluded due to .stashignore. Marking to clean: %q", fileOrFolder, path) return false } diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index cf675a5af..a006abbf8 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -549,6 +549,7 @@ type scanFilter struct { videoExcludeRegex []*regexp.Regexp imageExcludeRegex []*regexp.Regexp minModTime time.Time + stashIgnoreFilter *file.StashIgnoreFilter } func newScanFilter(c *config.Config, repo models.Repository, minModTime time.Time) *scanFilter { @@ -560,6 +561,7 @@ func newScanFilter(c *config.Config, repo models.Repository, minModTime time.Tim videoExcludeRegex: generateRegexps(c.GetExcludes()), imageExcludeRegex: generateRegexps(c.GetImageExcludes()), minModTime: minModTime, + stashIgnoreFilter: file.NewStashIgnoreFilter(), } } @@ -580,6 +582,12 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) return false } + // Check .stashignore files, bounded to the library root. + if !f.stashIgnoreFilter.Accept(ctx, path, info, s.Path) { + logger.Debugf("Skipping %s due to .stashignore", path) + return false + } + isVideoFile := useAsVideo(path) isImageFile := useAsImage(path) isZipFile := fsutil.MatchExtension(path, f.zipExt) diff --git a/pkg/file/stashignore.go b/pkg/file/stashignore.go new file mode 100644 index 000000000..160b5c224 --- /dev/null +++ b/pkg/file/stashignore.go @@ -0,0 +1,255 @@ +package file + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "strings" + "sync" + + lru "github.com/hashicorp/golang-lru/v2" + ignore "github.com/sabhiram/go-gitignore" + "github.com/stashapp/stash/pkg/logger" +) + +const stashIgnoreFilename = ".stashignore" + +// entriesCacheSize is the size of the LRU cache for collected ignore entries. +// This cache stores the computed list of ignore entries per directory, avoiding +// repeated directory tree walks for files in the same directory. +const entriesCacheSize = 500 + +// StashIgnoreFilter implements PathFilter to exclude files/directories +// based on .stashignore files with gitignore-style patterns. +type StashIgnoreFilter struct { + // cache stores compiled ignore patterns per directory. + cache sync.Map // map[string]*ignoreEntry + // entriesCache stores collected ignore entries per (dir, libraryRoot) pair. + // This avoids recomputing the entry list for every file in the same directory. + entriesCache *lru.Cache[string, []*ignoreEntry] +} + +// ignoreEntry holds the compiled ignore patterns for a directory. +type ignoreEntry struct { + // patterns is the compiled gitignore matcher for this directory. + patterns *ignore.GitIgnore + // dir is the directory this entry applies to. + dir string +} + +// NewStashIgnoreFilter creates a new StashIgnoreFilter. +func NewStashIgnoreFilter() *StashIgnoreFilter { + // Create the LRU cache for collected entries. + // Ignore error as it only fails if size <= 0. + entriesCache, _ := lru.New[string, []*ignoreEntry](entriesCacheSize) + return &StashIgnoreFilter{ + entriesCache: entriesCache, + } +} + +// Accept returns true if the path should be included in the scan. +// It checks for .stashignore files in the directory hierarchy and +// applies gitignore-style pattern matching. +// The libraryRoot parameter bounds the search for .stashignore files - +// only directories within the library root are checked. +func (f *StashIgnoreFilter) Accept(ctx context.Context, path string, info fs.FileInfo, libraryRoot string) bool { + // If no library root provided, accept the file (safety fallback). + if libraryRoot == "" { + return true + } + + // Get the directory containing this path. + dir := filepath.Dir(path) + + // Collect all applicable ignore entries from library root to this directory. + entries := f.collectIgnoreEntries(dir, libraryRoot) + + // If no .stashignore files found, accept the file. + if len(entries) == 0 { + return true + } + + // Check each ignore entry in order (from root to most specific). + // Later entries can override earlier ones with negation patterns. + ignored := false + for _, entry := range entries { + // Get path relative to the ignore file's directory. + entryRelPath, err := filepath.Rel(entry.dir, path) + if err != nil { + continue + } + entryRelPath = filepath.ToSlash(entryRelPath) + if info.IsDir() { + entryRelPath += "/" + } + + if entry.patterns.MatchesPath(entryRelPath) { + ignored = true + } + } + + return !ignored +} + +// collectIgnoreEntries gathers all ignore entries from library root to the given directory. +// It walks up the directory tree from dir to libraryRoot and returns entries in order +// from root to most specific. Results are cached to avoid repeated computation for +// files in the same directory. +func (f *StashIgnoreFilter) collectIgnoreEntries(dir string, libraryRoot string) []*ignoreEntry { + // Clean paths for consistent comparison and cache key generation. + dir = filepath.Clean(dir) + libraryRoot = filepath.Clean(libraryRoot) + + // Build cache key from dir and libraryRoot. + cacheKey := dir + "\x00" + libraryRoot + + // Check the entries cache first. + if cached, ok := f.entriesCache.Get(cacheKey); ok { + return cached + } + + // Try subdirectory shortcut: if parent's entries are cached, extend them. + if dir != libraryRoot { + parent := filepath.Dir(dir) + if isPathInOrEqual(libraryRoot, parent) { + parentKey := parent + "\x00" + libraryRoot + if parentEntries, ok := f.entriesCache.Get(parentKey); ok { + // Parent is cached - just check if current dir has a .stashignore. + entries := parentEntries + if entry := f.getOrLoadIgnoreEntry(dir); entry != nil { + // Copy parent slice and append to avoid mutating cached slice. + entries = make([]*ignoreEntry, len(parentEntries), len(parentEntries)+1) + copy(entries, parentEntries) + entries = append(entries, entry) + } + f.entriesCache.Add(cacheKey, entries) + return entries + } + } + } + + // No cache hit - compute from scratch. + // Walk up from dir to library root, collecting directories. + var dirs []string + current := dir + for { + // Check if we're still within the library root. + if !isPathInOrEqual(libraryRoot, current) { + break + } + + dirs = append(dirs, current) + + // Stop if we've reached the library root. + if current == libraryRoot { + break + } + + parent := filepath.Dir(current) + if parent == current { + // Reached filesystem root without finding library root. + break + } + current = parent + } + + // Reverse to get root-to-leaf order. + for i, j := 0, len(dirs)-1; i < j; i, j = i+1, j-1 { + dirs[i], dirs[j] = dirs[j], dirs[i] + } + + // Check each directory for .stashignore files. + var entries []*ignoreEntry + for _, d := range dirs { + if entry := f.getOrLoadIgnoreEntry(d); entry != nil { + entries = append(entries, entry) + } + } + + // Cache the result. + f.entriesCache.Add(cacheKey, entries) + + return entries +} + +// isPathInOrEqual checks if path is equal to or inside root. +func isPathInOrEqual(root, path string) bool { + if path == root { + return true + } + // Check if path starts with root + separator. + return strings.HasPrefix(path, root+string(filepath.Separator)) +} + +// getOrLoadIgnoreEntry returns the cached ignore entry for a directory, or loads it. +func (f *StashIgnoreFilter) getOrLoadIgnoreEntry(dir string) *ignoreEntry { + // Check cache first. + if cached, ok := f.cache.Load(dir); ok { + entry := cached.(*ignoreEntry) + if entry.patterns == nil { + return nil // Cached negative result. + } + return entry + } + + // Try to load .stashignore from this directory. + stashIgnorePath := filepath.Join(dir, stashIgnoreFilename) + patterns, err := f.loadIgnoreFile(stashIgnorePath) + if err != nil { + if !os.IsNotExist(err) { + logger.Warnf("Failed to load .stashignore from %s: %v", dir, err) + } + f.cache.Store(dir, &ignoreEntry{patterns: nil, dir: dir}) + return nil + } + if patterns == nil { + // File exists but has no patterns (empty or only comments). + f.cache.Store(dir, &ignoreEntry{patterns: nil, dir: dir}) + return nil + } + + logger.Debugf("Loaded .stashignore from %s", dir) + + entry := &ignoreEntry{ + patterns: patterns, + dir: dir, + } + f.cache.Store(dir, entry) + return entry +} + +// loadIgnoreFile loads and compiles a .stashignore file. +func (f *StashIgnoreFilter) loadIgnoreFile(path string) (*ignore.GitIgnore, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + lines := strings.Split(string(data), "\n") + var patterns []string + + for _, line := range lines { + // Trim trailing whitespace (but preserve leading for patterns). + line = strings.TrimRight(line, " \t\r") + + // Skip empty lines. + if line == "" { + continue + } + + // Skip comments (but not escaped #). + if strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "\\#") { + continue + } + + patterns = append(patterns, line) + } + + if len(patterns) == 0 { + // File exists but has no patterns (e.g., only comments). + return nil, nil + } + + return ignore.CompileIgnoreLines(patterns...), nil +} diff --git a/pkg/file/stashignore_test.go b/pkg/file/stashignore_test.go new file mode 100644 index 000000000..5297f544b --- /dev/null +++ b/pkg/file/stashignore_test.go @@ -0,0 +1,523 @@ +package file + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "sort" + "testing" +) + +// Helper to create an empty file. +func createTestFile(t *testing.T, dir, name string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("failed to create directory for %s: %v", path, err) + } + if err := os.WriteFile(path, []byte{}, 0644); err != nil { + t.Fatalf("failed to create file %s: %v", path, err) + } +} + +// Helper to create a file with content. +func createTestFileWithContent(t *testing.T, dir, name, content string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("failed to create directory for %s: %v", path, err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to create file %s: %v", path, err) + } +} + +// Helper to create a directory. +func createTestDir(t *testing.T, dir, name string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(path, 0755); err != nil { + t.Fatalf("failed to create directory %s: %v", path, err) + } +} + +// walkAndFilter walks the directory tree and returns paths accepted by the filter. +// Returns paths relative to root for easier assertion. +func walkAndFilter(t *testing.T, root string, filter *StashIgnoreFilter) []string { + t.Helper() + var accepted []string + ctx := context.Background() + + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip the root directory itself. + if path == root { + return nil + } + + info, err := d.Info() + if err != nil { + return err + } + + if filter.Accept(ctx, path, info, root) { + relPath, _ := filepath.Rel(root, path) + accepted = append(accepted, relPath) + } else if info.IsDir() { + // If directory is rejected, skip it. + return filepath.SkipDir + } + + return nil + }) + + if err != nil { + t.Fatalf("walk failed: %v", err) + } + + sort.Strings(accepted) + return accepted +} + +// assertPathsEqual checks that the accepted paths match expected. +func assertPathsEqual(t *testing.T, expected, actual []string) { + t.Helper() + sort.Strings(expected) + + if len(expected) != len(actual) { + t.Errorf("path count mismatch:\nexpected %d: %v\nactual %d: %v", len(expected), expected, len(actual), actual) + return + } + + for i := range expected { + if expected[i] != actual[i] { + t.Errorf("path mismatch at index %d:\nexpected: %s\nactual: %s", i, expected[i], actual[i]) + } + } +} + +func TestStashIgnore_ExactFilename(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestFile(t, tmpDir, "video2.mp4") + createTestFile(t, tmpDir, "ignore_me.mp4") + + // Create .stashignore that excludes exact filename. + createTestFileWithContent(t, tmpDir, ".stashignore", "ignore_me.mp4\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "video1.mp4", + "video2.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_WildcardPattern(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestFile(t, tmpDir, "video2.mp4") + createTestFile(t, tmpDir, "temp1.tmp") + createTestFile(t, tmpDir, "temp2.tmp") + createTestFile(t, tmpDir, "notes.log") + + // Create .stashignore that excludes by extension. + createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n*.log\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "video1.mp4", + "video2.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_DirectoryExclusion(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestDir(t, tmpDir, "excluded_dir") + createTestFile(t, tmpDir, "excluded_dir/video2.mp4") + createTestFile(t, tmpDir, "excluded_dir/video3.mp4") + createTestDir(t, tmpDir, "included_dir") + createTestFile(t, tmpDir, "included_dir/video4.mp4") + + // Create .stashignore that excludes a directory. + createTestFileWithContent(t, tmpDir, ".stashignore", "excluded_dir/\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "included_dir", + "included_dir/video4.mp4", + "video1.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_NegationPattern(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "file1.tmp") + createTestFile(t, tmpDir, "file2.tmp") + createTestFile(t, tmpDir, "keep_this.tmp") + + // Create .stashignore that excludes *.tmp but keeps one. + createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n!keep_this.tmp\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "keep_this.tmp", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_CommentsAndEmptyLines(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestFile(t, tmpDir, "ignore_me.mp4") + + // Create .stashignore with comments and empty lines. + stashignore := `# This is a comment +ignore_me.mp4 + +# Another comment + +` + createTestFileWithContent(t, tmpDir, ".stashignore", stashignore) + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "video1.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_NestedStashIgnoreFiles(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "root_video.mp4") + createTestFile(t, tmpDir, "root_ignore.tmp") + createTestDir(t, tmpDir, "subdir") + createTestFile(t, tmpDir, "subdir/sub_video.mp4") + createTestFile(t, tmpDir, "subdir/sub_ignore.log") + createTestFile(t, tmpDir, "subdir/also_tmp.tmp") + + // Root .stashignore excludes *.tmp. + createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n") + + // Subdir .stashignore excludes *.log. + createTestFileWithContent(t, tmpDir, "subdir/.stashignore", "*.log\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + // *.tmp from root should apply everywhere. + // *.log from subdir should only apply in subdir. + expected := []string{ + ".stashignore", + "root_video.mp4", + "subdir", + "subdir/.stashignore", + "subdir/sub_video.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_PathPattern(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestDir(t, tmpDir, "subdir") + createTestFile(t, tmpDir, "subdir/video2.mp4") + createTestFile(t, tmpDir, "subdir/skip_this.mp4") + + // Create .stashignore that excludes a specific path. + createTestFileWithContent(t, tmpDir, ".stashignore", "subdir/skip_this.mp4\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "subdir", + "subdir/video2.mp4", + "video1.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_DoubleStarPattern(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestDir(t, tmpDir, "a") + createTestFile(t, tmpDir, "a/video2.mp4") + createTestDir(t, tmpDir, "a/temp") + createTestFile(t, tmpDir, "a/temp/video3.mp4") + createTestDir(t, tmpDir, "a/b") + createTestDir(t, tmpDir, "a/b/temp") + createTestFile(t, tmpDir, "a/b/temp/video4.mp4") + + // Create .stashignore that excludes temp directories at any level. + createTestFileWithContent(t, tmpDir, ".stashignore", "**/temp/\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "a", + "a/b", + "a/video2.mp4", + "video1.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_LeadingSlashPattern(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "ignore.mp4") + createTestDir(t, tmpDir, "subdir") + createTestFile(t, tmpDir, "subdir/ignore.mp4") + + // Create .stashignore that excludes only at root level. + createTestFileWithContent(t, tmpDir, ".stashignore", "/ignore.mp4\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + // Only root ignore.mp4 should be excluded. + expected := []string{ + ".stashignore", + "subdir", + "subdir/ignore.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_NoStashIgnoreFile(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files without any .stashignore. + createTestFile(t, tmpDir, "video1.mp4") + createTestFile(t, tmpDir, "video2.mp4") + createTestDir(t, tmpDir, "subdir") + createTestFile(t, tmpDir, "subdir/video3.mp4") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + // All files should be accepted. + expected := []string{ + "subdir", + "subdir/video3.mp4", + "video1.mp4", + "video2.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_HiddenDirectories(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files including hidden directory. + createTestFile(t, tmpDir, "video1.mp4") + createTestDir(t, tmpDir, ".hidden") + createTestFile(t, tmpDir, ".hidden/video2.mp4") + + // Create .stashignore that excludes hidden directories. + createTestFileWithContent(t, tmpDir, ".stashignore", ".*\n!.stashignore\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "video1.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_MultiplePatternsSameLine(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestFile(t, tmpDir, "file.tmp") + createTestFile(t, tmpDir, "file.log") + createTestFile(t, tmpDir, "file.bak") + + // Each pattern should be on its own line. + createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n*.log\n*.bak\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "video1.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_TrailingSpaces(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestFile(t, tmpDir, "ignore_me.mp4") + + // Pattern with trailing spaces (should be trimmed). + createTestFileWithContent(t, tmpDir, ".stashignore", "ignore_me.mp4 \n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "video1.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_EscapedHash(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestFile(t, tmpDir, "#filename.mp4") + + // Escaped hash should match literal # character. + createTestFileWithContent(t, tmpDir, ".stashignore", "\\#filename.mp4\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "video1.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_CaseSensitiveMatching(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files - use distinct names that work on all filesystems. + createTestFile(t, tmpDir, "video_lower.mp4") + createTestFile(t, tmpDir, "VIDEO_UPPER.mp4") + createTestFile(t, tmpDir, "other.avi") + + // Pattern should match exactly (case-sensitive). + createTestFileWithContent(t, tmpDir, ".stashignore", "video_lower.mp4\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + // Only exact match is excluded. + expected := []string{ + ".stashignore", + "VIDEO_UPPER.mp4", + "other.avi", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_ComplexScenario(t *testing.T) { + tmpDir := t.TempDir() + + // Create a complex directory structure. + createTestFile(t, tmpDir, "video1.mp4") + createTestFile(t, tmpDir, "video2.avi") + createTestFile(t, tmpDir, "thumbnail.jpg") + createTestFile(t, tmpDir, "metadata.nfo") + createTestDir(t, tmpDir, "movies") + createTestFile(t, tmpDir, "movies/movie1.mp4") + createTestFile(t, tmpDir, "movies/movie1.nfo") + createTestDir(t, tmpDir, "movies/.thumbnails") + createTestFile(t, tmpDir, "movies/.thumbnails/thumb1.jpg") + createTestDir(t, tmpDir, "temp") + createTestFile(t, tmpDir, "temp/processing.mp4") + createTestDir(t, tmpDir, "backup") + createTestFile(t, tmpDir, "backup/video1.mp4.bak") + + // Complex .stashignore. + stashignore := `# Ignore metadata files +*.nfo + +# Ignore hidden directories +.* +!.stashignore + +# Ignore temp and backup directories +temp/ +backup/ + +# But keep thumbnails in specific location +!movies/.thumbnails/ +` + createTestFileWithContent(t, tmpDir, ".stashignore", stashignore) + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "movies", + "movies/.thumbnails", + "movies/.thumbnails/thumb1.jpg", + "movies/movie1.mp4", + "thumbnail.jpg", + "video1.mp4", + "video2.avi", + } + + assertPathsEqual(t, expected, accepted) +} diff --git a/ui/v2.5/src/docs/en/Manual/Tasks.md b/ui/v2.5/src/docs/en/Manual/Tasks.md index 4191afd24..584759b09 100644 --- a/ui/v2.5/src/docs/en/Manual/Tasks.md +++ b/ui/v2.5/src/docs/en/Manual/Tasks.md @@ -10,6 +10,39 @@ Stash currently identifies files by performing a quick file hash. This means tha Stash currently ignores duplicate files. If two files contain identical content, only the first one it comes across is used. +### Ignoring Files with .stashignore + +You can create `.stashignore` files to exclude specific files or directories from being scanned. These files use gitignore-style pattern matching syntax. + +Place a `.stashignore` file in any directory within your library. The patterns in that file will apply to all files and subdirectories within that directory. You can have multiple `.stashignore` files at different levels of your directory hierarchy - patterns from parent directories cascade down to child directories. + +**Supported patterns:** + +| Pattern | Description | +|---------|-------------| +| `filename.mp4` | Ignore a specific file. | +| `*.tmp` | Ignore all files with a specific extension. | +| `temp/` | Ignore a directory and all its contents. | +| `**/cache/` | Ignore directories named "cache" at any level. | +| `!important.mp4` | Negation - do not ignore this file even if it matches a previous pattern. | +| `# comment` | Lines starting with # are comments. | +| `\#filename` | Use backslash to match a literal # character. | + +**Example .stashignore file:** + +``` +# Ignore temporary files +*.tmp +*.log + +# Ignore specific directories +temp/ +.thumbnails/ + +# But keep this specific file +!important.tmp +``` + The scan task accepts the following options: | Option | Description | From f7da37400bac091f2eb706909865d569dbac9775 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:10:07 +1100 Subject: [PATCH 110/177] Fix preview scrubber scaling on smaller sizes (#6640) --- .../src/components/Scenes/PreviewScrubber.tsx | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx index 8c9d3097d..e60c638d7 100644 --- a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx +++ b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx @@ -46,6 +46,18 @@ export const PreviewScrubber: React.FC = ({ const [hasLoaded, setHasLoaded] = useState(false); const spriteInfo = useSpriteInfo(hasLoaded ? vttPath : undefined); + const spriteSheetSize = useMemo(() => { + if (!spriteInfo) { + return { x: 0, y: 0 }; + } + + // calculate total width/height of scrubber image so we can scale it + const maxX = Math.max(...spriteInfo.map((sprite) => sprite.x + sprite.w)); + const maxY = Math.max(...spriteInfo.map((sprite) => sprite.y + sprite.h)); + + return { x: maxX, y: maxY }; + }, [spriteInfo]); + const sprite = useMemo(() => { if (!spriteInfo || activeIndex === undefined) { return undefined; @@ -69,17 +81,17 @@ export const PreviewScrubber: React.FC = ({ const clientRect = imageParent.getBoundingClientRect(); const scale = scaleToFit(sprite, clientRect); - const spriteSheet = new Image(); - spriteSheet.src = sprite.url; setStyle({ - backgroundPosition: `${-sprite.x}px ${-sprite.y}px`, + backgroundPosition: `${-sprite.x * scale}px ${-sprite.y * scale}px`, backgroundImage: `url(${sprite.url})`, - width: `${sprite.w}px`, - height: `${sprite.h}px`, - transform: `scale(${scale})`, + backgroundSize: `${spriteSheetSize.x * scale}px ${ + spriteSheetSize.y * scale + }px`, + width: `${sprite.w * scale}px`, + height: `${sprite.h * scale}px`, }); - }, [sprite]); + }, [sprite, spriteSheetSize]); const currentTime = useMemo(() => { if (!sprite) return undefined; From fbf91b25262dcbb39a8cdcc566ae39d5c8d757e6 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:01:31 -0800 Subject: [PATCH 111/177] New: Add From Clipboard to Set Image (#6637) * add from clipboard to UI * only trigger when input not focused --- ui/v2.5/src/components/Shared/ImageInput.tsx | 37 ++++++++- ui/v2.5/src/locales/en-GB.json | 4 + ui/v2.5/src/utils/image.tsx | 85 ++++++++++++-------- 3 files changed, 93 insertions(+), 33 deletions(-) diff --git a/ui/v2.5/src/components/Shared/ImageInput.tsx b/ui/v2.5/src/components/Shared/ImageInput.tsx index 7675da41f..57b8f06f8 100644 --- a/ui/v2.5/src/components/Shared/ImageInput.tsx +++ b/ui/v2.5/src/components/Shared/ImageInput.tsx @@ -10,8 +10,10 @@ import { import { useIntl } from "react-intl"; import { ModalComponent } from "./Modal"; import { Icon } from "./Icon"; -import { faFile, faLink } from "@fortawesome/free-solid-svg-icons"; +import { faClipboard, faFile, faLink } from "@fortawesome/free-solid-svg-icons"; import { PatchComponent } from "src/patch"; +import ImageUtils from "src/utils/image"; +import { useToast } from "src/hooks/Toast"; interface IImageInput { isEditing: boolean; @@ -39,6 +41,7 @@ export const ImageInput: React.FC = PatchComponent( const [isShowDialog, setIsShowDialog] = useState(false); const [url, setURL] = useState(""); const intl = useIntl(); + const Toast = useToast(); if (!isEditing) return
; @@ -58,6 +61,28 @@ export const ImageInput: React.FC = PatchComponent( ); } + async function onPasteClipboard() { + try { + const data = await ImageUtils.readClipboardImage(); + if (data && onImageURL) { + onImageURL(data); + Toast.success( + intl.formatMessage({ id: "toast.clipboard_image_pasted" }) + ); + } else { + Toast.error(intl.formatMessage({ id: "toast.clipboard_no_image" })); + } + } catch (e) { + if (e instanceof DOMException && e.name === "NotAllowedError") { + Toast.error( + intl.formatMessage({ id: "toast.clipboard_access_denied" }) + ); + } else { + Toast.error(e); + } + } + } + function showDialog() { setURL(""); setIsShowDialog(true); @@ -127,6 +152,16 @@ export const ImageInput: React.FC = PatchComponent( {intl.formatMessage({ id: "actions.from_url" })}
+ {window.isSecureContext && ( +
+ +
+ )} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 56b11f3f9..8a0ad96da 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -56,6 +56,7 @@ "export_all": "Export all…", "find": "Find", "finish": "Finish", + "from_clipboard": "From clipboard", "from_file": "From file…", "from_url": "From URL…", "full_export": "Full export", @@ -1636,6 +1637,9 @@ "toast": { "added_entity": "Added {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "added_generation_job_to_queue": "Added generation job to queue", + "clipboard_access_denied": "Clipboard access denied. Check your browser permissions", + "clipboard_image_pasted": "Image pasted from clipboard", + "clipboard_no_image": "No image found in clipboard", "created_entity": "Created {entity}", "default_filter_set": "Default filter set", "delete_past_tense": "Deleted {count, plural, one {{singularEntity}} other {{pluralEntity}}}", diff --git a/ui/v2.5/src/utils/image.tsx b/ui/v2.5/src/utils/image.tsx index b31387e83..73b833f80 100644 --- a/ui/v2.5/src/utils/image.tsx +++ b/ui/v2.5/src/utils/image.tsx @@ -1,25 +1,18 @@ import React, { useCallback, useEffect } from "react"; +const blobToDataURL = (blob: Blob): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + const readImage = (file: File, onLoadEnd: (imageData: string) => void) => { - const reader: FileReader = new FileReader(); - reader.onloadend = () => { - // only proceed if no error encountered - if (!reader.error) { - onLoadEnd(reader.result as string); - } - }; - reader.readAsDataURL(file); -}; - -const pasteImage = ( - event: ClipboardEvent, - onLoadEnd: (imageData: string) => void -) => { - const files = event?.clipboardData?.files; - if (!files?.length) return; - - const file = files[0]; - readImage(file, onLoadEnd); + // only proceed if no error encountered + blobToDataURL(file) + .then(onLoadEnd) + .catch(() => {}); }; const onImageChange = ( @@ -30,6 +23,46 @@ const onImageChange = ( if (file) readImage(file, onLoadEnd); }; +const imageToDataURL = async (url: string) => { + const response = await fetch(url); + const blob = await response.blob(); + return blobToDataURL(blob); +}; + +// uses event.clipboardData which works in all contexts including insecure HTTP +const pasteImage = ( + event: ClipboardEvent, + onLoadEnd: (imageData: string) => void +) => { + const files = event?.clipboardData?.files; + if (!files?.length) return; + + if (document.activeElement instanceof HTMLInputElement) { + // don't interfere with pasting text into inputs + return; + } + + const file = Array.from(files).find((f) => f.type.startsWith("image/")); + if (file) readImage(file, onLoadEnd); +}; + +// uses Clipboard API which requires secure context (HTTPS or localhost) +const readClipboardImage = async (): Promise => { + if (!window.isSecureContext) { + return null; + } + + const items = await navigator.clipboard.read(); + for (const item of items) { + const imageType = item.types.find((t) => t.startsWith("image/")); + if (imageType) { + const blob = await item.getType(imageType); + return blobToDataURL(blob); + } + } + return null; +}; + const usePasteImage = ( onLoadEnd: (imageData: string) => void, isActive: boolean = true @@ -53,23 +86,11 @@ const usePasteImage = ( return false; }; -const imageToDataURL = async (url: string) => { - const response = await fetch(url); - const blob = await response.blob(); - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => { - resolve(reader.result as string); - }; - reader.onerror = reject; - reader.readAsDataURL(blob); - }); -}; - const ImageUtils = { onImageChange, usePasteImage, imageToDataURL, + readClipboardImage, }; export default ImageUtils; From 69e781b0ee3ed210787c2f2f9a1cf4424d3e2da3 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 5 Mar 2026 07:58:51 +1100 Subject: [PATCH 112/177] Use ffmpeg as a general fallback when generating phash (#6641) --- pkg/hash/imagephash/phash.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pkg/hash/imagephash/phash.go b/pkg/hash/imagephash/phash.go index 73e8e3667..0af5adec9 100644 --- a/pkg/hash/imagephash/phash.go +++ b/pkg/hash/imagephash/phash.go @@ -3,10 +3,9 @@ package imagephash import ( "bytes" "context" + "errors" "fmt" "image" - "path/filepath" - "strings" "github.com/corona10/goimagehash" "github.com/stashapp/stash/pkg/ffmpeg" @@ -32,17 +31,9 @@ func Generate(encoder *ffmpeg.FFMpeg, imageFile *models.ImageFile) (*uint64, err } // loadImage loads an image from disk and decodes it. -// For AVIF files, ffmpeg is used to convert to BMP first since Go has no built-in AVIF decoder. +// Where Go has no built-in decoder for a specific format, ffmpeg is used to convert to BMP first. func loadImage(encoder *ffmpeg.FFMpeg, imageFile *models.ImageFile) (image.Image, error) { - ext := strings.ToLower(filepath.Ext(imageFile.Path)) - if ext == ".avif" { - // AVIF in zip files is not supported - ffmpeg cannot read files inside zips - if imageFile.Base().ZipFileID != nil { - return nil, fmt.Errorf("AVIF images in zip files are not supported for phash generation") - } - return loadImageFFmpeg(encoder, imageFile.Path) - } - + // try to load with Go's built-in decoders first for better performance reader, err := imageFile.Open(&file.OsFS{}) if err != nil { return nil, err @@ -55,6 +46,15 @@ func loadImage(encoder *ffmpeg.FFMpeg, imageFile *models.ImageFile) (image.Image } img, _, err := image.Decode(buf) + if errors.Is(err, image.ErrFormat) { + // try ffmpeg as a fallback for unsupported formats + // ffmpeg cannot read files inside zips + if imageFile.Base().ZipFileID != nil { + return nil, fmt.Errorf("ffmpeg fallback unsupported for images in zip files") + } + return loadImageFFmpeg(encoder, imageFile.Path) + } + if err != nil { return nil, fmt.Errorf("decoding image: %w", err) } From 697c66ae627261376a01734a5b4362887904cad0 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 5 Mar 2026 07:59:13 +1100 Subject: [PATCH 113/177] Allow stash path to non-existing directory (#6644) --- internal/api/resolver_mutation_configure.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index 718d24998..3df1c9114 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io/fs" "path/filepath" "regexp" "strconv" @@ -85,6 +86,8 @@ func (r *mutationResolver) setConfigFloat(key string, value *float64) { func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGeneralInput) (*ConfigGeneralResult, error) { c := config.GetInstance() + // #4709 - allow stash paths even if they do not exist, so that users may configure stash + // for disconnected drives or network storage. existingPaths := c.GetStashPaths() if input.Stashes != nil { for _, s := range input.Stashes { @@ -97,8 +100,12 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen } } if isNew { + s.Path = filepath.Clean(s.Path) + + // if it exists, it must be directory exists, err := fsutil.DirExists(s.Path) - if !exists { + // allow it to not exist but if it does exist it must be a directory + if !exists && !errors.Is(err, fs.ErrNotExist) { return makeConfigGeneralResult(), err } } From 717f968a2c544a3f0c0f0be0b30e45cd0da58f10 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 5 Mar 2026 08:02:13 +1100 Subject: [PATCH 114/177] Add folder criteria to scenes, images and galleries and sidebars (#6636) * Add useDebouncedState hook * Add basename to folder sort whitelist * Add parent_folder criterion to gallery * Add selection on enter if single result --- graphql/schema/types/filters.graphql | 2 + pkg/models/gallery.go | 2 + pkg/sqlite/folder.go | 1 + pkg/sqlite/gallery_filter.go | 60 ++ ui/v2.5/graphql/data/file.graphql | 15 + ui/v2.5/graphql/queries/folder.graphql | 24 + .../src/components/Galleries/GalleryList.tsx | 9 + ui/v2.5/src/components/Images/ImageList.tsx | 7 + .../src/components/List/CriterionEditor.tsx | 21 +- .../components/List/Filters/FolderFilter.tsx | 612 ++++++++++++++++++ .../List/Filters/LabeledIdFilter.tsx | 22 +- .../List/Filters/SelectableFilter.tsx | 95 ++- .../List/Filters/SidebarListFilter.tsx | 26 +- ui/v2.5/src/components/List/styles.scss | 43 +- ui/v2.5/src/components/Scenes/SceneList.tsx | 7 + .../src/components/Shared/ClearableInput.tsx | 5 + .../src/components/Shared/CollapseButton.tsx | 14 +- ui/v2.5/src/components/Shared/Sidebar.tsx | 18 +- ui/v2.5/src/core/StashService.ts | 15 + ui/v2.5/src/hooks/debounce.ts | 29 +- ui/v2.5/src/locales/en-GB.json | 5 + .../models/list-filter/criteria/criterion.ts | 1 + .../src/models/list-filter/criteria/folder.ts | 52 ++ ui/v2.5/src/models/list-filter/galleries.ts | 2 + ui/v2.5/src/models/list-filter/images.ts | 2 + ui/v2.5/src/models/list-filter/scenes.ts | 2 + ui/v2.5/src/models/list-filter/types.ts | 4 +- 27 files changed, 1025 insertions(+), 70 deletions(-) create mode 100644 ui/v2.5/graphql/queries/folder.graphql create mode 100644 ui/v2.5/src/components/List/Filters/FolderFilter.tsx create mode 100644 ui/v2.5/src/models/list-filter/criteria/folder.ts diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 6eda473b4..323fb8741 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -603,6 +603,8 @@ input GalleryFilterType { files_filter: FileFilterType "Filter by related folders that meet this criteria" folders_filter: FolderFilterType + "Filter by parent folder of the zip or folder the gallery is in" + parent_folder: HierarchicalMultiCriterionInput custom_fields: [CustomFieldCriterionInput!] } diff --git a/pkg/models/gallery.go b/pkg/models/gallery.go index 8f335020a..3bf70b754 100644 --- a/pkg/models/gallery.go +++ b/pkg/models/gallery.go @@ -11,6 +11,8 @@ type GalleryFilterType struct { Checksum *StringCriterionInput `json:"checksum"` // Filter by path Path *StringCriterionInput `json:"path"` + // Filter by parent folder + ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder,omitempty"` // Filter by zip file count FileCount *IntCriterionInput `json:"file_count"` // Filter to only include galleries missing this property diff --git a/pkg/sqlite/folder.go b/pkg/sqlite/folder.go index fdeb00913..549b40d31 100644 --- a/pkg/sqlite/folder.go +++ b/pkg/sqlite/folder.go @@ -614,6 +614,7 @@ var folderSortOptions = sortOptions{ "created_at", "id", "path", + "basename", "random", "updated_at", } diff --git a/pkg/sqlite/gallery_filter.go b/pkg/sqlite/gallery_filter.go index 069bb1015..28f3b8fac 100644 --- a/pkg/sqlite/gallery_filter.go +++ b/pkg/sqlite/gallery_filter.go @@ -84,6 +84,7 @@ func (qb *galleryFilterHandler) criterionHandler() criterionHandler { }), qb.pathCriterionHandler(filter.Path), + qb.parentFolderCriterionHandler(filter.ParentFolder), qb.fileCountCriterionHandler(filter.FileCount), intCriterionHandler(filter.Rating100, "galleries.rating", nil), qb.urlsCriterionHandler(filter.URL), @@ -278,6 +279,65 @@ func (qb *galleryFilterHandler) pathCriterionHandler(c *models.StringCriterionIn } } +func (qb *galleryFilterHandler) parentFolderCriterionHandler(folder *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if folder == nil { + return + } + + galleryRepository.addFoldersTable(f) + f.addLeftJoin(folderTable, "gallery_folder", "galleries.folder_id = gallery_folder.id") + + criterion := *folder + switch criterion.Modifier { + case models.CriterionModifierEquals: + criterion.Modifier = models.CriterionModifierIncludes + case models.CriterionModifierNotEquals: + criterion.Modifier = models.CriterionModifierExcludes + } + + // only allow includes or excludes filters + if criterion.Modifier != models.CriterionModifierIncludes && criterion.Modifier != models.CriterionModifierExcludes { + f.setError(fmt.Errorf("invalid modifier for parent folder criterion: %s", criterion.Modifier)) + } + + if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { + return + } + + // combine excludes if excludes modifier is selected + if criterion.Modifier == models.CriterionModifierExcludes { + criterion.Modifier = models.CriterionModifierIncludes + criterion.Excludes = append(criterion.Excludes, criterion.Value...) + criterion.Value = nil + } + + if len(criterion.Value) > 0 { + valuesClause, err := getHierarchicalValues(ctx, criterion.Value, "folders", "", "parent_folder_id", "parent_folder_id", criterion.Depth) + if err != nil { + f.setError(err) + return + } + + // combine clauses with OR to handle zip file or folder + c1 := makeClause(fmt.Sprintf("folders.parent_folder_id IN (SELECT column2 FROM (%s))", valuesClause)) + c2 := makeClause(fmt.Sprintf("gallery_folder.parent_folder_id IN (SELECT column2 FROM (%s))", valuesClause)) + f.whereClauses = append(f.whereClauses, orClauses(c1, c2)) + } + + if len(criterion.Excludes) > 0 { + valuesClause, err := getHierarchicalValues(ctx, criterion.Excludes, "folders", "", "parent_folder_id", "parent_folder_id", criterion.Depth) + if err != nil { + f.setError(err) + return + } + + f.addWhere(fmt.Sprintf("folders.parent_folder_id NOT IN (SELECT column2 FROM (%s)) OR folders.parent_folder_id IS NULL", valuesClause)) + f.addWhere(fmt.Sprintf("gallery_folder.parent_folder_id NOT IN (SELECT column2 FROM (%s)) OR gallery_folder.parent_folder_id IS NULL", valuesClause)) + } + } +} + func (qb *galleryFilterHandler) fileCountCriterionHandler(fileCount *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: galleryTable, diff --git a/ui/v2.5/graphql/data/file.graphql b/ui/v2.5/graphql/data/file.graphql index 52a4c50f8..7386adb81 100644 --- a/ui/v2.5/graphql/data/file.graphql +++ b/ui/v2.5/graphql/data/file.graphql @@ -1,5 +1,6 @@ fragment FolderData on Folder { id + basename path } @@ -86,3 +87,17 @@ fragment VisualFileData on VisualFile { } } } + +fragment SelectFolderData on Folder { + id + path + basename +} + +fragment RecursiveFolderData on Folder { + ...SelectFolderData + + parent_folders { + ...SelectFolderData + } +} diff --git a/ui/v2.5/graphql/queries/folder.graphql b/ui/v2.5/graphql/queries/folder.graphql new file mode 100644 index 000000000..a42f5eb84 --- /dev/null +++ b/ui/v2.5/graphql/queries/folder.graphql @@ -0,0 +1,24 @@ +query FindRootFoldersForSelect { + findFolders( + filter: { per_page: -1, sort: "path", direction: ASC } + folder_filter: { parent_folder: { modifier: IS_NULL } } + ) { + count + folders { + ...SelectFolderData + } + } +} + +query FindFoldersForQuery( + $filter: FindFilterType + $folder_filter: FolderFilterType + $ids: [ID!] +) { + findFolders(filter: $filter, folder_filter: $folder_filter, ids: $ids) { + count + folders { + ...RecursiveFolderData + } + } +} diff --git a/ui/v2.5/src/components/Galleries/GalleryList.tsx b/ui/v2.5/src/components/Galleries/GalleryList.tsx index b31b597cc..abb2bdda8 100644 --- a/ui/v2.5/src/components/Galleries/GalleryList.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryList.tsx @@ -51,6 +51,8 @@ import { import { FilterTags } from "../List/FilterTags"; import { SidebarAgeFilter } from "../List/Filters/SidebarAgeFilter"; import { PerformerAgeCriterionOption } from "src/models/list-filter/galleries"; +import { SidebarFolderFilter } from "../List/Filters/FolderFilter"; +import { ParentFolderCriterionOption } from "src/models/list-filter/criteria/folder"; const GalleryList: React.FC<{ galleries: GQL.SlimGalleryDataFragment[]; @@ -165,6 +167,13 @@ const SidebarContent: React.FC<{ filterHook={filterHook} /> + } + criterionOption={ParentFolderCriterionOption} + filter={filter} + setFilter={setFilter} + sectionID="parent_folder" + /> } data-type={OrganizedCriterionOption.type} diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index 50956b497..35c367a8a 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -66,6 +66,7 @@ import { Button } from "react-bootstrap"; import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organized"; import { SidebarAgeFilter } from "../List/Filters/SidebarAgeFilter"; import { PerformerAgeCriterionOption } from "src/models/list-filter/images"; +import { SidebarFolderFilter } from "../List/Filters/FolderFilter"; interface IImageWallProps { images: GQL.SlimImageDataFragment[]; @@ -430,6 +431,12 @@ const SidebarContent: React.FC<{ filterHook={filterHook} /> + } + filter={filter} + setFilter={setFilter} + sectionID="folder" + /> } data-type={OrganizedCriterionOption.type} diff --git a/ui/v2.5/src/components/List/CriterionEditor.tsx b/ui/v2.5/src/components/List/CriterionEditor.tsx index 8795296fa..8a72d6e43 100644 --- a/ui/v2.5/src/components/List/CriterionEditor.tsx +++ b/ui/v2.5/src/components/List/CriterionEditor.tsx @@ -52,6 +52,11 @@ import { PathCriterion } from "src/models/list-filter/criteria/path"; import { ModifierSelectorButtons } from "./ModifierSelect"; import { CustomFieldsCriterion } from "src/models/list-filter/criteria/custom-fields"; import { CustomFieldsFilter } from "./Filters/CustomFieldsFilter"; +import { FolderFilter } from "./Filters/FolderFilter"; +import { + FolderCriterion, + ParentFolderCriterion, +} from "src/models/list-filter/criteria/folder"; interface IGenericCriterionEditor { criterion: ModifierCriterion; @@ -68,7 +73,9 @@ const GenericCriterionEditor: React.FC = ({ if ( criterion instanceof PerformersCriterion || criterion instanceof StudiosCriterion || - criterion instanceof TagsCriterion + criterion instanceof TagsCriterion || + criterion instanceof FolderCriterion || + criterion instanceof ParentFolderCriterion ) { return false; } @@ -163,6 +170,18 @@ const GenericCriterionEditor: React.FC = ({ ); } + if ( + criterion instanceof FolderCriterion || + criterion instanceof ParentFolderCriterion + ) { + return ( + setCriterion(c)} + /> + ); + } + if (criterion instanceof ILabeledIdCriterion) { return ( void; + onSelect: (folder: IFolder, exclude?: boolean) => void; +}> = ({ folder, level, toggleExpanded, onSelect, canExclude }) => { + return ( + <> +
  • + onSelect(folder)} + onKeyDown={keyboardClickHandler(() => onSelect(folder))} + tabIndex={0} + > + + + toggleExpanded(folder)} + collapsedIcon={faChevronRight} + notCollapsedIcon={faChevronDown} + /> + + {folder.basename} + + {canExclude && ( + + )} + +
  • + {folder.expanded && + folder.children?.map((child) => ( + + ))} + + ); +}; + +function toggleExpandedFn(object: IFolder): (f: IFolder) => IFolder { + return (f: IFolder) => { + if (f.id === object.id) { + return { ...f, expanded: !f.expanded }; + } + + if (f.children) { + return { + ...f, + children: f.children.map(toggleExpandedFn(object)), + }; + } + + return f; + }; +} + +function replaceFolder(folder: IFolder): (f: IFolder) => IFolder { + return (f: IFolder) => { + if (f.id === folder.id) { + return folder; + } + + if (f.children) { + return { + ...f, + children: f.children.map(replaceFolder(folder)), + }; + } + + return f; + }; +} + +function useFolderMap(query: string, skip?: boolean) { + const { data: rootFoldersResult } = useFindRootFoldersForSelectQuery({ + skip, + }); + + const { data: queryFoldersResult } = useFindFoldersForQueryQuery({ + skip: !query, + variables: { + filter: { q: query, per_page: 200 }, + }, + }); + + const rootFolders: IFolder[] = useMemo(() => { + const ret = rootFoldersResult?.findFolders.folders ?? []; + return ret.map((f) => ({ ...f, expanded: false, children: undefined })); + }, [rootFoldersResult]); + + const queryFolders: IFolder[] = useMemo(() => { + // construct the folder list from the query result + const ret: IFolder[] = []; + + (queryFoldersResult?.findFolders.folders ?? []).forEach((folder) => { + if (!folder.parent_folders.length) { + // no parents, just add it if not present + if (!ret.find((f) => f.id === folder.id)) { + ret.push({ ...folder, expanded: true, children: [] }); + } + return; + } + + // expand the parent folders + let currentParent: IFolder | undefined; + for (let i = folder.parent_folders.length - 1; i >= 0; i--) { + const thisFolder = folder.parent_folders[i]; + let existing: IFolder | undefined; + + if (i === folder.parent_folders.length - 1) { + // last parent, add the folder as root + existing = ret.find((f) => f.id === thisFolder.id); + if (!existing) { + existing = { + ...folder.parent_folders[i], + expanded: true, + children: [], + }; + ret.push(existing); + } + currentParent = existing; + continue; + } + + // find folder in current parent's children + // currentParent is guaranteed to be defined here + existing = currentParent!.children?.find((f) => f.id === thisFolder.id); + if (!existing) { + // add to current parent's children + existing = { + ...thisFolder, + expanded: true, + children: [], + }; + currentParent!.children!.push(existing); + } + currentParent = existing; + } + + if (!currentParent) { + return; + } + + if (!currentParent.children) { + currentParent.children = []; + } + + // currentParent is now the immediate parent folder + currentParent!.children!.push({ + ...folder, + expanded: false, + children: undefined, + }); + }); + return ret; + }, [queryFoldersResult]); + + const [folderMap, setFolderMap] = React.useState([]); + + useEffect(() => { + if (!query) { + setFolderMap(rootFolders); + } else { + setFolderMap(queryFolders); + } + }, [query, rootFolders, queryFolders]); + + async function onToggleExpanded(folder: IFolder) { + setFolderMap(folderMap.map(toggleExpandedFn(folder))); + + // query children folders if not already loaded + if (folder.children === undefined) { + const subFolderResult = await queryFindSubFolders(folder.id); + setFolderMap((current) => + current.map( + replaceFolder({ + ...folder, + expanded: true, + children: subFolderResult.data.findFolders.folders.map((f) => ({ + ...f, + expanded: false, + })), + }) + ) + ); + } + } + + return { folderMap, onToggleExpanded }; +} + +function getMatchingFolders(folders: IFolder[], query: string): IFolder[] { + let matches: IFolder[] = []; + + const queryLower = query.toLowerCase(); + + folders.forEach((folder) => { + if ( + folder.basename.toLowerCase().includes(queryLower) || + folder.path.toLowerCase() === queryLower + ) { + matches.push(folder); + } + + if (folder.children) { + matches = matches.concat(getMatchingFolders(folder.children, query)); + } + }); + + return matches; +} + +export const FolderSelector: React.FC<{ + onSelect: (folder: IFolder, exclude?: boolean) => void; + canExclude?: boolean; + preListContent?: React.ReactNode; + folderMap: IFolder[]; + onToggleExpanded: (folder: IFolder) => void; +}> = ({ + onSelect, + preListContent, + canExclude = false, + folderMap, + onToggleExpanded, +}) => { + return ( +
      + {preListContent} + {folderMap.map((folder) => ( + onSelect(f, exclude)} + toggleExpanded={onToggleExpanded} + canExclude={canExclude} + /> + ))} +
    + ); +}; + +interface IInputFilterProps { + criterion: FolderCriterion; + setCriterion: (c: FolderCriterion) => void; +} + +export const FolderFilter: React.FC = ({ + criterion, + setCriterion, +}) => { + const intl = useIntl(); + const [query, setQuery] = useState(""); + const [displayQuery, onQueryChange] = useDebouncedState(query, setQuery, 250); + + const { folderMap, onToggleExpanded } = useFolderMap(query); + + const messages = defineMessages({ + sub_folder_depth: { + id: "sub_folder_depth", + defaultMessage: "Levels (empty for all)", + }, + }); + + function criterionOptionTypeToIncludeID(): string { + return "include-sub-folders"; + } + + function criterionOptionTypeToIncludeUIString(): MessageDescriptor { + const optionType = "include_sub_folders"; + + return { + id: optionType, + }; + } + + function onDepthChanged(depth: number) { + // this could be ParentFolderCriterion, but the types are the same + const newValue = criterion.clone() as FolderCriterion; + newValue.value.depth = depth; + setCriterion(newValue); + } + + function onSelect(folder: IFolder, exclude: boolean = false) { + // toggle selection + const newValue = criterion.clone() as FolderCriterion; + + if (!exclude) { + if (newValue.value.items.find((i) => i.id === folder.id)) { + return; + } + + newValue.value.items.push({ id: folder.id, label: folder.path }); + } else { + if (newValue.value.excluded.find((i) => i.id === folder.id)) { + return; + } + + newValue.value.excluded.push({ id: folder.id, label: folder.path }); + } + + setCriterion(newValue); + } + + const onUnselect = useCallback( + (i: Option, excluded?: boolean) => { + const newValue = criterion.clone() as FolderCriterion; + + if (!excluded) { + newValue.value.items = newValue.value.items.filter( + (item) => item.id !== i.id + ); + } else { + newValue.value.excluded = newValue.value.excluded.filter( + (item) => item.id !== i.id + ); + } + setCriterion(newValue); + }, + [criterion, setCriterion] + ); + + function onEnter() { + if (!query) return; + + // if there is a single folder that matches the query, select it + const matchingFolders = getMatchingFolders(folderMap, query); + if (matchingFolders.length === 1) { + onSelect(matchingFolders[0]); + } + } + + const selectedList = useMemo(() => { + const selected: Option[] = + criterion.value?.items.map((item) => ({ + id: item.id, + label: item.label, + })) ?? []; + + return ; + }, [criterion, onUnselect]); + + const excludedList = useMemo(() => { + const selected: Option[] = + criterion.value?.excluded.map((item) => ({ + id: item.id, + label: item.label, + })) ?? []; + + return ( + onUnselect(i, true)} + /> + ); + }, [criterion, onUnselect]); + + return ( +
    + + + + {selectedList} + {excludedList} + onQueryChange(v)} + placeholder={`${intl.formatMessage({ id: "actions.search" })}…`} + onEnter={onEnter} + /> + + +
    + ); +}; + +export const SidebarFolderFilter: React.FC< + ISidebarSectionProps & { + filter: ListFilterModel; + setFilter: (f: ListFilterModel) => void; + criterionOption?: ModifierCriterionOption; + } +> = (props) => { + const intl = useIntl(); + const [skip, setSkip] = useState(true); + const [query, setQuery] = useState(""); + const [displayQuery, onQueryChange] = useDebouncedState(query, setQuery, 250); + + function onOpen() { + setSkip(false); + props.onOpen?.(); + } + + const { folderMap, onToggleExpanded } = useFolderMap(query, skip); + + const option = props.criterionOption ?? FolderCriterionOption; + const { filter, setFilter } = props; + + const criterion = useMemo(() => { + const ret = filter.criteria.find( + (c) => c.criterionOption.type === option.type + ); + if (ret) return ret as FolderCriterion; + + const newCriterion = filter.makeCriterion(option.type) as FolderCriterion; + return newCriterion; + }, [option.type, filter]); + + // if there are multiple values or excluded values, then we show none of the + // current values + const multipleSelected = + criterion.value.items.length > 1 || criterion.value.excluded.length > 0; + + function onSelect(folder: IFolder) { + const c = criterion.clone() as FolderCriterion; + c.value = { + items: [{ id: folder.id, label: folder.path }], + depth: 0, + excluded: [], + }; + + const newCriteria = props.filter.criteria.filter( + (cc) => cc.criterionOption.type !== option.type + ); + + if (c.isValid()) newCriteria.push(c); + + setFilter(props.filter.setCriteria(newCriteria)); + } + + function onSelectSubfolders() { + const c = criterion.clone() as FolderCriterion; + c.value = { + items: c.value?.items ?? [], + depth: -1, + excluded: c.value?.excluded ?? [], + }; + + setFilter(props.filter.replaceCriteria(option.type, [c])); + } + + const onUnselect = useCallback( + (i: Option) => { + if (i.className === "modifier-object") { + // subfolders option + const c = criterion.clone() as FolderCriterion; + c.value = { + items: c.value?.items ?? [], + depth: 0, + excluded: c.value?.excluded ?? [], + }; + + setFilter(props.filter.replaceCriteria(option.type, [c])); + return; + } + + setFilter(props.filter.removeCriterion(option.type)); + }, + [props.filter, setFilter, option.type, criterion] + ); + + function onEnter() { + if (!query) return; + + // if there is a single folder that matches the query, select it + const matchingFolders = getMatchingFolders(folderMap, query); + if (matchingFolders.length === 1) { + onSelect(matchingFolders[0]); + } + } + + const subDirsSelected = criterion.value?.depth === -1; + + const selectedList = useMemo(() => { + if (multipleSelected) { + return null; + } + + const selected: Option[] = + criterion.value?.items.map((item) => ({ + id: item.id, + label: item.label, + })) ?? []; + + if (subDirsSelected) { + selected.push({ + id: "subfolders", + label: "(" + intl.formatMessage({ id: "sub_folders" }) + ")", + className: "modifier-object", + }); + } + + return ; + }, [intl, multipleSelected, subDirsSelected, criterion, onUnselect]); + + const modifierItem = criterion.value.items.length > 0 && + !multipleSelected && + !subDirsSelected && ( +
  • + + + + () + + +
  • + ); + + return ( + + onQueryChange(v)} + placeholder={`${intl.formatMessage({ id: "actions.search" })}…`} + onEnter={onEnter} + /> + + onSelect(f)} + /> + + ); +}; diff --git a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx index e006d6b50..355a85d67 100644 --- a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx @@ -391,10 +391,17 @@ export function useCandidates(props: { const defaultModifier = getDefaultModifier(singleValue); const candidates = useMemo(() => { + return (results ?? []).map((r) => ({ + id: r.id, + label: r.label, + })); + }, [results]); + + const modifierCandidates = useMemo(() => { const hierarchicalCandidate = hierarchical && (criterion.value as IHierarchicalLabelValue).depth !== -1; - const modifierCandidates: Option[] = getModifierCandidates({ + return getModifierCandidates({ modifier, defaultModifier, hasSelected: selected.length > 0, @@ -416,19 +423,11 @@ export function useCandidates(props: { canExclude: false, }; }); - - return modifierCandidates.concat( - (results ?? []).map((r) => ({ - id: r.id, - label: r.label, - })) - ); }, [ defaultModifier, intl, modifier, singleValue, - results, selected, excluded, criterion.value, @@ -436,7 +435,7 @@ export function useCandidates(props: { includeSubMessageID, ]); - return candidates; + return { candidates, modifierCandidates }; } export function useLabeledIdFilterState(props: { @@ -481,7 +480,7 @@ export function useLabeledIdFilterState(props: { includeSubMessageID, }); - const candidates = useCandidates({ + const { candidates, modifierCandidates } = useCandidates({ criterion, queryResults, selected, @@ -497,6 +496,7 @@ export function useLabeledIdFilterState(props: { return { candidates, + modifierCandidates, onSelect, onUnselect, selected, diff --git a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx index 9ea4333da..e599f3a87 100644 --- a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx @@ -19,7 +19,12 @@ import { ModifierCriterion, IHierarchicalLabeledIdCriterion, } from "src/models/list-filter/criteria/criterion"; -import { defineMessages, MessageDescriptor, useIntl } from "react-intl"; +import { + defineMessages, + FormattedMessage, + MessageDescriptor, + useIntl, +} from "react-intl"; import { CriterionModifier } from "src/core/generated-graphql"; import { keyboardClickHandler } from "src/utils/keyboard"; import { useDebounce } from "src/hooks/debounce"; @@ -118,7 +123,9 @@ const UnselectedItem: React.FC<{ onKeyDown={(e) => e.stopPropagation()} className="minimal exclude-button" > - exclude + + + {excludeIcon} )} @@ -240,12 +247,19 @@ const SelectableFilter: React.FC = ({ onSetModifier(defaultModifier); } + function onEnter() { + if (objects.length === 1) { + onSelect(objects[0], false); + } + } + return (
    onQueryChange(v)} + onEnter={onEnter} placeholder={`${intl.formatMessage({ id: "actions.search" })}…`} />
      @@ -450,6 +464,42 @@ export const ObjectsFilter = < ); }; +export const DepthSelector: React.FC<{ + depth: number | undefined; + onDepthChanged: (depth: number) => void; + id: string; + label?: React.ReactNode; + placeholder?: string; + disabled?: boolean; +}> = ({ depth, onDepthChanged, id, label, disabled, placeholder }) => { + return ( + + + onDepthChanged(depth !== 0 ? 0 : -1)} + disabled={disabled} + /> + + {depth !== 0 && ( + + + onDepthChanged(e.target.value ? parseInt(e.target.value, 10) : -1) + } + defaultValue={depth !== -1 ? depth : ""} + min="1" + /> + + )} + + ); +}; + interface IHierarchicalObjectsFilter extends IObjectsFilter {} @@ -497,38 +547,15 @@ export const HierarchicalObjectsFilter = < } return ( -
      - - onDepthChanged(criterion.value.depth !== 0 ? 0 : -1)} - disabled={criterion.modifier === CriterionModifier.Equals} - /> - - - {criterion.value.depth !== 0 && ( - - - onDepthChanged(e.target.value ? parseInt(e.target.value, 10) : -1) - } - defaultValue={ - criterion.value && criterion.value.depth !== -1 - ? criterion.value.depth - : "" - } - min="1" - /> - - )} +
      + - +
      ); }; diff --git a/ui/v2.5/src/components/List/Filters/SidebarListFilter.tsx b/ui/v2.5/src/components/List/Filters/SidebarListFilter.tsx index fe9b7987c..14e11e968 100644 --- a/ui/v2.5/src/components/List/Filters/SidebarListFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/SidebarListFilter.tsx @@ -182,7 +182,8 @@ const QueryField: React.FC<{ focus: ReturnType; value: string; setValue: (query: string) => void; -}> = ({ focus, value, setValue }) => { + onEnter?: () => void; +}> = ({ focus, value, setValue, onEnter }) => { const intl = useIntl(); const [displayQuery, setDisplayQuery] = useState(value); @@ -206,6 +207,7 @@ const QueryField: React.FC<{ value={displayQuery} setValue={(v) => onQueryChange(v)} placeholder={`${intl.formatMessage({ id: "actions.search" })}…`} + onEnter={onEnter} /> ); }; @@ -214,6 +216,7 @@ interface IQueryableProps { inputFocus?: ReturnType; query?: string; setQuery?: (query: string) => void; + onEnter?: () => void; } export const CandidateList: React.FC< @@ -227,6 +230,7 @@ export const CandidateList: React.FC< inputFocus, query, setQuery, + onEnter, items, onSelect, canExclude, @@ -242,6 +246,7 @@ export const CandidateList: React.FC< focus={inputFocus} value={query} setValue={(v) => setQuery(v)} + onEnter={onEnter} /> )}
        @@ -265,6 +270,7 @@ export const SidebarListFilter: React.FC<{ selected: Option[]; excluded?: Option[]; candidates: Option[]; + modifierCandidates?: Option[]; singleValue?: boolean; onSelect: (item: Option, exclude: boolean) => void; onUnselect: (item: Option, exclude: boolean) => void; @@ -283,6 +289,7 @@ export const SidebarListFilter: React.FC<{ selected, excluded, candidates, + modifierCandidates, onSelect, onUnselect, canExclude, @@ -324,6 +331,20 @@ export const SidebarListFilter: React.FC<{ } } + function onEnter() { + if (candidates && candidates.length === 1) { + selectHook(candidates[0], false); + } + } + + const items = useMemo(() => { + if (!modifierCandidates) { + return candidates; + } + + return [...modifierCandidates, ...candidates]; + }, [candidates, modifierCandidates]); + return ( {preCandidates ?
        {preCandidates}
        : null} {postCandidates ?
        {postCandidates}
        : null}
        diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index ede004101..cccd73cb2 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -234,14 +234,14 @@ input[type="range"].zoom-slider { .saved-filter-item { cursor: pointer; - height: 2em; margin-bottom: 0.25rem; + min-height: 2em; a { align-items: center; display: flex; - height: 2em; justify-content: space-between; + min-height: 2em; outline: none; &:hover, @@ -507,7 +507,8 @@ input[type="range"].zoom-slider { } } -.selectable-filter ul { +.selectable-filter ul, +ul.selectable-list { list-style-type: none; margin-top: 0.5rem; max-height: 300px; @@ -533,14 +534,14 @@ input[type="range"].zoom-slider { .excluded-object, .unselected-object { cursor: pointer; - height: 2em; margin-bottom: 0.25rem; + min-height: 2em; a { align-items: center; display: flex; - height: 2em; justify-content: space-between; + min-height: 2em; outline: none; &:hover, @@ -613,7 +614,8 @@ input[type="range"].zoom-slider { margin-bottom: 0.5rem; } -.sidebar-list-filter ul { +.sidebar-list-filter ul, +.folder-filter ul { list-style-type: none; margin-bottom: 0.25rem; max-height: 300px; @@ -639,14 +641,14 @@ input[type="range"].zoom-slider { .excluded-object, .unselected-object { cursor: pointer; - height: 2em; margin-bottom: 0.25rem; + min-height: 2em; a { align-items: center; display: flex; - height: 2em; justify-content: space-between; + min-height: 2em; outline: none; &:hover, @@ -687,7 +689,7 @@ input[type="range"].zoom-slider { } &:hover { - background-color: inherit; + background-color: transparent; } &:hover .exclude-button-text, @@ -748,6 +750,29 @@ input[type="range"].zoom-slider { } } +.sidebar-folder-filter ul, +.folder-filter ul, +ul.selectable-list { + margin-top: 0.25rem; + + .btn.expand-collapse { + font-size: 0.8rem; + padding-left: 0; + padding-right: 0.25rem; + text-align: left; + } + + .empty .btn.expand-collapse { + visibility: hidden; + } + + .selected-object a .selected-object-label { + font-size: 0.8em; + overflow-wrap: break-word; + white-space: normal; + } +} + .tilted { transform: rotate(45deg); } diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 6570e39db..156258045 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -58,6 +58,7 @@ import useFocus from "src/utils/focus"; import { useZoomKeybinds } from "../List/ZoomSlider"; import { FilteredListToolbar } from "../List/FilteredListToolbar"; import { FilterTags } from "../List/FilterTags"; +import { SidebarFolderFilter } from "../List/Filters/FolderFilter"; function renderMetadataByline(result: GQL.FindScenesQueryResult) { const duration = result?.data?.findScenes?.duration; @@ -305,6 +306,12 @@ const SidebarContent: React.FC<{ /> + } + filter={filter} + setFilter={setFilter} + sectionID="folder" + /> } data-type={HasMarkersCriterionOption.type} diff --git a/ui/v2.5/src/components/Shared/ClearableInput.tsx b/ui/v2.5/src/components/Shared/ClearableInput.tsx index 76c6db54a..56f17a7f9 100644 --- a/ui/v2.5/src/components/Shared/ClearableInput.tsx +++ b/ui/v2.5/src/components/Shared/ClearableInput.tsx @@ -10,6 +10,7 @@ interface IClearableInput { className?: string; value: string; setValue: (value: string) => void; + onEnter?: () => void; focus?: ReturnType; placeholder?: string; } @@ -18,6 +19,7 @@ export const ClearableInput: React.FC = ({ className, value, setValue, + onEnter, focus, placeholder, }) => { @@ -43,6 +45,9 @@ export const ClearableInput: React.FC = ({ if (e.key === "Escape") { queryRef.current?.blur(); } + if (e.key === "Enter" && onEnter) { + onEnter(); + } } return ( diff --git a/ui/v2.5/src/components/Shared/CollapseButton.tsx b/ui/v2.5/src/components/Shared/CollapseButton.tsx index 0d05f6e64..fe8330a9c 100644 --- a/ui/v2.5/src/components/Shared/CollapseButton.tsx +++ b/ui/v2.5/src/components/Shared/CollapseButton.tsx @@ -2,6 +2,7 @@ import { faChevronDown, faChevronRight, faChevronUp, + IconDefinition, } from "@fortawesome/free-solid-svg-icons"; import React, { useEffect, useState } from "react"; import { Button, Collapse, CollapseProps } from "react-bootstrap"; @@ -55,14 +56,21 @@ export const CollapseButton: React.FC> = ( export const ExpandCollapseButton: React.FC<{ collapsed: boolean; setCollapsed: (collapsed: boolean) => void; -}> = ({ collapsed, setCollapsed }) => { - const buttonIcon = collapsed ? faChevronDown : faChevronUp; + collapsedIcon?: IconDefinition; + notCollapsedIcon?: IconDefinition; +}> = ({ collapsedIcon, notCollapsedIcon, collapsed, setCollapsed }) => { + const buttonIcon = collapsed + ? collapsedIcon ?? faChevronDown + : notCollapsedIcon ?? faChevronUp; return ( diff --git a/ui/v2.5/src/components/Shared/Sidebar.tsx b/ui/v2.5/src/components/Shared/Sidebar.tsx index 10dbaaaba..51fddee33 100644 --- a/ui/v2.5/src/components/Shared/Sidebar.tsx +++ b/ui/v2.5/src/components/Shared/Sidebar.tsx @@ -97,15 +97,17 @@ interface IContext { export const SidebarStateContext = React.createContext(null); +export interface ISidebarSectionProps { + text: React.ReactNode; + className?: string; + outsideCollapse?: React.ReactNode; + onOpen?: () => void; + // used to store open/closed state in SidebarStateContext + sectionID?: string; +} + export const SidebarSection: React.FC< - PropsWithChildren<{ - text: React.ReactNode; - className?: string; - outsideCollapse?: React.ReactNode; - onOpen?: () => void; - // used to store open/closed state in SidebarStateContext - sectionID?: string; - }> + PropsWithChildren > = ({ className = "", text, diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 535beed65..ac23d59e0 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -515,6 +515,21 @@ export const useFindSavedFilters = (mode?: GQL.FilterMode) => variables: { mode }, }); +export const queryFindSubFolders = (id: string) => + client.query({ + query: GQL.FindFoldersForQueryDocument, + variables: { + folder_filter: { + parent_folder: { value: id, modifier: GQL.CriterionModifier.Equals }, + }, + filter: { + per_page: -1, + sort: "basename", + direction: GQL.SortDirectionEnum.Asc, + }, + }, + }); + /// Object Mutations // Increases/decreases the given field of the Stats query by diff diff --git a/ui/v2.5/src/hooks/debounce.ts b/ui/v2.5/src/hooks/debounce.ts index 9baf3d1d4..bc4f63ef1 100644 --- a/ui/v2.5/src/hooks/debounce.ts +++ b/ui/v2.5/src/hooks/debounce.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable react-hooks/exhaustive-deps */ import { debounce, DebouncedFunc, DebounceSettings } from "lodash-es"; -import { useCallback, useRef } from "react"; +import { useCallback, useRef, useState } from "react"; export function useDebounce any>( fn: T, @@ -21,3 +21,30 @@ export function useDebounce any>( [wait, options?.leading, options?.trailing, options?.maxWait] ); } + +export function useDebouncedState( + initialValue: T, + setValue: (v: T) => void, + wait?: number +): [T, (v: T) => void, (v: T) => void] { + const [displayedState, setDisplayedState] = useState(initialValue); + + const debouncedSetValue = useDebounce(setValue, wait); + const onChange = useCallback( + (input: T) => { + setDisplayedState(input); + debouncedSetValue(input); + }, + [debouncedSetValue, setDisplayedState] + ); + + const setInstant = useCallback( + (v: T) => { + setDisplayedState(v); + setValue(v); + }, + [setValue] + ); + + return [displayedState, onChange, setInstant]; +} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 8a0ad96da..7b4091f8b 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -52,6 +52,7 @@ "edit_entity": "Edit {entityType}", "enable": "Enable", "encoding_image": "Encoding image…", + "exclude_lowercase": "exclude", "export": "Export", "export_all": "Export all…", "find": "Find", @@ -1225,6 +1226,7 @@ "image_index": "Image #", "images": "Images", "include_parent_tags": "Include parent tags", + "include_sub_folders": "Include sub-folders", "include_sub_group_content": "Include sub-group content", "include_sub_groups": "Include sub-groups", "include_sub_studio_content": "Include sub-studio content", @@ -1327,6 +1329,7 @@ "next": "Next", "previous": "Previous" }, + "parent_folder": "Parent Folder", "parent_of": "Parent of {children}", "parent_studio": "Parent Studio", "parent_studios": "Parent Studios", @@ -1578,6 +1581,8 @@ }, "studio_tags": "Studio Tags", "studios": "Studios", + "sub_folder_depth": "Sub folder depth (empty for all)", + "sub_folders": "Sub folders", "sub_group": "Sub-Group", "sub_group_count": "Sub-Group Count", "sub_group_of": "Sub-group of {parent}", diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index 007ee6508..37f04e6dc 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -251,6 +251,7 @@ export type InputType = | "scene_tags" | "groups" | "galleries" + | "folders" | undefined; type MakeCriterionFn = ( diff --git a/ui/v2.5/src/models/list-filter/criteria/folder.ts b/ui/v2.5/src/models/list-filter/criteria/folder.ts new file mode 100644 index 000000000..2e288a5f1 --- /dev/null +++ b/ui/v2.5/src/models/list-filter/criteria/folder.ts @@ -0,0 +1,52 @@ +import { CriterionModifier } from "src/core/generated-graphql"; +import { + ModifierCriterionOption, + IHierarchicalLabeledIdCriterion, +} from "./criterion"; + +const modifierOptions = [CriterionModifier.Includes]; + +const defaultModifier = CriterionModifier.Includes; +const inputType = "folders"; + +export const FolderCriterionOption = new ModifierCriterionOption({ + messageID: "folder", + type: "folder", + modifierOptions, + defaultModifier, + inputType, + makeCriterion: () => new FolderCriterion(), +}); + +// for galleries, we should use parent folder to distinguish between gallery folder +// and parent folder of the gallery folder +export const ParentFolderCriterionOption = new ModifierCriterionOption({ + messageID: "parent_folder", + type: "parent_folder", + modifierOptions, + defaultModifier, + inputType, + makeCriterion: () => new ParentFolderCriterion(), +}); + +export class FolderCriterion extends IHierarchicalLabeledIdCriterion { + constructor() { + super(FolderCriterionOption); + } + + public applyToCriterionInput(input: Record) { + input.files_filter = { + parent_folder: this.toCriterionInput(), + }; + } +} + +export class ParentFolderCriterion extends IHierarchicalLabeledIdCriterion { + constructor() { + super(ParentFolderCriterionOption); + } + + public applyToCriterionInput(input: Record) { + input.parent_folder = this.toCriterionInput(); + } +} diff --git a/ui/v2.5/src/models/list-filter/galleries.ts b/ui/v2.5/src/models/list-filter/galleries.ts index e8549701f..ed0b2c155 100644 --- a/ui/v2.5/src/models/list-filter/galleries.ts +++ b/ui/v2.5/src/models/list-filter/galleries.ts @@ -22,6 +22,7 @@ import { DisplayMode } from "./types"; import { RatingCriterionOption } from "./criteria/rating"; import { PathCriterionOption } from "./criteria/path"; import { CustomFieldsCriterionOption } from "./criteria/custom-fields"; +import { ParentFolderCriterionOption } from "./criteria/folder"; const defaultSortBy = "path"; @@ -53,6 +54,7 @@ const criterionOptions = [ createStringCriterionOption("details"), createStringCriterionOption("photographer"), PathCriterionOption, + ParentFolderCriterionOption, createStringCriterionOption("checksum", "media_info.md5"), RatingCriterionOption, OrganizedCriterionOption, diff --git a/ui/v2.5/src/models/list-filter/images.ts b/ui/v2.5/src/models/list-filter/images.ts index aa6ad81ca..82b382677 100644 --- a/ui/v2.5/src/models/list-filter/images.ts +++ b/ui/v2.5/src/models/list-filter/images.ts @@ -23,6 +23,7 @@ import { DisplayMode } from "./types"; import { GalleriesCriterionOption } from "./criteria/galleries"; import { PhashCriterionOption } from "./criteria/phash"; import { CustomFieldsCriterionOption } from "./criteria/custom-fields"; +import { FolderCriterionOption } from "./criteria/folder"; const defaultSortBy = "path"; @@ -54,6 +55,7 @@ const criterionOptions = [ createMandatoryStringCriterionOption("checksum", "media_info.md5"), PhashCriterionOption, PathCriterionOption, + FolderCriterionOption, GalleriesCriterionOption, OrganizedCriterionOption, createMandatoryNumberCriterionOption("o_counter", "o_count", { diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index c0e4a75a1..ef7c62802 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -36,6 +36,7 @@ import { RatingCriterionOption } from "./criteria/rating"; import { PathCriterionOption } from "./criteria/path"; import { OrientationCriterionOption } from "./criteria/orientation"; import { CustomFieldsCriterionOption } from "./criteria/custom-fields"; +import { FolderCriterionOption } from "./criteria/folder"; const defaultSortBy = "date"; const sortByOptions = [ @@ -96,6 +97,7 @@ const criterionOptions = [ createStringCriterionOption("title"), createStringCriterionOption("code", "scene_code"), PathCriterionOption, + FolderCriterionOption, createStringCriterionOption("details"), createStringCriterionOption("director"), createMandatoryStringCriterionOption("oshash", "media_info.oshash"), diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 7fe334c4c..d5ae684fc 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -223,4 +223,6 @@ export type CriterionType = | "disambiguation" | "has_chapters" | "sort_name" - | "custom_fields"; + | "custom_fields" + | "folder" + | "parent_folder"; From 74a8f2e5d59ad70f2e7cd676fdf02537f03d6457 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 6 Mar 2026 08:27:25 +1100 Subject: [PATCH 115/177] Disable links on wall items when selecting (#6649) --- ui/v2.5/src/components/Galleries/GalleryWallCard.tsx | 8 +++++++- ui/v2.5/src/components/Scenes/SceneWallPanel.tsx | 11 ++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx index c1501bd9d..c79000783 100644 --- a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx @@ -132,7 +132,13 @@ const GalleryWallCard: React.FC = ({
        e.stopPropagation()} + onClick={(e) => { + if (selecting) { + e.preventDefault(); + handleCardClick(e); + } + e.stopPropagation(); + }} > {title && (
        - e.stopPropagation()}> + { + if (props.selecting) { + e.preventDefault(); + handleClick(e); + } + e.stopPropagation(); + }} + > {title && ( Date: Mon, 9 Mar 2026 17:01:46 -0400 Subject: [PATCH 116/177] Use StashIDPill in the performer modal dialog (#6655) Currently, this dialog just shows a text "Stash-Box Source". This change instead re-uses the StashIDPill, with the main advantage that you can immediately tell which stash box is being used. --- ui/v2.5/src/components/Tagger/PerformerModal.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/PerformerModal.tsx b/ui/v2.5/src/components/Tagger/PerformerModal.tsx index ac9444c5b..9b2434165 100755 --- a/ui/v2.5/src/components/Tagger/PerformerModal.tsx +++ b/ui/v2.5/src/components/Tagger/PerformerModal.tsx @@ -15,10 +15,10 @@ import { faArrowLeft, faArrowRight, faCheck, - faExternalLinkAlt, faTimes, } from "@fortawesome/free-solid-svg-icons"; import { ExternalLink } from "../Shared/ExternalLink"; +import { StashIDPill } from "../Shared/StashID"; interface IPerformerModalProps { performer: GQL.ScrapedScenePerformerDataFragment; @@ -208,15 +208,13 @@ const PerformerModal: React.FC = ({ function maybeRenderStashBoxLink() { const base = endpoint?.match(/https?:\/\/.*?\//)?.[0]; - if (!base) return; + if (!base || !performer.remote_site_id) return; return ( -
        - - - - -
        + ); } From ae5d065da1980305b2b56a31fb55d5159f414df5 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Mon, 9 Mar 2026 19:50:57 -0700 Subject: [PATCH 117/177] Fix infinite re-render loop in gallery image list (#6651) --- .../GalleryDetails/GalleryAddPanel.tsx | 63 ++++++++++--------- .../GalleryDetails/GalleryImagesPanel.tsx | 63 ++++++++++--------- ui/v2.5/src/components/Images/ImageList.tsx | 4 +- ui/v2.5/src/components/List/util.ts | 10 +-- 4 files changed, 73 insertions(+), 67 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx index 6fbb12f15..e0c115f34 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useCallback } from "react"; import * as GQL from "src/core/generated-graphql"; import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries"; import { ListFilterModel } from "src/models/list-filter/filter"; @@ -24,40 +24,43 @@ export const GalleryAddPanel: React.FC = PatchComponent( const Toast = useToast(); const intl = useIntl(); - function filterHook(filter: ListFilterModel) { - const galleryValue = { - id: gallery.id, - label: galleryTitle(gallery), - }; - // if galleries is already present, then we modify it, otherwise add - let galleryCriterion = filter.criteria.find((c) => { - return c.criterionOption.type === "galleries"; - }) as GalleriesCriterion | undefined; + const filterHook = useCallback( + (filter: ListFilterModel) => { + const galleryValue = { + id: gallery.id, + label: galleryTitle(gallery), + }; + // if galleries is already present, then we modify it, otherwise add + let galleryCriterion = filter.criteria.find((c) => { + return c.criterionOption.type === "galleries"; + }) as GalleriesCriterion | undefined; - if ( - galleryCriterion && - galleryCriterion.modifier === GQL.CriterionModifier.Excludes - ) { - // add the gallery if not present if ( - !galleryCriterion.value.find((p) => { - return p.id === gallery.id; - }) + galleryCriterion && + galleryCriterion.modifier === GQL.CriterionModifier.Excludes ) { - galleryCriterion.value.push(galleryValue); + // add the gallery if not present + if ( + !galleryCriterion.value.find((p) => { + return p.id === gallery.id; + }) + ) { + galleryCriterion.value.push(galleryValue); + } + + galleryCriterion.modifier = GQL.CriterionModifier.Excludes; + } else { + // overwrite + galleryCriterion = new GalleriesCriterion(); + galleryCriterion.modifier = GQL.CriterionModifier.Excludes; + galleryCriterion.value = [galleryValue]; + filter.criteria.push(galleryCriterion); } - galleryCriterion.modifier = GQL.CriterionModifier.Excludes; - } else { - // overwrite - galleryCriterion = new GalleriesCriterion(); - galleryCriterion.modifier = GQL.CriterionModifier.Excludes; - galleryCriterion.value = [galleryValue]; - filter.criteria.push(galleryCriterion); - } - - return filter; - } + return filter; + }, + [gallery] + ); async function addImages( result: GQL.FindImagesQueryResult, diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx index 174e507a8..c555116b5 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useCallback } from "react"; import * as GQL from "src/core/generated-graphql"; import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries"; import { ListFilterModel } from "src/models/list-filter/filter"; @@ -32,40 +32,43 @@ export const GalleryImagesPanel: React.FC = const intl = useIntl(); const Toast = useToast(); - function filterHook(filter: ListFilterModel) { - const galleryValue = { - id: gallery.id!, - label: galleryTitle(gallery), - }; - // if galleries is already present, then we modify it, otherwise add - let galleryCriterion = filter.criteria.find((c) => { - return c.criterionOption.type === "galleries"; - }) as GalleriesCriterion | undefined; + const filterHook = useCallback( + (filter: ListFilterModel) => { + const galleryValue = { + id: gallery.id!, + label: galleryTitle(gallery), + }; + // if galleries is already present, then we modify it, otherwise add + let galleryCriterion = filter.criteria.find((c) => { + return c.criterionOption.type === "galleries"; + }) as GalleriesCriterion | undefined; - if ( - galleryCriterion && - (galleryCriterion.modifier === GQL.CriterionModifier.IncludesAll || - galleryCriterion.modifier === GQL.CriterionModifier.Includes) - ) { - // add the gallery if not present if ( - !galleryCriterion.value.find((p) => { - return p.id === gallery.id; - }) + galleryCriterion && + (galleryCriterion.modifier === GQL.CriterionModifier.IncludesAll || + galleryCriterion.modifier === GQL.CriterionModifier.Includes) ) { - galleryCriterion.value.push(galleryValue); + // add the gallery if not present + if ( + !galleryCriterion.value.find((p) => { + return p.id === gallery.id; + }) + ) { + galleryCriterion.value.push(galleryValue); + } + + galleryCriterion.modifier = GQL.CriterionModifier.IncludesAll; + } else { + // overwrite + galleryCriterion = new GalleriesCriterion(); + galleryCriterion.value = [galleryValue]; + filter.criteria.push(galleryCriterion); } - galleryCriterion.modifier = GQL.CriterionModifier.IncludesAll; - } else { - // overwrite - galleryCriterion = new GalleriesCriterion(); - galleryCriterion.value = [galleryValue]; - filter.criteria.push(galleryCriterion); - } - - return filter; - } + return filter; + }, + [gallery] + ); async function setCover( result: GQL.FindImagesQueryResult, diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index 35c367a8a..00b23b0aa 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -751,7 +751,7 @@ export const FilteredImageList = PatchComponent( currentPage={filter.currentPage} itemsPerPage={filter.itemsPerPage} totalItems={totalCount} - onChangePage={(page) => setFilter(filter.changePage(page))} + onChangePage={setPage} /> setFilter(filter.changePage(page))} + onChangePage={setPage} onSelectChange={onSelectChange} pageCount={pageCount} selectedIds={selectedIds} diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index 89c32222f..da52ea765 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Mousetrap from "mousetrap"; import { ListFilterModel } from "src/models/list-filter/filter"; import { useHistory, useLocation } from "react-router-dom"; @@ -489,20 +489,20 @@ export function useCachedQueryResult( result: T ) { const [cachedResult, setCachedResult] = useState(result); - const [lastFilter, setLastFilter] = useState(filter); + const lastFilterRef = useRef(filter); // if we are only changing the page or sort, don't update the result count useEffect(() => { if (!result.loading) { setCachedResult(result); } else { - if (totalCountImpacted(lastFilter, filter)) { + if (totalCountImpacted(lastFilterRef.current, filter)) { setCachedResult(result); } } - setLastFilter(filter); - }, [filter, result, lastFilter]); + lastFilterRef.current = filter; + }, [filter, result]); return cachedResult; } From 69a49c9ab8b36de520ed68d92759c706c2cf9277 Mon Sep 17 00:00:00 2001 From: smith113-p <205463041+smith113-p@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:12:17 -0400 Subject: [PATCH 118/177] Show the stash box for each stash ID in the scene merge dialog (#6656) * Show the stash box for each stash ID in the scene merge dialog Currently, this dialog only shows the ID but not the stash box it corresponds to. This is not very useful because the ID does not mean anything to a user. This renders the ID as "Stashdb | 1234...", mimicing the StashIDPill. * Use StashIDPill instead --- .../src/components/Scenes/SceneMergeDialog.tsx | 15 +++++++++++++-- ui/v2.5/src/components/Shared/styles.scss | 6 +++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx index 89d445002..c38b27f07 100644 --- a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useState } from "react"; import * as GQL from "src/core/generated-graphql"; import { Icon } from "../Shared/Icon"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; -import { StringListSelect, GallerySelect } from "../Shared/Select"; +import { GallerySelect } from "../Shared/Select"; import * as FormUtils from "src/utils/form"; import ImageUtils from "src/utils/image"; import TextUtils from "src/utils/text"; @@ -41,13 +41,24 @@ import { ScrapedTagsRow, } from "../Shared/ScrapeDialog/ScrapedObjectsRow"; import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect"; +import { StashIDPill } from "src/components/Shared/StashID"; interface IStashIDsField { values: GQL.StashId[]; } const StashIDsField: React.FC = ({ values }) => { - return v.stash_id)} />; + if (!values.length) return null; + + return ( +
          + {values.map((v) => ( +
        • + +
        • + ))} +
        + ); }; type MergeOptions = { diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index f2881fc55..61226df49 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -666,10 +666,11 @@ div.react-datepicker { } .stash-id-pill { - display: inline-block; + display: inline-flex; font-size: 90%; font-weight: 700; line-height: 1; + max-width: 100%; padding-bottom: 0.25em; padding-top: 0.25em; text-align: center; @@ -685,12 +686,15 @@ div.react-datepicker { span { background-color: $primary; border-radius: 0.25rem 0 0 0.25rem; + flex-shrink: 0; min-width: 5em; } a { background-color: $secondary; border-radius: 0 0.25rem 0.25rem 0; + overflow: hidden; + text-overflow: ellipsis; } } From 490fa3ea14fb168386f0c08c672a30908c149f0c Mon Sep 17 00:00:00 2001 From: smith113-p <205463041+smith113-p@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:53:20 -0400 Subject: [PATCH 119/177] Show scene resolution and duration in tagger (#6663) * Show scene resolution and duration in tagger A scene's duration and resolution is often useful to ensure you have found the right scene. This PR adds the same resolution/duration overlay from the grid view to the tagger view. --- ui/v2.5/src/components/Scenes/SceneCard.tsx | 62 ++++++++++--------- .../components/Tagger/scenes/TaggerScene.tsx | 6 +- ui/v2.5/src/components/Tagger/styles.scss | 5 ++ 3 files changed, 42 insertions(+), 31 deletions(-) diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index b7c263168..e840dcbac 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -352,6 +352,37 @@ const SceneCardOverlays = PatchComponent( } ); +interface ISceneSpecsOverlay { + scene: GQL.SlimSceneDataFragment; +} + +export const SceneSpecsOverlay: React.FC = ({ scene }) => { + if (!scene.files.length) return null; + let file = scene.files[0]; + return ( +
        + + + + {file.width && file.height ? ( + + {" "} + {TextUtils.resolution(file.width, file.height)} + + ) : ( + "" + )} + {(file.duration ?? 0) >= 1 ? ( + + {TextUtils.secondsToTimestamp(file.duration)} + + ) : ( + "" + )} +
        + ); +}; + const SceneCardImage = PatchComponent( "SceneCard.Image", (props: ISceneCardProps) => { @@ -364,35 +395,6 @@ const SceneCardImage = PatchComponent( [props.scene] ); - function maybeRenderSceneSpecsOverlay() { - return ( -
        - {file?.size !== undefined ? ( - - - - ) : ( - "" - )} - {file?.width && file?.height ? ( - - {" "} - {TextUtils.resolution(file?.width, file?.height)} - - ) : ( - "" - )} - {(file?.duration ?? 0) >= 1 ? ( - - {TextUtils.secondsToTimestamp(file?.duration ?? 0)} - - ) : ( - "" - )} -
        - ); - } - function maybeRenderInteractiveSpeedOverlay() { return (
        @@ -432,7 +434,7 @@ const SceneCardImage = PatchComponent( disabled={props.selecting} /> - {maybeRenderSceneSpecsOverlay()} + {maybeRenderInteractiveSpeedOverlay()} ); diff --git a/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx b/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx index 5446257e5..5ad895fc2 100644 --- a/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx @@ -11,7 +11,10 @@ import { StashIDPill } from "src/components/Shared/StashID"; import { PerformerLink, TagLink } from "src/components/Shared/TagLink"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { parsePath, prepareQueryString } from "src/components/Tagger/utils"; -import { ScenePreview } from "src/components/Scenes/SceneCard"; +import { + ScenePreview, + SceneSpecsOverlay, +} from "src/components/Scenes/SceneCard"; import { TaggerStateContext } from "../context"; import { faChevronDown, @@ -271,6 +274,7 @@ export const TaggerScene: React.FC> = ({ vttPath={scene.paths.vtt ?? undefined} onScrubberClick={onScrubberClick} /> + {maybeRenderSpriteIcon()}
        diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index 8861d0043..5f6ece37d 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -8,6 +8,11 @@ .scene-card { position: relative; + + .scene-specs-overlay { + bottom: 5px; + right: 5px; + } } .scene-card-preview { From 300e7edb755193bba61d38bac6547648bab4b749 Mon Sep 17 00:00:00 2001 From: hyper440 <111574945+hyper440@users.noreply.github.com> Date: Tue, 10 Mar 2026 07:07:46 +0300 Subject: [PATCH 120/177] fix: support string-based fingerprints in hashes filter (#6654) * fix: support string-based fingerprints in hashes filter * Fix tests and add phash test File fingerprints weren't using correct types. Filter test wasn't using correct types. Add phash to general files. --------- Co-authored-by: hyper440 Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- pkg/sqlite/file_filter.go | 30 ++++++++++++++++--------- pkg/sqlite/file_filter_test.go | 41 +++++++++++++++++++++++++++++++++- pkg/sqlite/file_test.go | 6 ++--- pkg/sqlite/setup_test.go | 12 ++++++++-- 4 files changed, 73 insertions(+), 16 deletions(-) diff --git a/pkg/sqlite/file_filter.go b/pkg/sqlite/file_filter.go index 157efb1d8..29946a8ce 100644 --- a/pkg/sqlite/file_filter.go +++ b/pkg/sqlite/file_filter.go @@ -238,22 +238,32 @@ func (qb *fileFilterHandler) hashesCriterionHandler(hashes []*models.Fingerprint t := fmt.Sprintf("file_fingerprints_%d", i) f.addLeftJoin(fingerprintTable, t, fmt.Sprintf("files.id = %s.file_id AND %s.type = ?", t, t), hash.Type) - value, _ := utils.StringToPhash(hash.Value) distance := 0 if hash.Distance != nil { distance = *hash.Distance } - if distance > 0 { - // needed to avoid a type mismatch - f.addWhere(fmt.Sprintf("typeof(%s.fingerprint) = 'integer'", t)) - f.addWhere(fmt.Sprintf("phash_distance(%s.fingerprint, ?) < ?", t), value, distance) + // Only phash supports distance matching and is stored as integer + if hash.Type == models.FingerprintTypePhash { + value, err := utils.StringToPhash(hash.Value) + if err != nil { + f.setError(fmt.Errorf("invalid phash value: %w", err)) + return + } + if distance > 0 { + // needed to avoid a type mismatch + f.addWhere(fmt.Sprintf("typeof(%s.fingerprint) = 'integer'", t)) + f.addWhere(fmt.Sprintf("phash_distance(%s.fingerprint, ?) < ?", t), value, distance) + } else { + intCriterionHandler(&models.IntCriterionInput{ + Value: int(value), + Modifier: models.CriterionModifierEquals, + }, t+".fingerprint", nil)(ctx, f) + } } else { - // use the default handler - intCriterionHandler(&models.IntCriterionInput{ - Value: int(value), - Modifier: models.CriterionModifierEquals, - }, t+".fingerprint", nil)(ctx, f) + // All other fingerprint types (md5, oshash, sha1, etc.) are stored as strings + // Use exact match for string-based fingerprints + f.addWhere(fmt.Sprintf("%s.fingerprint = ?", t), hash.Value) } } } diff --git a/pkg/sqlite/file_filter_test.go b/pkg/sqlite/file_filter_test.go index 50eed0129..648e502f7 100644 --- a/pkg/sqlite/file_filter_test.go +++ b/pkg/sqlite/file_filter_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" "github.com/stretchr/testify/assert" ) @@ -81,7 +82,45 @@ func TestFileQuery(t *testing.T) { includeIDs: []models.FileID{fileIDs[fileIdxInZip]}, excludeIdxs: []int{fileIdxStartImageFiles}, }, - // TODO - add more tests for other file filters + { + name: "hashes md5", + filter: &models.FileFilterType{ + Hashes: []*models.FingerprintFilterInput{ + { + Type: models.FingerprintTypeMD5, + Value: getPrefixedStringValue("file", fileIdxStartVideoFiles, "md5"), + }, + }, + }, + includeIdxs: []int{fileIdxStartVideoFiles}, + excludeIdxs: []int{fileIdxStartImageFiles}, + }, + { + name: "hashes oshash", + filter: &models.FileFilterType{ + Hashes: []*models.FingerprintFilterInput{ + { + Type: models.FingerprintTypeOshash, + Value: getPrefixedStringValue("file", fileIdxStartVideoFiles, "oshash"), + }, + }, + }, + includeIdxs: []int{fileIdxStartVideoFiles}, + excludeIdxs: []int{fileIdxStartImageFiles}, + }, + { + name: "hashes phash", + filter: &models.FileFilterType{ + Hashes: []*models.FingerprintFilterInput{ + { + Type: models.FingerprintTypePhash, + Value: utils.PhashToString(getFilePhash(fileIdxStartImageFiles)), + }, + }, + }, + includeIdxs: []int{fileIdxStartImageFiles}, + excludeIdxs: []int{fileIdxStartVideoFiles}, + }, } for _, tt := range tests { diff --git a/pkg/sqlite/file_test.go b/pkg/sqlite/file_test.go index 8422390c0..55c41f4f7 100644 --- a/pkg/sqlite/file_test.go +++ b/pkg/sqlite/file_test.go @@ -572,7 +572,7 @@ func TestFileStore_FindByFingerprint(t *testing.T) { { "by MD5", models.Fingerprint{ - Type: "MD5", + Type: models.FingerprintTypeMD5, Fingerprint: getPrefixedStringValue("file", fileIdxZip, "md5"), }, []models.File{makeFileWithID(fileIdxZip)}, @@ -581,7 +581,7 @@ func TestFileStore_FindByFingerprint(t *testing.T) { { "by OSHASH", models.Fingerprint{ - Type: "OSHASH", + Type: models.FingerprintTypeOshash, Fingerprint: getPrefixedStringValue("file", fileIdxZip, "oshash"), }, []models.File{makeFileWithID(fileIdxZip)}, @@ -590,7 +590,7 @@ func TestFileStore_FindByFingerprint(t *testing.T) { { "non-existing", models.Fingerprint{ - Type: "OSHASH", + Type: models.FingerprintTypeOshash, Fingerprint: "foo", }, nil, diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index d8baae3b8..db59ff570 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -865,16 +865,24 @@ func getFileModTime(index int) time.Time { return getFolderModTime(index) } +func getFilePhash(index int) int64 { + return int64(index * 567) +} + func getFileFingerprints(index int) []models.Fingerprint { return []models.Fingerprint{ { - Type: "MD5", + Type: models.FingerprintTypeMD5, Fingerprint: getPrefixedStringValue("file", index, "md5"), }, { - Type: "OSHASH", + Type: models.FingerprintTypeOshash, Fingerprint: getPrefixedStringValue("file", index, "oshash"), }, + { + Type: models.FingerprintTypePhash, + Fingerprint: getFilePhash(index), + }, } } From b8bd8953f7ac2f790785b6e794a9b357c6594d82 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Sat, 14 Mar 2026 17:56:31 +1100 Subject: [PATCH 121/177] Refactor bulk edit dialogs (#6647) * Add BulkUpdateDateInput * Refactor edit scenes dialog * Improve bulk date input styling * Make fields inline in edit performers dialog * Refactor edit images dialog * Refactor edit galleries dialog * Add date and synopsis to bulk update group input * Refactor edit groups dialog * Change edit dialog titles to 'Edit x entities' * Update styling of bulk fields to be consistent with other UI * Rename BulkUpdateTextInput to generic BulkUpdate We'll collect other bulk inputs here * Add and use BulkUpdateFormGroup * Handle null dates correctly * Add date clear button and validation --- graphql/schema/types/group.graphql | 2 + internal/api/resolver_mutation_group.go | 6 + .../Galleries/EditGalleriesDialog.tsx | 410 ++++++++--------- .../components/Groups/EditGroupsDialog.tsx | 293 ++++++------ .../components/Images/EditImagesDialog.tsx | 384 ++++++++-------- .../Performers/EditPerformersDialog.tsx | 290 +++++++----- .../Scenes/EditSceneMarkersDialog.tsx | 73 ++- .../components/Scenes/EditScenesDialog.tsx | 432 ++++++++---------- ui/v2.5/src/components/Shared/BulkUpdate.tsx | 89 ++++ .../components/Shared/BulkUpdateTextInput.tsx | 48 -- ui/v2.5/src/components/Shared/DateInput.tsx | 131 +++++- ui/v2.5/src/components/Shared/MultiSet.tsx | 14 +- ui/v2.5/src/components/Shared/styles.scss | 33 +- .../components/Studios/EditStudiosDialog.tsx | 94 ++-- .../src/components/Tags/EditTagsDialog.tsx | 44 +- ui/v2.5/src/core/StashService.ts | 6 +- ui/v2.5/src/locales/en-GB.json | 2 + ui/v2.5/src/utils/bulkUpdate.ts | 5 + ui/v2.5/src/utils/form.tsx | 2 +- ui/v2.5/src/utils/yup.ts | 50 +- 20 files changed, 1253 insertions(+), 1155 deletions(-) create mode 100644 ui/v2.5/src/components/Shared/BulkUpdate.tsx delete mode 100644 ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx diff --git a/graphql/schema/types/group.graphql b/graphql/schema/types/group.graphql index a1c878923..8610f39dc 100644 --- a/graphql/schema/types/group.graphql +++ b/graphql/schema/types/group.graphql @@ -99,6 +99,8 @@ input BulkGroupUpdateInput { ids: [ID!] # rating expressed as 1-100 rating100: Int + date: String + synopsis: String studio_id: ID director: String urls: BulkUpdateStrings diff --git a/internal/api/resolver_mutation_group.go b/internal/api/resolver_mutation_group.go index dff5a6c1e..6c986c4da 100644 --- a/internal/api/resolver_mutation_group.go +++ b/internal/api/resolver_mutation_group.go @@ -227,6 +227,12 @@ func (r *mutationResolver) GroupUpdate(ctx context.Context, input GroupUpdateInp func groupPartialFromBulkGroupUpdateInput(translator changesetTranslator, input BulkGroupUpdateInput) (ret models.GroupPartial, err error) { updatedGroup := models.NewGroupPartial() + updatedGroup.Date, err = translator.optionalDate(input.Date, "date") + if err != nil { + err = fmt.Errorf("converting date: %w", err) + return + } + updatedGroup.Synopsis = translator.optionalString(input.Synopsis, "synopsis") updatedGroup.Rating = translator.optionalInt(input.Rating100, "rating100") updatedGroup.Director = translator.optionalString(input.Director, "director") diff --git a/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx b/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx index 9ff7e00f2..cec44abf1 100644 --- a/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx +++ b/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx @@ -1,100 +1,129 @@ -import React, { useEffect, useState } from "react"; -import { Form, Col, Row } from "react-bootstrap"; -import { FormattedMessage, useIntl } from "react-intl"; -import isEqual from "lodash-es/isEqual"; +import React, { useEffect, useMemo, useState } from "react"; +import { Form } from "react-bootstrap"; +import { useIntl } from "react-intl"; import { useBulkGalleryUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { StudioSelect } from "../Shared/Select"; import { ModalComponent } from "../Shared/Modal"; -import { useToast } from "src/hooks/Toast"; -import * as FormUtils from "src/utils/form"; import { MultiSet } from "../Shared/MultiSet"; +import { useToast } from "src/hooks/Toast"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { - getAggregateInputIDs, getAggregateInputValue, getAggregatePerformerIds, - getAggregateRating, - getAggregateStudioId, + getAggregateStateObject, getAggregateTagIds, + getAggregateStudioId, + getAggregateSceneIds, } from "src/utils/bulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; +import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; +import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate"; +import { BulkUpdateDateInput } from "../Shared/DateInput"; +import { getDateError } from "src/utils/yup"; interface IListOperationProps { selected: GQL.SlimGalleryDataFragment[]; onClose: (applied: boolean) => void; } +const galleryFields = [ + "code", + "rating100", + "details", + "organized", + "photographer", + "date", +]; + export const EditGalleriesDialog: React.FC = ( props: IListOperationProps ) => { const intl = useIntl(); const Toast = useToast(); - const [rating100, setRating] = useState(); - const [studioId, setStudioId] = useState(); - const [performerMode, setPerformerMode] = - React.useState(GQL.BulkUpdateIdMode.Add); - const [performerIds, setPerformerIds] = useState(); - const [existingPerformerIds, setExistingPerformerIds] = useState(); - const [tagMode, setTagMode] = React.useState( - GQL.BulkUpdateIdMode.Add - ); - const [tagIds, setTagIds] = useState(); - const [existingTagIds, setExistingTagIds] = useState(); - const [organized, setOrganized] = useState(); + + const [updateInput, setUpdateInput] = useState({ + ids: props.selected.map((gallery) => { + return gallery.id; + }), + }); + + const [performerIds, setPerformerIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + const [tagIds, setTagIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + const [sceneIds, setSceneIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + + const unsetDisabled = props.selected.length < 2; + + const [dateError, setDateError] = useState(); const [updateGalleries] = useBulkGalleryUpdate(); // Network state const [isUpdating, setIsUpdating] = useState(false); - const checkboxRef = React.createRef(); + const aggregateState = useMemo(() => { + const updateState: Partial = {}; + const state = props.selected; + updateState.studio_id = getAggregateStudioId(props.selected); + const updateTagIds = getAggregateTagIds(props.selected); + const updatePerformerIds = getAggregatePerformerIds(props.selected); + const updateSceneIds = getAggregateSceneIds(props.selected); + let first = true; + + state.forEach((gallery: GQL.SlimGalleryDataFragment) => { + getAggregateStateObject(updateState, gallery, galleryFields, first); + first = false; + }); + + return { + state: updateState, + tagIds: updateTagIds, + performerIds: updatePerformerIds, + sceneIds: updateSceneIds, + }; + }, [props.selected]); + + // update initial state from aggregate + useEffect(() => { + setUpdateInput((current) => ({ ...current, ...aggregateState.state })); + }, [aggregateState]); + + useEffect(() => { + setDateError(getDateError(updateInput.date ?? "", intl)); + }, [updateInput.date, intl]); + + function setUpdateField(input: Partial) { + setUpdateInput((current) => ({ ...current, ...input })); + } function getGalleryInput(): GQL.BulkGalleryUpdateInput { - // need to determine what we are actually setting on each gallery - const aggregateRating = getAggregateRating(props.selected); - const aggregateStudioId = getAggregateStudioId(props.selected); - const aggregatePerformerIds = getAggregatePerformerIds(props.selected); - const aggregateTagIds = getAggregateTagIds(props.selected); - const galleryInput: GQL.BulkGalleryUpdateInput = { - ids: props.selected.map((gallery) => { - return gallery.id; - }), + ...updateInput, + tag_ids: tagIds, + performer_ids: performerIds, + scene_ids: sceneIds, }; - galleryInput.rating100 = getAggregateInputValue(rating100, aggregateRating); - galleryInput.studio_id = getAggregateInputValue( - studioId, - aggregateStudioId + // we don't have unset functionality for the rating star control + // so need to determine if we are setting a rating or not + galleryInput.rating100 = getAggregateInputValue( + updateInput.rating100, + aggregateState.state.rating100 ); - galleryInput.performer_ids = getAggregateInputIDs( - performerMode, - performerIds, - aggregatePerformerIds - ); - galleryInput.tag_ids = getAggregateInputIDs( - tagMode, - tagIds, - aggregateTagIds - ); - - if (organized !== undefined) { - galleryInput.organized = organized; - } - return galleryInput; } async function onSave() { setIsUpdating(true); try { - await updateGalleries({ - variables: { - input: getGalleryInput(), - }, - }); + await updateGalleries({ variables: { input: getGalleryInput() } }); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, @@ -110,129 +139,13 @@ export const EditGalleriesDialog: React.FC = ( setIsUpdating(false); } - useEffect(() => { - const state = props.selected; - let updateRating: number | undefined; - let updateStudioID: string | undefined; - let updatePerformerIds: string[] = []; - let updateTagIds: string[] = []; - let updateOrganized: boolean | undefined; - let first = true; - - state.forEach((gallery: GQL.SlimGalleryDataFragment) => { - const galleryRating = gallery.rating100; - const GalleriestudioID = gallery?.studio?.id; - const galleryPerformerIDs = (gallery.performers ?? []) - .map((p) => p.id) - .sort(); - const galleryTagIDs = (gallery.tags ?? []).map((p) => p.id).sort(); - - if (first) { - updateRating = galleryRating ?? undefined; - updateStudioID = GalleriestudioID; - updatePerformerIds = galleryPerformerIDs; - updateTagIds = galleryTagIDs; - updateOrganized = gallery.organized; - first = false; - } else { - if (galleryRating !== updateRating) { - updateRating = undefined; - } - if (GalleriestudioID !== updateStudioID) { - updateStudioID = undefined; - } - if (!isEqual(galleryPerformerIDs, updatePerformerIds)) { - updatePerformerIds = []; - } - if (!isEqual(galleryTagIDs, updateTagIds)) { - updateTagIds = []; - } - if (gallery.organized !== updateOrganized) { - updateOrganized = undefined; - } - } - }); - - setRating(updateRating); - setStudioId(updateStudioID); - setExistingPerformerIds(updatePerformerIds); - setExistingTagIds(updateTagIds); - - setOrganized(updateOrganized); - }, [props.selected]); - - useEffect(() => { - if (checkboxRef.current) { - checkboxRef.current.indeterminate = organized === undefined; - } - }, [organized, checkboxRef]); - - function renderMultiSelect( - type: "performers" | "tags", - ids: string[] | undefined - ) { - let mode = GQL.BulkUpdateIdMode.Add; - let existingIds: string[] | undefined = []; - switch (type) { - case "performers": - mode = performerMode; - existingIds = existingPerformerIds; - break; - case "tags": - mode = tagMode; - existingIds = existingTagIds; - break; - } - - return ( - { - switch (type) { - case "performers": - setPerformerIds(itemIDs); - break; - case "tags": - setTagIds(itemIDs); - break; - } - }} - onSetMode={(newMode) => { - switch (type) { - case "performers": - setPerformerMode(newMode); - break; - case "tags": - setTagMode(newMode); - break; - } - }} - existingIds={existingIds ?? []} - ids={ids ?? []} - mode={mode} - menuPortalTarget={document.body} - /> - ); - } - - function cycleOrganized() { - if (organized) { - setOrganized(undefined); - } else if (organized === undefined) { - setOrganized(false); - } else { - setOrganized(true); - } - } - function render() { return ( = ( onClick: onSave, text: intl.formatMessage({ id: "actions.apply" }), }} + disabled={isUpdating || !!dateError} cancel={{ onClick: () => props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), @@ -251,55 +165,119 @@ export const EditGalleriesDialog: React.FC = ( isRunning={isUpdating} >
        - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "rating" }), - })} - - setRating(value ?? undefined)} - disabled={isUpdating} - /> - - - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "studio" }), - })} - - - setStudioId(items.length > 0 ? items[0]?.id : undefined) - } - ids={studioId ? [studioId] : []} - isDisabled={isUpdating} - menuPortalTarget={document.body} - /> - - + + + setUpdateField({ rating100: value ?? undefined }) + } + disabled={isUpdating} + /> + - - - - - {renderMultiSelect("performers", performerIds)} - + + setUpdateField({ code: newValue })} + unsetDisabled={unsetDisabled} + /> + + + setUpdateField({ date: newValue })} + unsetDisabled={unsetDisabled} + error={dateError} + /> + - - - - - {renderMultiSelect("tags", tagIds)} - + + + setUpdateField({ photographer: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ + studio_id: items.length > 0 ? items[0]?.id : undefined, + }) + } + ids={updateInput.studio_id ? [updateInput.studio_id] : []} + isDisabled={isUpdating} + menuPortalTarget={document.body} + /> + + + + { + setPerformerIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setPerformerIds((c) => ({ ...c, mode: newMode })); + }} + ids={performerIds.ids ?? []} + existingIds={aggregateState.performerIds} + mode={performerIds.mode} + menuPortalTarget={document.body} + /> + + + + { + setSceneIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setSceneIds((c) => ({ ...c, mode: newMode })); + }} + ids={sceneIds.ids ?? []} + existingIds={aggregateState.sceneIds} + mode={sceneIds.mode} + menuPortalTarget={document.body} + /> + + + + { + setTagIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setTagIds((c) => ({ ...c, mode: newMode })); + }} + ids={tagIds.ids ?? []} + existingIds={aggregateState.tagIds} + mode={tagIds.mode} + menuPortalTarget={document.body} + /> + + + + setUpdateField({ details: newValue })} + unsetDisabled={unsetDisabled} + as="textarea" + /> + - cycleOrganized()} + setChecked={(checked) => setUpdateField({ organized: checked })} + checked={updateInput.organized ?? undefined} />
        diff --git a/ui/v2.5/src/components/Groups/EditGroupsDialog.tsx b/ui/v2.5/src/components/Groups/EditGroupsDialog.tsx index ef3171de2..99c482aba 100644 --- a/ui/v2.5/src/components/Groups/EditGroupsDialog.tsx +++ b/ui/v2.5/src/components/Groups/EditGroupsDialog.tsx @@ -1,26 +1,26 @@ -import React, { useEffect, useState } from "react"; -import { Form, Col, Row } from "react-bootstrap"; -import { FormattedMessage, useIntl } from "react-intl"; +import React, { useEffect, useMemo, useState } from "react"; +import { Form } from "react-bootstrap"; +import { useIntl } from "react-intl"; import { useBulkGroupUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; -import { ModalComponent } from "../Shared/Modal"; import { StudioSelect } from "../Shared/Select"; +import { ModalComponent } from "../Shared/Modal"; +import { MultiSet } from "../Shared/MultiSet"; import { useToast } from "src/hooks/Toast"; -import * as FormUtils from "src/utils/form"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { - getAggregateIds, - getAggregateInputIDs, getAggregateInputValue, - getAggregateRating, - getAggregateStudioId, + getAggregateStateObject, getAggregateTagIds, + getAggregateStudioId, + getAggregateIds, } from "src/utils/bulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; -import { isEqual } from "lodash-es"; -import { MultiSet } from "../Shared/MultiSet"; -import { ContainingGroupsMultiSet } from "./ContainingGroupsMultiSet"; +import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate"; +import { BulkUpdateDateInput } from "../Shared/DateInput"; import { IRelatedGroupEntry } from "./GroupDetails/RelatedGroupTable"; +import { ContainingGroupsMultiSet } from "./ContainingGroupsMultiSet"; +import { getDateError } from "src/utils/yup"; interface IListOperationProps { selected: GQL.ListGroupDataFragment[]; @@ -67,50 +67,86 @@ function getAggregateContainingGroupInput( return undefined; } +const groupFields = ["rating100", "synopsis", "director", "date"]; + export const EditGroupsDialog: React.FC = ( props: IListOperationProps ) => { const intl = useIntl(); const Toast = useToast(); - const [rating100, setRating] = useState(); - const [studioId, setStudioId] = useState(); - const [director, setDirector] = useState(); - const [tagMode, setTagMode] = React.useState( - GQL.BulkUpdateIdMode.Add - ); - const [tagIds, setTagIds] = useState(); - const [existingTagIds, setExistingTagIds] = useState(); + const [updateInput, setUpdateInput] = useState({ + ids: props.selected.map((group) => { + return group.id; + }), + }); + const [tagIds, setTagIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); const [containingGroupsMode, setGroupMode] = React.useState(GQL.BulkUpdateIdMode.Add); const [containingGroups, setGroups] = useState(); - const [existingContainingGroups, setExistingContainingGroups] = - useState(); - const [updateGroups] = useBulkGroupUpdate(getGroupInput()); + const unsetDisabled = props.selected.length < 2; + const [updateGroups] = useBulkGroupUpdate(); + + const [dateError, setDateError] = useState(); + + // Network state const [isUpdating, setIsUpdating] = useState(false); - function getGroupInput(): GQL.BulkGroupUpdateInput { - const aggregateRating = getAggregateRating(props.selected); - const aggregateStudioId = getAggregateStudioId(props.selected); - const aggregateTagIds = getAggregateTagIds(props.selected); + const aggregateState = useMemo(() => { + const updateState: Partial = {}; + const state = props.selected; + updateState.studio_id = getAggregateStudioId(props.selected); + const updateTagIds = getAggregateTagIds(props.selected); const aggregateGroups = getAggregateContainingGroups(props.selected); + let first = true; + state.forEach((group: GQL.ListGroupDataFragment) => { + getAggregateStateObject(updateState, group, groupFields, first); + first = false; + }); + + return { + state: updateState, + tagIds: updateTagIds, + containingGroups: aggregateGroups, + }; + }, [props.selected]); + + // update initial state from aggregate + useEffect(() => { + setUpdateInput((current) => ({ ...current, ...aggregateState.state })); + }, [aggregateState]); + + useEffect(() => { + setDateError(getDateError(updateInput.date ?? "", intl)); + }, [updateInput.date, intl]); + + function setUpdateField(input: Partial) { + setUpdateInput((current) => ({ ...current, ...input })); + } + + function getGroupInput(): GQL.BulkGroupUpdateInput { const groupInput: GQL.BulkGroupUpdateInput = { - ids: props.selected.map((group) => group.id), - director, + ...updateInput, + tag_ids: tagIds, }; - groupInput.rating100 = getAggregateInputValue(rating100, aggregateRating); - groupInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); - groupInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds); + // we don't have unset functionality for the rating star control + // so need to determine if we are setting a rating or not + groupInput.rating100 = getAggregateInputValue( + updateInput.rating100, + aggregateState.state.rating100 + ); groupInput.containing_groups = getAggregateContainingGroupInput( containingGroupsMode, containingGroups, - aggregateGroups + aggregateState.containingGroups ); return groupInput; @@ -119,13 +155,11 @@ export const EditGroupsDialog: React.FC = ( async function onSave() { setIsUpdating(true); try { - await updateGroups(); + await updateGroups({ variables: { input: getGroupInput() } }); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, - { - entity: intl.formatMessage({ id: "groups" }).toLocaleLowerCase(), - } + { entity: intl.formatMessage({ id: "groups" }).toLocaleLowerCase() } ) ); props.onClose(true); @@ -135,67 +169,24 @@ export const EditGroupsDialog: React.FC = ( setIsUpdating(false); } - useEffect(() => { - const state = props.selected; - let updateRating: number | undefined; - let updateStudioId: string | undefined; - let updateTagIds: string[] = []; - let updateContainingGroupIds: IRelatedGroupEntry[] = []; - let updateDirector: string | undefined; - let first = true; - - state.forEach((group: GQL.ListGroupDataFragment) => { - const groupTagIDs = (group.tags ?? []).map((p) => p.id).sort(); - const groupContainingGroupIDs = (group.containing_groups ?? []).sort( - (a, b) => a.group.id.localeCompare(b.group.id) - ); - - if (first) { - first = false; - updateRating = group.rating100 ?? undefined; - updateStudioId = group.studio?.id ?? undefined; - updateTagIds = groupTagIDs; - updateContainingGroupIds = groupContainingGroupIDs; - updateDirector = group.director ?? undefined; - } else { - if (group.rating100 !== updateRating) { - updateRating = undefined; - } - if (group.studio?.id !== updateStudioId) { - updateStudioId = undefined; - } - if (group.director !== updateDirector) { - updateDirector = undefined; - } - if (!isEqual(groupTagIDs, updateTagIds)) { - updateTagIds = []; - } - if (!isEqual(groupContainingGroupIDs, updateContainingGroupIds)) { - updateTagIds = []; - } - } - }); - - setRating(updateRating); - setStudioId(updateStudioId); - setExistingTagIds(updateTagIds); - setExistingContainingGroups(updateContainingGroupIds); - setDirector(updateDirector); - }, [props.selected]); - function render() { return ( props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), @@ -204,74 +195,90 @@ export const EditGroupsDialog: React.FC = ( isRunning={isUpdating} >
        - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "rating" }), - })} - - setRating(value ?? undefined)} - disabled={isUpdating} - /> - - - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "studio" }), - })} - - - setStudioId(items.length > 0 ? items[0]?.id : undefined) - } - ids={studioId ? [studioId] : []} - isDisabled={isUpdating} - menuPortalTarget={document.body} - /> - - - - - - + + + setUpdateField({ rating100: value ?? undefined }) + } + disabled={isUpdating} + /> + + + + setUpdateField({ date: newValue })} + unsetDisabled={unsetDisabled} + error={dateError} + /> + + + + + setUpdateField({ director: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ + studio_id: items.length > 0 ? items[0]?.id : undefined, + }) + } + ids={updateInput.studio_id ? [updateInput.studio_id] : []} + isDisabled={isUpdating} + menuPortalTarget={document.body} + /> + + + setGroups(v)} onSetMode={(newMode) => setGroupMode(newMode)} - existingValue={existingContainingGroups ?? []} + existingValue={aggregateState.containingGroups ?? []} value={containingGroups ?? []} mode={containingGroupsMode} menuPortalTarget={document.body} /> - - - - - - setDirector(event.currentTarget.value)} - placeholder={intl.formatMessage({ id: "director" })} - /> - - - - - + + + setTagIds(itemIDs)} - onSetMode={(newMode) => setTagMode(newMode)} - existingIds={existingTagIds ?? []} - ids={tagIds ?? []} - mode={tagMode} + onUpdate={(itemIDs) => { + setTagIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setTagIds((c) => ({ ...c, mode: newMode })); + }} + ids={tagIds.ids ?? []} + existingIds={aggregateState.tagIds} + mode={tagIds.mode} menuPortalTarget={document.body} /> - + + + + + setUpdateField({ synopsis: newValue }) + } + unsetDisabled={unsetDisabled} + as="textarea" + /> +
        ); diff --git a/ui/v2.5/src/components/Images/EditImagesDialog.tsx b/ui/v2.5/src/components/Images/EditImagesDialog.tsx index 275ff1556..a90ef922e 100644 --- a/ui/v2.5/src/components/Images/EditImagesDialog.tsx +++ b/ui/v2.5/src/components/Images/EditImagesDialog.tsx @@ -1,96 +1,121 @@ -import React, { useEffect, useState } from "react"; -import { Form, Col, Row } from "react-bootstrap"; -import { FormattedMessage, useIntl } from "react-intl"; -import isEqual from "lodash-es/isEqual"; +import React, { useEffect, useMemo, useState } from "react"; +import { Form } from "react-bootstrap"; +import { useIntl } from "react-intl"; import { useBulkImageUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; -import { StudioSelect } from "src/components/Shared/Select"; -import { ModalComponent } from "src/components/Shared/Modal"; -import { useToast } from "src/hooks/Toast"; -import * as FormUtils from "src/utils/form"; +import { StudioSelect } from "../Shared/Select"; +import { ModalComponent } from "../Shared/Modal"; import { MultiSet } from "../Shared/MultiSet"; +import { useToast } from "src/hooks/Toast"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { - getAggregateGalleryIds, - getAggregateInputIDs, getAggregateInputValue, getAggregatePerformerIds, - getAggregateRating, - getAggregateStudioId, + getAggregateStateObject, getAggregateTagIds, + getAggregateStudioId, + getAggregateGalleryIds, } from "src/utils/bulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; +import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; +import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate"; +import { BulkUpdateDateInput } from "../Shared/DateInput"; +import { getDateError } from "src/utils/yup"; interface IListOperationProps { selected: GQL.SlimImageDataFragment[]; onClose: (applied: boolean) => void; } +const imageFields = [ + "code", + "rating100", + "details", + "organized", + "photographer", + "date", +]; + export const EditImagesDialog: React.FC = ( props: IListOperationProps ) => { const intl = useIntl(); const Toast = useToast(); - const [rating100, setRating] = useState(); - const [studioId, setStudioId] = useState(); - const [performerMode, setPerformerMode] = - React.useState(GQL.BulkUpdateIdMode.Add); - const [performerIds, setPerformerIds] = useState(); - const [existingPerformerIds, setExistingPerformerIds] = useState(); - const [tagMode, setTagMode] = React.useState( - GQL.BulkUpdateIdMode.Add - ); - const [tagIds, setTagIds] = useState(); - const [existingTagIds, setExistingTagIds] = useState(); + const [updateInput, setUpdateInput] = useState({ + ids: props.selected.map((image) => { + return image.id; + }), + }); - const [galleryMode, setGalleryMode] = React.useState( - GQL.BulkUpdateIdMode.Add - ); - const [galleryIds, setGalleryIds] = useState(); - const [existingGalleryIds, setExistingGalleryIds] = useState(); + const [performerIds, setPerformerIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + const [tagIds, setTagIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + const [galleryIds, setGalleryIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); - const [organized, setOrganized] = useState(); + const unsetDisabled = props.selected.length < 2; + + const [dateError, setDateError] = useState(); const [updateImages] = useBulkImageUpdate(); // Network state const [isUpdating, setIsUpdating] = useState(false); - const checkboxRef = React.createRef(); + const aggregateState = useMemo(() => { + const updateState: Partial = {}; + const state = props.selected; + updateState.studio_id = getAggregateStudioId(props.selected); + const updateTagIds = getAggregateTagIds(props.selected); + const updatePerformerIds = getAggregatePerformerIds(props.selected); + const updateGalleryIds = getAggregateGalleryIds(props.selected); + let first = true; + + state.forEach((image: GQL.SlimImageDataFragment) => { + getAggregateStateObject(updateState, image, imageFields, first); + first = false; + }); + + return { + state: updateState, + tagIds: updateTagIds, + performerIds: updatePerformerIds, + galleryIds: updateGalleryIds, + }; + }, [props.selected]); + + // update initial state from aggregate + useEffect(() => { + setUpdateInput((current) => ({ ...current, ...aggregateState.state })); + }, [aggregateState]); + + useEffect(() => { + setDateError(getDateError(updateInput.date ?? "", intl)); + }, [updateInput.date, intl]); + + function setUpdateField(input: Partial) { + setUpdateInput((current) => ({ ...current, ...input })); + } function getImageInput(): GQL.BulkImageUpdateInput { - // need to determine what we are actually setting on each image - const aggregateRating = getAggregateRating(props.selected); - const aggregateStudioId = getAggregateStudioId(props.selected); - const aggregatePerformerIds = getAggregatePerformerIds(props.selected); - const aggregateTagIds = getAggregateTagIds(props.selected); - const aggregateGalleryIds = getAggregateGalleryIds(props.selected); - const imageInput: GQL.BulkImageUpdateInput = { - ids: props.selected.map((image) => { - return image.id; - }), + ...updateInput, + tag_ids: tagIds, + performer_ids: performerIds, + gallery_ids: galleryIds, }; - imageInput.rating100 = getAggregateInputValue(rating100, aggregateRating); - imageInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); - - imageInput.performer_ids = getAggregateInputIDs( - performerMode, - performerIds, - aggregatePerformerIds + // we don't have unset functionality for the rating star control + // so need to determine if we are setting a rating or not + imageInput.rating100 = getAggregateInputValue( + updateInput.rating100, + aggregateState.state.rating100 ); - imageInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds); - imageInput.gallery_ids = getAggregateInputIDs( - galleryMode, - galleryIds, - aggregateGalleryIds - ); - - if (organized !== undefined) { - imageInput.organized = organized; - } return imageInput; } @@ -98,11 +123,7 @@ export const EditImagesDialog: React.FC = ( async function onSave() { setIsUpdating(true); try { - await updateImages({ - variables: { - input: getImageInput(), - }, - }); + await updateImages({ variables: { input: getImageInput() } }); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, @@ -116,86 +137,13 @@ export const EditImagesDialog: React.FC = ( setIsUpdating(false); } - useEffect(() => { - const state = props.selected; - let updateRating: number | undefined; - let updateStudioID: string | undefined; - let updatePerformerIds: string[] = []; - let updateTagIds: string[] = []; - let updateGalleryIds: string[] = []; - let updateOrganized: boolean | undefined; - let first = true; - - state.forEach((image: GQL.SlimImageDataFragment) => { - const imageRating = image.rating100; - const imageStudioID = image?.studio?.id; - const imagePerformerIDs = (image.performers ?? []) - .map((p) => p.id) - .sort(); - const imageTagIDs = (image.tags ?? []).map((p) => p.id).sort(); - const imageGalleryIDs = (image.galleries ?? []).map((p) => p.id).sort(); - - if (first) { - updateRating = imageRating ?? undefined; - updateStudioID = imageStudioID; - updatePerformerIds = imagePerformerIDs; - updateTagIds = imageTagIDs; - updateGalleryIds = imageGalleryIDs; - updateOrganized = image.organized; - first = false; - } else { - if (imageRating !== updateRating) { - updateRating = undefined; - } - if (imageStudioID !== updateStudioID) { - updateStudioID = undefined; - } - if (!isEqual(imagePerformerIDs, updatePerformerIds)) { - updatePerformerIds = []; - } - if (!isEqual(imageTagIDs, updateTagIds)) { - updateTagIds = []; - } - if (!isEqual(imageGalleryIDs, updateGalleryIds)) { - updateGalleryIds = []; - } - if (image.organized !== updateOrganized) { - updateOrganized = undefined; - } - } - }); - - setRating(updateRating); - setStudioId(updateStudioID); - setExistingPerformerIds(updatePerformerIds); - setExistingTagIds(updateTagIds); - setExistingGalleryIds(updateGalleryIds); - setOrganized(updateOrganized); - }, [props.selected]); - - useEffect(() => { - if (checkboxRef.current) { - checkboxRef.current.indeterminate = organized === undefined; - } - }, [organized, checkboxRef]); - - function cycleOrganized() { - if (organized) { - setOrganized(undefined); - } else if (organized === undefined) { - setOrganized(false); - } else { - setOrganized(true); - } - } - function render() { return ( = ( onClick: onSave, text: intl.formatMessage({ id: "actions.apply" }), }} + disabled={isUpdating || !!dateError} cancel={{ onClick: () => props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), @@ -214,89 +163,120 @@ export const EditImagesDialog: React.FC = ( isRunning={isUpdating} >
        - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "rating" }), - })} - - setRating(value ?? undefined)} - disabled={isUpdating} - /> - - - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "studio" }), - })} - - - setStudioId(items.length > 0 ? items[0]?.id : undefined) - } - ids={studioId ? [studioId] : []} - isDisabled={isUpdating} - menuPortalTarget={document.body} - /> - - - - - - - - + + setUpdateField({ rating100: value ?? undefined }) + } disabled={isUpdating} - onUpdate={(itemIDs) => setPerformerIds(itemIDs)} - onSetMode={(newMode) => setPerformerMode(newMode)} - existingIds={existingPerformerIds ?? []} - ids={performerIds ?? []} - mode={performerMode} + /> + + + + setUpdateField({ code: newValue })} + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ date: newValue })} + unsetDisabled={unsetDisabled} + error={dateError} + /> + + + + + setUpdateField({ photographer: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ + studio_id: items.length > 0 ? items[0]?.id : undefined, + }) + } + ids={updateInput.studio_id ? [updateInput.studio_id] : []} + isDisabled={isUpdating} menuPortalTarget={document.body} /> - + - - - - + setTagIds(itemIDs)} - onSetMode={(newMode) => setTagMode(newMode)} - existingIds={existingTagIds ?? []} - ids={tagIds ?? []} - mode={tagMode} + onUpdate={(itemIDs) => { + setPerformerIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setPerformerIds((c) => ({ ...c, mode: newMode })); + }} + ids={performerIds.ids ?? []} + existingIds={aggregateState.performerIds} + mode={performerIds.mode} menuPortalTarget={document.body} /> - + - - - - + setGalleryIds(itemIDs)} - onSetMode={(newMode) => setGalleryMode(newMode)} - existingIds={existingGalleryIds ?? []} - ids={galleryIds ?? []} - mode={galleryMode} + onUpdate={(itemIDs) => { + setGalleryIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setGalleryIds((c) => ({ ...c, mode: newMode })); + }} + ids={galleryIds.ids ?? []} + existingIds={aggregateState.galleryIds} + mode={galleryIds.mode} menuPortalTarget={document.body} /> - + + + + { + setTagIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setTagIds((c) => ({ ...c, mode: newMode })); + }} + ids={tagIds.ids ?? []} + existingIds={aggregateState.tagIds} + mode={tagIds.mode} + menuPortalTarget={document.body} + /> + + + + setUpdateField({ details: newValue })} + unsetDisabled={unsetDisabled} + as="textarea" + /> + - cycleOrganized()} + setChecked={(checked) => setUpdateField({ organized: checked })} + checked={updateInput.organized ?? undefined} />
        diff --git a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx index d60118d4b..d63886167 100644 --- a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx +++ b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; -import { Col, Form, Row } from "react-bootstrap"; -import { FormattedMessage, useIntl } from "react-intl"; +import { Form } from "react-bootstrap"; +import { useIntl } from "react-intl"; import { useBulkPerformerUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { ModalComponent } from "../Shared/Modal"; @@ -23,12 +23,13 @@ import { stringToCircumcised, } from "src/utils/circumcised"; import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; -import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput"; +import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; -import * as FormUtils from "src/utils/form"; import { CountrySelect } from "../Shared/CountrySelect"; import { useConfigurationContext } from "src/hooks/Config"; import cx from "classnames"; +import { BulkUpdateDateInput } from "../Shared/DateInput"; +import { getDateError } from "src/utils/yup"; interface IListOperationProps { selected: GQL.SlimPerformerDataFragment[]; @@ -75,17 +76,30 @@ export const EditPerformersDialog: React.FC = ( const [aggregateState, setAggregateState] = useState({}); // height and weight needs conversion to/from number - const [height, setHeight] = useState(); - const [weight, setWeight] = useState(); - const [penis_length, setPenisLength] = useState(); + const [height, setHeight] = useState(); + const [weight, setWeight] = useState(); + const [penis_length, setPenisLength] = useState(); const [updateInput, setUpdateInput] = useState( {} ); const genderOptions = [""].concat(genderStrings); const circumcisedOptions = [""].concat(circumcisedStrings); + const unsetDisabled = props.selected.length < 2; + const [updatePerformers] = useBulkPerformerUpdate(getPerformerInput()); + const [birthdateError, setBirthdateError] = useState(); + const [deathDateError, setDeathDateError] = useState(); + + useEffect(() => { + setBirthdateError(getDateError(updateInput.birthdate ?? "", intl)); + }, [updateInput.birthdate, intl]); + + useEffect(() => { + setDeathDateError(getDateError(updateInput.death_date ?? "", intl)); + }, [updateInput.death_date, intl]); + // Network state const [isUpdating, setIsUpdating] = useState(false); @@ -121,14 +135,14 @@ export const EditPerformersDialog: React.FC = ( ); if (height !== undefined) { - performerInput.height_cm = parseFloat(height); + performerInput.height_cm = height === null ? null : parseFloat(height); } if (weight !== undefined) { - performerInput.weight = parseFloat(weight); + performerInput.weight = weight === null ? null : parseFloat(weight); } - if (penis_length !== undefined) { - performerInput.penis_length = parseFloat(penis_length); + performerInput.penis_length = + penis_length === null ? null : parseFloat(penis_length); } return performerInput; @@ -205,25 +219,6 @@ export const EditPerformersDialog: React.FC = ( setUpdateInput(updateState); }, [props.selected]); - function renderTextField( - name: string, - value: string | undefined | null, - setter: (newValue: string | undefined) => void - ) { - return ( - - - - - setter(newValue)} - unsetDisabled={props.selected.length < 2} - /> - - ); - } - function render() { // sfw class needs to be set because it is outside body @@ -235,13 +230,18 @@ export const EditPerformersDialog: React.FC = ( show icon={faPencilAlt} header={intl.formatMessage( - { id: "actions.edit_entity" }, - { entityType: intl.formatMessage({ id: "performers" }) } + { id: "dialogs.edit_entity_count_title" }, + { + count: props?.selected?.length ?? 1, + singularEntity: intl.formatMessage({ id: "performer" }), + pluralEntity: intl.formatMessage({ id: "performers" }), + } )} accept={{ onClick: onSave, text: intl.formatMessage({ id: "actions.apply" }), }} + disabled={isUpdating || !!birthdateError || !!deathDateError} cancel={{ onClick: () => props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), @@ -249,11 +249,8 @@ export const EditPerformersDialog: React.FC = ( }} isRunning={isUpdating} > - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "rating" }), - })} - +
        + @@ -261,9 +258,8 @@ export const EditPerformersDialog: React.FC = ( } disabled={isUpdating} /> - - - + + setUpdateField({ favorite: checked })} @@ -272,10 +268,7 @@ export const EditPerformersDialog: React.FC = ( /> - - - - + = ( ))} - + - {renderTextField("disambiguation", updateInput.disambiguation, (v) => - setUpdateField({ disambiguation: v }) - )} - {renderTextField("birthdate", updateInput.birthdate, (v) => - setUpdateField({ birthdate: v }) - )} - {renderTextField("death_date", updateInput.death_date, (v) => - setUpdateField({ death_date: v }) - )} + + + setUpdateField({ disambiguation: newValue }) + } + unsetDisabled={unsetDisabled} + /> + - - - - + + + setUpdateField({ birthdate: newValue }) + } + unsetDisabled={unsetDisabled} + error={birthdateError} + /> + + + + setUpdateField({ death_date: newValue }) + } + unsetDisabled={unsetDisabled} + error={deathDateError} + /> + + setUpdateField({ country: v })} showFlag /> - + - {renderTextField("ethnicity", updateInput.ethnicity, (v) => - setUpdateField({ ethnicity: v }) - )} - {renderTextField("hair_color", updateInput.hair_color, (v) => - setUpdateField({ hair_color: v }) - )} - {renderTextField("eye_color", updateInput.eye_color, (v) => - setUpdateField({ eye_color: v }) - )} - {renderTextField("height", height, (v) => setHeight(v))} - {renderTextField("weight", weight, (v) => setWeight(v))} - {renderTextField("measurements", updateInput.measurements, (v) => - setUpdateField({ measurements: v }) - )} - {renderTextField("penis_length", penis_length, (v) => - setPenisLength(v) - )} + + + setUpdateField({ ethnicity: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ hair_color: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ eye_color: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + setHeight(newValue)} + unsetDisabled={unsetDisabled} + /> + + + setWeight(newValue)} + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ measurements: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + setPenisLength(newValue)} + unsetDisabled={unsetDisabled} + /> + - - - - + = ( ))} - + - {renderTextField("fake_tits", updateInput.fake_tits, (v) => - setUpdateField({ fake_tits: v }) - )} - {renderTextField("tattoos", updateInput.tattoos, (v) => - setUpdateField({ tattoos: v }) - )} - {renderTextField("piercings", updateInput.piercings, (v) => - setUpdateField({ piercings: v }) - )} - {renderTextField( - "career_start", - updateInput.career_start?.toString(), - (v) => setUpdateField({ career_start: v ? parseInt(v) : undefined }) - )} - {renderTextField( - "career_end", - updateInput.career_end?.toString(), - (v) => setUpdateField({ career_end: v ? parseInt(v) : undefined }) - )} + + + setUpdateField({ fake_tits: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + setUpdateField({ tattoos: newValue })} + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ piercings: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ career_start: v ? parseInt(v) : undefined }) + } + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ career_end: v ? parseInt(v) : undefined }) + } + unsetDisabled={unsetDisabled} + /> + - - - - + setTagIds({ ...tagIds, ids: itemIDs })} - onSetMode={(newMode) => setTagIds({ ...tagIds, mode: newMode })} - existingIds={existingTagIds ?? []} + onUpdate={(itemIDs) => { + setTagIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setTagIds((c) => ({ ...c, mode: newMode })); + }} ids={tagIds.ids ?? []} + existingIds={existingTagIds} mode={tagIds.mode} menuPortalTarget={document.body} /> - + = ( mode: GQL.BulkUpdateIdMode.Add, }); + const unsetDisabled = props.selected.length < 2; + const [updateSceneMarkers] = useBulkSceneMarkerUpdate(); // Network state @@ -115,27 +117,6 @@ export const EditSceneMarkersDialog: React.FC = ( setIsUpdating(false); } - function renderTextField( - name: string, - value: string | undefined | null, - setter: (newValue: string | undefined) => void, - area: boolean = false - ) { - return ( - - - - - setter(newValue)} - unsetDisabled={props.selected.length < 2} - as={area ? "textarea" : undefined} - /> - - ); - } - function render() { return ( = ( show icon={faPencilAlt} header={intl.formatMessage( - { id: "actions.edit_entity" }, - { entityType: intl.formatMessage({ id: "markers" }) } + { id: "dialogs.edit_entity_count_title" }, + { + count: props?.selected?.length ?? 1, + singularEntity: intl.formatMessage({ id: "marker" }), + pluralEntity: intl.formatMessage({ id: "markers" }), + } )} accept={{ onClick: onSave, @@ -158,39 +143,39 @@ export const EditSceneMarkersDialog: React.FC = ( isRunning={isUpdating} > - {renderTextField("title", updateInput.title, (newValue) => - setUpdateField({ title: newValue }) - )} + + setUpdateField({ title: newValue })} + unsetDisabled={unsetDisabled} + /> + - - - - + setUpdateField({ primary_tag_id: t[0]?.id })} ids={ updateInput.primary_tag_id ? [updateInput.primary_tag_id] : [] } /> - + - - - - + setTagIds((v) => ({ ...v, ids: itemIDs }))} - onSetMode={(newMode) => - setTagIds((v) => ({ ...v, mode: newMode })) - } - existingIds={aggregateState.tagIds ?? []} + onUpdate={(itemIDs) => { + setTagIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setTagIds((c) => ({ ...c, mode: newMode })); + }} ids={tagIds.ids ?? []} + existingIds={aggregateState.tagIds ?? []} mode={tagIds.mode} menuPortalTarget={document.body} /> - + ); diff --git a/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx b/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx index 7b69cf655..17466bfc9 100644 --- a/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx +++ b/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx @@ -1,93 +1,121 @@ -import React, { useEffect, useState } from "react"; -import { Form, Col, Row } from "react-bootstrap"; -import { FormattedMessage, useIntl } from "react-intl"; -import isEqual from "lodash-es/isEqual"; +import React, { useEffect, useMemo, useState } from "react"; +import { Form } from "react-bootstrap"; +import { useIntl } from "react-intl"; import { useBulkSceneUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { StudioSelect } from "../Shared/Select"; import { ModalComponent } from "../Shared/Modal"; import { MultiSet } from "../Shared/MultiSet"; import { useToast } from "src/hooks/Toast"; -import * as FormUtils from "src/utils/form"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { - getAggregateInputIDs, getAggregateInputValue, getAggregateGroupIds, getAggregatePerformerIds, - getAggregateRating, - getAggregateStudioId, + getAggregateStateObject, getAggregateTagIds, + getAggregateStudioId, } from "src/utils/bulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; +import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; +import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate"; +import { BulkUpdateDateInput } from "../Shared/DateInput"; +import { getDateError } from "src/utils/yup"; interface IListOperationProps { selected: GQL.SlimSceneDataFragment[]; onClose: (applied: boolean) => void; } +const sceneFields = [ + "code", + "rating100", + "details", + "organized", + "director", + "date", +]; + export const EditScenesDialog: React.FC = ( props: IListOperationProps ) => { const intl = useIntl(); const Toast = useToast(); - const [rating100, setRating] = useState(); - const [studioId, setStudioId] = useState(); - const [performerMode, setPerformerMode] = - React.useState(GQL.BulkUpdateIdMode.Add); - const [performerIds, setPerformerIds] = useState(); - const [existingPerformerIds, setExistingPerformerIds] = useState(); - const [tagMode, setTagMode] = React.useState( - GQL.BulkUpdateIdMode.Add - ); - const [tagIds, setTagIds] = useState(); - const [existingTagIds, setExistingTagIds] = useState(); - const [groupMode, setGroupMode] = React.useState( - GQL.BulkUpdateIdMode.Add - ); - const [groupIds, setGroupIds] = useState(); - const [existingGroupIds, setExistingGroupIds] = useState(); - const [organized, setOrganized] = useState(); - const [updateScenes] = useBulkSceneUpdate(getSceneInput()); + const [updateInput, setUpdateInput] = useState({ + ids: props.selected.map((scene) => { + return scene.id; + }), + }); + + const [dateError, setDateError] = useState(); + + const [performerIds, setPerformerIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + const [tagIds, setTagIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + const [groupIds, setGroupIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + + const unsetDisabled = props.selected.length < 2; + + const [updateScenes] = useBulkSceneUpdate(); // Network state const [isUpdating, setIsUpdating] = useState(false); - const checkboxRef = React.createRef(); + const aggregateState = useMemo(() => { + const updateState: Partial = {}; + const state = props.selected; + updateState.studio_id = getAggregateStudioId(props.selected); + const updateTagIds = getAggregateTagIds(props.selected); + const updatePerformerIds = getAggregatePerformerIds(props.selected); + const updateGroupIds = getAggregateGroupIds(props.selected); + let first = true; + + state.forEach((scene: GQL.SlimSceneDataFragment) => { + getAggregateStateObject(updateState, scene, sceneFields, first); + first = false; + }); + + return { + state: updateState, + tagIds: updateTagIds, + performerIds: updatePerformerIds, + groupIds: updateGroupIds, + }; + }, [props.selected]); + + // update initial state from aggregate + useEffect(() => { + setUpdateInput((current) => ({ ...current, ...aggregateState.state })); + }, [aggregateState]); + + useEffect(() => { + setDateError(getDateError(updateInput.date ?? "", intl)); + }, [updateInput.date, intl]); + + function setUpdateField(input: Partial) { + setUpdateInput((current) => ({ ...current, ...input })); + } function getSceneInput(): GQL.BulkSceneUpdateInput { - // need to determine what we are actually setting on each scene - const aggregateRating = getAggregateRating(props.selected); - const aggregateStudioId = getAggregateStudioId(props.selected); - const aggregatePerformerIds = getAggregatePerformerIds(props.selected); - const aggregateTagIds = getAggregateTagIds(props.selected); - const aggregateGroupIds = getAggregateGroupIds(props.selected); - const sceneInput: GQL.BulkSceneUpdateInput = { - ids: props.selected.map((scene) => { - return scene.id; - }), + ...updateInput, + tag_ids: tagIds, + performer_ids: performerIds, + group_ids: groupIds, }; - sceneInput.rating100 = getAggregateInputValue(rating100, aggregateRating); - sceneInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); - - sceneInput.performer_ids = getAggregateInputIDs( - performerMode, - performerIds, - aggregatePerformerIds + // we don't have unset functionality for the rating star control + // so need to determine if we are setting a rating or not + sceneInput.rating100 = getAggregateInputValue( + updateInput.rating100, + aggregateState.state.rating100 ); - sceneInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds); - sceneInput.group_ids = getAggregateInputIDs( - groupMode, - groupIds, - aggregateGroupIds - ); - - if (organized !== undefined) { - sceneInput.organized = organized; - } return sceneInput; } @@ -95,7 +123,7 @@ export const EditScenesDialog: React.FC = ( async function onSave() { setIsUpdating(true); try { - await updateScenes(); + await updateScenes({ variables: { input: getSceneInput() } }); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, @@ -109,145 +137,13 @@ export const EditScenesDialog: React.FC = ( setIsUpdating(false); } - useEffect(() => { - const state = props.selected; - let updateRating: number | undefined; - let updateStudioID: string | undefined; - let updatePerformerIds: string[] = []; - let updateTagIds: string[] = []; - let updateGroupIds: string[] = []; - let updateOrganized: boolean | undefined; - let first = true; - - state.forEach((scene: GQL.SlimSceneDataFragment) => { - const sceneRating = scene.rating100; - const sceneStudioID = scene?.studio?.id; - const scenePerformerIDs = (scene.performers ?? []) - .map((p) => p.id) - .sort(); - const sceneTagIDs = (scene.tags ?? []).map((p) => p.id).sort(); - const sceneGroupIDs = (scene.groups ?? []).map((m) => m.group.id).sort(); - - if (first) { - updateRating = sceneRating ?? undefined; - updateStudioID = sceneStudioID; - updatePerformerIds = scenePerformerIDs; - updateTagIds = sceneTagIDs; - updateGroupIds = sceneGroupIDs; - first = false; - updateOrganized = scene.organized; - } else { - if (sceneRating !== updateRating) { - updateRating = undefined; - } - if (sceneStudioID !== updateStudioID) { - updateStudioID = undefined; - } - if (!isEqual(scenePerformerIDs, updatePerformerIds)) { - updatePerformerIds = []; - } - if (!isEqual(sceneTagIDs, updateTagIds)) { - updateTagIds = []; - } - if (!isEqual(sceneGroupIDs, updateGroupIds)) { - updateGroupIds = []; - } - if (scene.organized !== updateOrganized) { - updateOrganized = undefined; - } - } - }); - - setRating(updateRating); - setStudioId(updateStudioID); - setExistingPerformerIds(updatePerformerIds); - setExistingTagIds(updateTagIds); - setExistingGroupIds(updateGroupIds); - setOrganized(updateOrganized); - }, [props.selected]); - - useEffect(() => { - if (checkboxRef.current) { - checkboxRef.current.indeterminate = organized === undefined; - } - }, [organized, checkboxRef]); - - function renderMultiSelect( - type: "performers" | "tags" | "groups", - ids: string[] | undefined - ) { - let mode = GQL.BulkUpdateIdMode.Add; - let existingIds: string[] | undefined = []; - switch (type) { - case "performers": - mode = performerMode; - existingIds = existingPerformerIds; - break; - case "tags": - mode = tagMode; - existingIds = existingTagIds; - break; - case "groups": - mode = groupMode; - existingIds = existingGroupIds; - break; - } - - return ( - { - switch (type) { - case "performers": - setPerformerIds(itemIDs); - break; - case "tags": - setTagIds(itemIDs); - break; - case "groups": - setGroupIds(itemIDs); - break; - } - }} - onSetMode={(newMode) => { - switch (type) { - case "performers": - setPerformerMode(newMode); - break; - case "tags": - setTagMode(newMode); - break; - case "groups": - setGroupMode(newMode); - break; - } - }} - ids={ids ?? []} - existingIds={existingIds ?? []} - mode={mode} - menuPortalTarget={document.body} - /> - ); - } - - function cycleOrganized() { - if (organized) { - setOrganized(undefined); - } else if (organized === undefined) { - setOrganized(false); - } else { - setOrganized(true); - } - } - function render() { return ( = ( onClick: onSave, text: intl.formatMessage({ id: "actions.apply" }), }} + disabled={isUpdating || !!dateError} cancel={{ onClick: () => props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), @@ -266,62 +163,121 @@ export const EditScenesDialog: React.FC = ( isRunning={isUpdating} >
        - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "rating" }), - })} - - setRating(value ?? undefined)} - disabled={isUpdating} - /> - - - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "studio" }), - })} - - - setStudioId(items.length > 0 ? items[0]?.id : undefined) - } - ids={studioId ? [studioId] : []} - isDisabled={isUpdating} - menuPortalTarget={document.body} - /> - - + + + setUpdateField({ rating100: value ?? undefined }) + } + disabled={isUpdating} + /> + - - - - - {renderMultiSelect("performers", performerIds)} - + + setUpdateField({ code: newValue })} + unsetDisabled={unsetDisabled} + /> + - - - - - {renderMultiSelect("tags", tagIds)} - + + setUpdateField({ date: newValue })} + unsetDisabled={unsetDisabled} + error={dateError} + /> + - - - - - {renderMultiSelect("groups", groupIds)} - + + + setUpdateField({ director: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + + setUpdateField({ + studio_id: items.length > 0 ? items[0]?.id : undefined, + }) + } + ids={updateInput.studio_id ? [updateInput.studio_id] : []} + isDisabled={isUpdating} + menuPortalTarget={document.body} + /> + + + + { + setPerformerIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setPerformerIds((c) => ({ ...c, mode: newMode })); + }} + ids={performerIds.ids ?? []} + existingIds={aggregateState.performerIds} + mode={performerIds.mode} + menuPortalTarget={document.body} + /> + + + + { + setGroupIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setGroupIds((c) => ({ ...c, mode: newMode })); + }} + ids={groupIds.ids ?? []} + existingIds={aggregateState.groupIds} + mode={groupIds.mode} + menuPortalTarget={document.body} + /> + + + + { + setTagIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setTagIds((c) => ({ ...c, mode: newMode })); + }} + ids={tagIds.ids ?? []} + existingIds={aggregateState.tagIds} + mode={tagIds.mode} + menuPortalTarget={document.body} + /> + + + + setUpdateField({ details: newValue })} + unsetDisabled={unsetDisabled} + as="textarea" + /> + - cycleOrganized()} + setChecked={(checked) => setUpdateField({ organized: checked })} + checked={updateInput.organized ?? undefined} />
        diff --git a/ui/v2.5/src/components/Shared/BulkUpdate.tsx b/ui/v2.5/src/components/Shared/BulkUpdate.tsx new file mode 100644 index 000000000..8a1b7c884 --- /dev/null +++ b/ui/v2.5/src/components/Shared/BulkUpdate.tsx @@ -0,0 +1,89 @@ +import { faBan } from "@fortawesome/free-solid-svg-icons"; +import React from "react"; +import { + Button, + Col, + Form, + FormControlProps, + InputGroup, + Row, +} from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; +import { Icon } from "./Icon"; +import * as FormUtils from "src/utils/form"; + +interface IBulkUpdateTextInputProps extends Omit { + valueChanged: (value: string | null | undefined) => void; + value: string | null | undefined; + unsetDisabled?: boolean; + as?: React.ElementType; +} + +export const BulkUpdateTextInput: React.FC = ({ + valueChanged, + unsetDisabled, + ...props +}) => { + const intl = useIntl(); + + const value = props.value === null ? "" : props.value ?? undefined; + const unset = value === undefined; + + const placeholderValue = unset + ? `<${intl.formatMessage({ id: "existing_value" })}>` + : value === "" + ? `<${intl.formatMessage({ id: "empty_value" })}>` + : undefined; + + return ( + + valueChanged(event.currentTarget.value)} + /> + + {!unsetDisabled ? ( + + ) : undefined} + + + ); +}; + +export const BulkUpdateFormGroup: React.FC<{ + name: string; + messageId?: string; + inline?: boolean; +}> = ({ name, messageId = name, inline = true, children }) => { + if (inline) { + return ( + + {FormUtils.renderLabel({ + title: , + })} + {children} + + ); + } + + return ( + + + + + {children} + + ); +}; diff --git a/ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx b/ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx deleted file mode 100644 index cf78798e1..000000000 --- a/ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { faBan } from "@fortawesome/free-solid-svg-icons"; -import React from "react"; -import { Button, Form, FormControlProps, InputGroup } from "react-bootstrap"; -import { useIntl } from "react-intl"; -import { Icon } from "./Icon"; - -interface IBulkUpdateTextInputProps extends FormControlProps { - valueChanged: (value: string | undefined) => void; - unsetDisabled?: boolean; - as?: React.ElementType; -} - -export const BulkUpdateTextInput: React.FC = ({ - valueChanged, - unsetDisabled, - ...props -}) => { - const intl = useIntl(); - - const unsetClassName = props.value === undefined ? "unset" : ""; - - return ( - - ` - : undefined - } - onChange={(event) => valueChanged(event.currentTarget.value)} - /> - {!unsetDisabled ? ( - - ) : undefined} - - ); -}; diff --git a/ui/v2.5/src/components/Shared/DateInput.tsx b/ui/v2.5/src/components/Shared/DateInput.tsx index 15a0f1123..4bb39ac39 100644 --- a/ui/v2.5/src/components/Shared/DateInput.tsx +++ b/ui/v2.5/src/components/Shared/DateInput.tsx @@ -8,14 +8,20 @@ import { Icon } from "./Icon"; import "react-datepicker/dist/react-datepicker.css"; import { useIntl } from "react-intl"; import { PatchComponent } from "src/patch"; +import { faBan, faTimes } from "@fortawesome/free-solid-svg-icons"; interface IProps { + groupClassName?: string; + className?: string; disabled?: boolean; value: string; isTime?: boolean; onValueChange(value: string): void; placeholder?: string; + placeholderOverride?: string; error?: string; + appendBefore?: React.ReactNode; + appendAfter?: React.ReactNode; } const ShowPickerButton = forwardRef< @@ -32,6 +38,11 @@ const ShowPickerButton = forwardRef< const _DateInput: React.FC = (props: IProps) => { const intl = useIntl(); + const { + groupClassName = "date-input-group", + className = "date-input text-input", + } = props; + const date = useMemo(() => { const toDate = props.isTime ? TextUtils.stringToFuzzyDateTime @@ -70,34 +81,108 @@ const _DateInput: React.FC = (props: IProps) => { } } - const placeholderText = intl.formatMessage({ + const formatHint = intl.formatMessage({ id: props.isTime ? "datetime_format" : "date_format", }); + const placeholderText = props.placeholder + ? `${props.placeholder} (${formatHint})` + : formatHint; + return ( -
        - - props.onValueChange(e.currentTarget.value)} - placeholder={ - !props.disabled - ? props.placeholder - ? `${props.placeholder} (${placeholderText})` - : placeholderText - : undefined - } - isInvalid={!!props.error} - /> - {maybeRenderButton()} - - {props.error} - - -
        + + props.onValueChange(e.currentTarget.value)} + placeholder={ + !props.disabled + ? props.placeholderOverride ?? placeholderText + : undefined + } + isInvalid={!!props.error} + /> + + {props.appendBefore} + {maybeRenderButton()} + {props.appendAfter} + + + {props.error} + + ); }; export const DateInput = PatchComponent("DateInput", _DateInput); + +interface IBulkUpdateDateInputProps + extends Omit { + value: string | null | undefined; + valueChanged: (value: string | null | undefined) => void; + unsetDisabled?: boolean; + as?: React.ElementType; + error?: string; +} + +export const BulkUpdateDateInput: React.FC = ({ + valueChanged, + unsetDisabled, + ...props +}) => { + const intl = useIntl(); + + const unset = props.value === undefined; + + const unsetButton = !unsetDisabled ? ( + + ) : undefined; + + const clearButton = + props.value !== null ? ( + + ) : undefined; + + const placeholderValue = + props.value === null + ? `<${intl.formatMessage({ id: "empty_value" })}>` + : props.value === undefined + ? `<${intl.formatMessage({ id: "existing_value" })}>` + : undefined; + + function outValue(v: string | undefined) { + if (v === "") { + return null; + } + + return v; + } + + return ( + valueChanged(outValue(v))} + groupClassName="bulk-update-date-input" + className="date-input text-input" + appendBefore={clearButton} + appendAfter={unsetButton} + /> + ); +}; diff --git a/ui/v2.5/src/components/Shared/MultiSet.tsx b/ui/v2.5/src/components/Shared/MultiSet.tsx index 6be85b8b3..8f16bd716 100644 --- a/ui/v2.5/src/components/Shared/MultiSet.tsx +++ b/ui/v2.5/src/components/Shared/MultiSet.tsx @@ -12,9 +12,10 @@ import { PerformerIDSelect } from "../Performers/PerformerSelect"; import { StudioIDSelect } from "../Studios/StudioSelect"; import { TagIDSelect } from "../Tags/TagSelect"; import { GroupIDSelect } from "../Groups/GroupSelect"; +import { SceneIDSelect } from "../Scenes/SceneSelect"; interface IMultiSetProps { - type: "performers" | "studios" | "tags" | "groups" | "galleries"; + type: "performers" | "studios" | "tags" | "groups" | "galleries" | "scenes"; existingIds?: string[]; ids?: string[]; mode: GQL.BulkUpdateIdMode; @@ -89,6 +90,17 @@ const Select: React.FC = (props) => { menuPortalTarget={props.menuPortalTarget} /> ); + case "scenes": + return ( + + ); default: return ( = ( mode: GQL.BulkUpdateIdMode.Add, }); + const unsetDisabled = props.selected.length < 2; + const [updateStudios] = useBulkStudioUpdate(); // Network state @@ -126,27 +127,6 @@ export const EditStudiosDialog: React.FC = ( setIsUpdating(false); } - function renderTextField( - name: string, - value: string | undefined | null, - setter: (newValue: string | undefined) => void, - area: boolean = false - ) { - return ( - - - - - setter(newValue)} - unsetDisabled={props.selected.length < 2} - as={area ? "textarea" : undefined} - /> - - ); - } - function render() { return ( = ( show icon={faPencilAlt} header={intl.formatMessage( - { id: "actions.edit_entity" }, - { entityType: intl.formatMessage({ id: "studios" }) } + { id: "dialogs.edit_entity_count_title" }, + { + count: props?.selected?.length ?? 1, + singularEntity: intl.formatMessage({ id: "studio" }), + pluralEntity: intl.formatMessage({ id: "studios" }), + } )} accept={{ onClick: onSave, @@ -168,11 +152,8 @@ export const EditStudiosDialog: React.FC = ( }} isRunning={isUpdating} > - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "parent_studio" }), - })} - +
        + setUpdateField({ @@ -183,13 +164,8 @@ export const EditStudiosDialog: React.FC = ( isDisabled={isUpdating} menuPortalTarget={document.body} /> - - - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "rating" }), - })} - + + @@ -197,9 +173,8 @@ export const EditStudiosDialog: React.FC = ( } disabled={isUpdating} /> - - - + + setUpdateField({ favorite: checked })} @@ -208,30 +183,31 @@ export const EditStudiosDialog: React.FC = ( /> - - - - + setTagIds((v) => ({ ...v, ids: itemIDs }))} - onSetMode={(newMode) => - setTagIds((v) => ({ ...v, mode: newMode })) - } - existingIds={aggregateState.tagIds ?? []} + onUpdate={(itemIDs) => { + setTagIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setTagIds((c) => ({ ...c, mode: newMode })); + }} ids={tagIds.ids ?? []} + existingIds={aggregateState.tagIds} mode={tagIds.mode} menuPortalTarget={document.body} /> - + - {renderTextField( - "details", - updateInput.details, - (newValue) => setUpdateField({ details: newValue }), - true - )} + + setUpdateField({ details: newValue })} + unsetDisabled={unsetDisabled} + as="textarea" + /> + = ( const [updateInput, setUpdateInput] = useState({}); + const unsetDisabled = props.selected.length < 2; + const [updateTags] = useBulkTagUpdate(getTagInput()); // Network state @@ -153,33 +155,18 @@ export const EditTagsDialog: React.FC = ( setUpdateInput(updateState); }, [props.selected]); - function renderTextField( - name: string, - value: string | undefined | null, - setter: (newValue: string | undefined) => void - ) { - return ( - - - - - setter(newValue)} - unsetDisabled={props.selected.length < 2} - /> - - ); - } - return ( = ( /> - {renderTextField("description", updateInput.description, (v) => - setUpdateField({ description: v }) - )} + + + setUpdateField({ description: newValue }) + } + unsetDisabled={unsetDisabled} + as="textarea" + /> + }, }); -export const useBulkSceneUpdate = (input: GQL.BulkSceneUpdateInput) => +export const useBulkSceneUpdate = () => GQL.useBulkSceneUpdateMutation({ - variables: { input }, update(cache, result) { if (!result.data?.bulkSceneUpdate) return; @@ -1403,9 +1402,8 @@ export const useGroupUpdate = () => }, }); -export const useBulkGroupUpdate = (input: GQL.BulkGroupUpdateInput) => +export const useBulkGroupUpdate = () => GQL.useBulkGroupUpdateMutation({ - variables: { input }, update(cache, result) { if (!result.data?.bulkGroupUpdate) return; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 7b4091f8b..3c3fd4f28 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -985,6 +985,7 @@ "delete_object_title": "Delete {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "dont_show_until_updated": "Don't show until next update", "edit_entity_title": "Edit {count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "edit_entity_count_title": "Edit {count} {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "export_include_related_objects": "Include related objects in export", "export_title": "Export", "imagewall": { @@ -1147,6 +1148,7 @@ "warmth": "Warmth" }, "empty_server": "Add some scenes to your server to view recommendations on this page.", + "empty_value": "empty", "errors": { "custom_fields": { "duplicate_field": "Field name must be unique", diff --git a/ui/v2.5/src/utils/bulkUpdate.ts b/ui/v2.5/src/utils/bulkUpdate.ts index 1ded76c27..c667b231b 100644 --- a/ui/v2.5/src/utils/bulkUpdate.ts +++ b/ui/v2.5/src/utils/bulkUpdate.ts @@ -81,6 +81,11 @@ export function getAggregateTagIds(state: { tags: IHasID[] }[]) { return getAggregateIds(sortedLists); } +export function getAggregateSceneIds(state: { scenes: IHasID[] }[]) { + const sortedLists = state.map((o) => o.scenes.map((oo) => oo.id).sort()); + return getAggregateIds(sortedLists); +} + interface IGroup { group: IHasID; } diff --git a/ui/v2.5/src/utils/form.tsx b/ui/v2.5/src/utils/form.tsx index fbf239a9b..7c804e221 100644 --- a/ui/v2.5/src/utils/form.tsx +++ b/ui/v2.5/src/utils/form.tsx @@ -33,7 +33,7 @@ function getLabelProps(labelProps?: FormLabelProps) { } export function renderLabel(options: { - title: string; + title: React.ReactNode; labelProps?: FormLabelProps; }) { return ( diff --git a/ui/v2.5/src/utils/yup.ts b/ui/v2.5/src/utils/yup.ts index a9c4f69e1..912886858 100644 --- a/ui/v2.5/src/utils/yup.ts +++ b/ui/v2.5/src/utils/yup.ts @@ -92,6 +92,37 @@ export function yupUniqueStringList(intl: IntlShape) { }); } +export function validateDateString(value?: string) { + if (!value) return true; + // Allow YYYY, YYYY-MM, or YYYY-MM-DD formats + if (!value.match(/^\d{4}(-\d{2}(-\d{2})?)?$/)) return false; + // Validate the date components + const parts = value.split("-"); + const year = parseInt(parts[0], 10); + if (year < 1 || year > 9999) return false; + if (parts.length >= 2) { + const month = parseInt(parts[1], 10); + if (month < 1 || month > 12) return false; + } + if (parts.length === 3) { + const day = parseInt(parts[2], 10); + if (day < 1 || day > 31) return false; + // Full date - validate it parses correctly + if (Number.isNaN(Date.parse(value))) return false; + } + return true; +} + +export function getDateError( + value: string | undefined | null, + intl: IntlShape +) { + if (validateDateString(value ?? "")) return undefined; + return intl + .formatMessage({ id: "validation.date_invalid_form" }) + .replace("${path}", intl.formatMessage({ id: "date" })); +} + export function yupDateString(intl: IntlShape) { return yup .string() @@ -99,24 +130,7 @@ export function yupDateString(intl: IntlShape) { .test({ name: "date", test(value) { - if (!value) return true; - // Allow YYYY, YYYY-MM, or YYYY-MM-DD formats - if (!value.match(/^\d{4}(-\d{2}(-\d{2})?)?$/)) return false; - // Validate the date components - const parts = value.split("-"); - const year = parseInt(parts[0], 10); - if (year < 1 || year > 9999) return false; - if (parts.length >= 2) { - const month = parseInt(parts[1], 10); - if (month < 1 || month > 12) return false; - } - if (parts.length === 3) { - const day = parseInt(parts[2], 10); - if (day < 1 || day > 31) return false; - // Full date - validate it parses correctly - if (Number.isNaN(Date.parse(value))) return false; - } - return true; + return validateDateString(value); }, message: intl.formatMessage({ id: "validation.date_invalid_form" }), }); From b4fab0ac48732b3e3cb20d571f6fd8a0edac120d Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:34:57 -0700 Subject: [PATCH 122/177] Add parent tag hierarchy support to tag tagger (#6620) --- graphql/schema/types/scraper.graphql | 1 + graphql/stash-box/query.graphql | 5 + internal/manager/manager_tasks.go | 6 +- internal/manager/task_stash_box_tag.go | 42 ++- pkg/match/scraped.go | 14 + pkg/models/model_scraped_item.go | 28 +- pkg/stashbox/graphql/generated_client.go | 215 ++++++++++++- pkg/stashbox/tag.go | 7 + ui/v2.5/graphql/data/scrapers.graphql | 5 + ui/v2.5/src/components/Shared/BatchModals.tsx | 242 ++++++++++++++ ui/v2.5/src/components/Tagger/constants.ts | 4 +- .../Tagger/studios/StudioTagger.tsx | 266 ++-------------- ui/v2.5/src/components/Tagger/styles.scss | 6 +- .../Tagger/tags/StashSearchResult.tsx | 59 +++- .../src/components/Tagger/tags/TagModal.tsx | 150 ++++++++- .../src/components/Tagger/tags/TagTagger.tsx | 295 +++++------------- ui/v2.5/src/locales/en-GB.json | 6 + 17 files changed, 867 insertions(+), 484 deletions(-) create mode 100644 ui/v2.5/src/components/Shared/BatchModals.tsx diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index b8810aa79..fafd928f7 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -73,6 +73,7 @@ type ScrapedTag { name: String! description: String alias_list: [String!] + parent: ScrapedTag "Remote site ID, if applicable" remote_site_id: String } diff --git a/graphql/stash-box/query.graphql b/graphql/stash-box/query.graphql index edd44c835..ebaf05648 100644 --- a/graphql/stash-box/query.graphql +++ b/graphql/stash-box/query.graphql @@ -31,6 +31,11 @@ fragment TagFragment on Tag { id description aliases + category { + id + name + description + } } fragment MeasurementsFragment on Measurements { diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index c9e840519..e3529c0b8 100644 --- a/internal/manager/manager_tasks.go +++ b/internal/manager/manager_tasks.go @@ -431,7 +431,7 @@ type StashBoxBatchTagInput struct { ExcludeFields []string `json:"exclude_fields"` // Refresh items already tagged by StashBox if true. Only tag items with no StashBox tagging if false Refresh bool `json:"refresh"` - // If batch adding studios, should their parent studios also be created? + // If batch adding studios or tags, should their parent entities also be created? CreateParent bool `json:"createParent"` // IDs in stash of the items to update. // If set, names and stash_ids fields will be ignored. @@ -749,6 +749,7 @@ func (s *Manager) batchTagTagsByIds(ctx context.Context, input StashBoxBatchTagI if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) { tasks = append(tasks, &stashBoxBatchTagTagTask{ tag: t, + createParent: input.CreateParent, box: box, excludedFields: input.ExcludeFields, }) @@ -769,6 +770,7 @@ func (s *Manager) batchTagTagsByNamesOrStashIds(input StashBoxBatchTagInput, box if len(stashID) > 0 { tasks = append(tasks, &stashBoxBatchTagTagTask{ stashID: &stashID, + createParent: input.CreateParent, box: box, excludedFields: input.ExcludeFields, }) @@ -780,6 +782,7 @@ func (s *Manager) batchTagTagsByNamesOrStashIds(input StashBoxBatchTagInput, box if len(name) > 0 { tasks = append(tasks, &stashBoxBatchTagTagTask{ name: &name, + createParent: input.CreateParent, box: box, excludedFields: input.ExcludeFields, }) @@ -806,6 +809,7 @@ func (s *Manager) batchTagAllTags(ctx context.Context, input StashBoxBatchTagInp for _, t := range tags { tasks = append(tasks, &stashBoxBatchTagTagTask{ tag: t, + createParent: input.CreateParent, box: box, excludedFields: input.ExcludeFields, }) diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index 97c766010..ec17fac06 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -541,6 +541,7 @@ type stashBoxBatchTagTagTask struct { name *string stashID *string tag *models.Tag + createParent bool excludedFields []string } @@ -630,7 +631,7 @@ func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models. result := results[0] if err := r.WithReadTxn(ctx, func(ctx context.Context) error { - return match.ScrapedTag(ctx, r.Tag, result, t.box.Endpoint) + return match.ScrapedTagHierarchy(ctx, r.Tag, result, t.box.Endpoint) }); err != nil { return nil, err } @@ -638,6 +639,39 @@ func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models. return result, nil } +func (t *stashBoxBatchTagTagTask) processParentTag(ctx context.Context, parent *models.ScrapedTag, excluded map[string]bool) error { + if parent.StoredID == nil { + // Create new parent tag + newParentTag := parent.ToTag(t.box.Endpoint, excluded) + + r := instance.Repository + err := r.WithTxn(ctx, func(ctx context.Context) error { + qb := r.Tag + + if err := tag.ValidateCreate(ctx, *newParentTag, qb); err != nil { + return err + } + + if err := qb.Create(ctx, &models.CreateTagInput{Tag: newParentTag}); err != nil { + return err + } + + storedID := strconv.Itoa(newParentTag.ID) + parent.StoredID = &storedID + return nil + }) + if err != nil { + logger.Errorf("Failed to create parent tag %s: %v", parent.Name, err) + } else { + logger.Infof("Created parent tag %s", parent.Name) + } + return err + } + + // Parent already exists — nothing to update for categories + return nil +} + func (t *stashBoxBatchTagTagTask) processMatchedTag(ctx context.Context, s *models.ScrapedTag, excluded map[string]bool) { // Determine the tag ID to update — either from the task's tag or from the // StoredID set by match.ScrapedTag (when batch adding by name and the tag @@ -649,6 +683,12 @@ func (t *stashBoxBatchTagTagTask) processMatchedTag(ctx context.Context, s *mode tagID, _ = strconv.Atoi(*s.StoredID) } + if s.Parent != nil && t.createParent { + if err := t.processParentTag(ctx, s.Parent, excluded); err != nil { + return + } + } + if tagID > 0 { r := instance.Repository err := r.WithTxn(ctx, func(ctx context.Context) error { diff --git a/pkg/match/scraped.go b/pkg/match/scraped.go index d3039f4c6..a6683ff52 100644 --- a/pkg/match/scraped.go +++ b/pkg/match/scraped.go @@ -188,6 +188,20 @@ func ScrapedGroup(ctx context.Context, qb GroupNamesFinder, storedID *string, na return } +// ScrapedTagHierarchy executes ScrapedTag for the provided tag and its parent. +func ScrapedTagHierarchy(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error { + if err := ScrapedTag(ctx, qb, s, stashBoxEndpoint); err != nil { + return err + } + + if s.Parent == nil { + return nil + } + + // Match parent by name only (categories don't have StashDB tag IDs) + return ScrapedTag(ctx, qb, s.Parent, "") +} + // ScrapedTag matches the provided tag with the tags // in the database and sets the ID field if one is found. func ScrapedTag(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error { diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 1367003cb..1a64d0849 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -471,11 +471,12 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, type ScrapedTag struct { // Set if tag matched - StoredID *string `json:"stored_id"` - Name string `json:"name"` - Description *string `json:"description"` - AliasList []string `json:"alias_list"` - RemoteSiteID *string `json:"remote_site_id"` + StoredID *string `json:"stored_id"` + Name string `json:"name"` + Description *string `json:"description"` + AliasList []string `json:"alias_list"` + RemoteSiteID *string `json:"remote_site_id"` + Parent *ScrapedTag `json:"parent"` } func (ScrapedTag) IsScrapedContent() {} @@ -496,6 +497,13 @@ func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag { ret.Aliases = NewRelatedStrings(t.AliasList) } + if t.Parent != nil && t.Parent.StoredID != nil { + parentID, err := strconv.Atoi(*t.Parent.StoredID) + if err == nil && parentID > 0 { + ret.ParentIDs = NewRelatedIDs([]int{parentID}) + } + } + if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" { ret.StashIDs = NewRelatedStashIDs([]StashID{ { @@ -527,6 +535,16 @@ func (t *ScrapedTag) ToPartial(storedID string, endpoint string, excluded map[st } } + if t.Parent != nil && t.Parent.StoredID != nil { + parentID, err := strconv.Atoi(*t.Parent.StoredID) + if err == nil && parentID > 0 { + ret.ParentIDs = &UpdateIDs{ + IDs: []int{parentID}, + Mode: RelationshipUpdateModeAdd, + } + } + } + if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" { ret.StashIDs = &UpdateStashIDs{ StashIDs: existingStashIDs, diff --git a/pkg/stashbox/graphql/generated_client.go b/pkg/stashbox/graphql/generated_client.go index acb2202dc..bc9a6ce89 100644 --- a/pkg/stashbox/graphql/generated_client.go +++ b/pkg/stashbox/graphql/generated_client.go @@ -128,10 +128,11 @@ func (t *StudioFragment) GetImages() []*ImageFragment { } type TagFragment struct { - Name string "json:\"name\" graphql:\"name\"" - ID string "json:\"id\" graphql:\"id\"" - Description *string "json:\"description,omitempty\" graphql:\"description\"" - Aliases []string "json:\"aliases\" graphql:\"aliases\"" + Name string "json:\"name\" graphql:\"name\"" + ID string "json:\"id\" graphql:\"id\"" + Description *string "json:\"description,omitempty\" graphql:\"description\"" + Aliases []string "json:\"aliases\" graphql:\"aliases\"" + Category *TagFragment_Category "json:\"category,omitempty\" graphql:\"category\"" } func (t *TagFragment) GetName() string { @@ -158,6 +159,12 @@ func (t *TagFragment) GetAliases() []string { } return t.Aliases } +func (t *TagFragment) GetCategory() *TagFragment_Category { + if t == nil { + t = &TagFragment{} + } + return t.Category +} type MeasurementsFragment struct { BandSize *int "json:\"band_size,omitempty\" graphql:\"band_size\"" @@ -530,6 +537,31 @@ func (t *StudioFragment_Parent) GetName() string { return t.Name } +type TagFragment_Category struct { + Description *string "json:\"description,omitempty\" graphql:\"description\"" + ID string "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" +} + +func (t *TagFragment_Category) GetDescription() *string { + if t == nil { + t = &TagFragment_Category{} + } + return t.Description +} +func (t *TagFragment_Category) GetID() string { + if t == nil { + t = &TagFragment_Category{} + } + return t.ID +} +func (t *TagFragment_Category) GetName() string { + if t == nil { + t = &TagFragment_Category{} + } + return t.Name +} + type SceneFragment_Studio_StudioFragment_Parent struct { ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" @@ -548,6 +580,31 @@ func (t *SceneFragment_Studio_StudioFragment_Parent) GetName() string { return t.Name } +type SceneFragment_Tags_TagFragment_Category struct { + Description *string "json:\"description,omitempty\" graphql:\"description\"" + ID string "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" +} + +func (t *SceneFragment_Tags_TagFragment_Category) GetDescription() *string { + if t == nil { + t = &SceneFragment_Tags_TagFragment_Category{} + } + return t.Description +} +func (t *SceneFragment_Tags_TagFragment_Category) GetID() string { + if t == nil { + t = &SceneFragment_Tags_TagFragment_Category{} + } + return t.ID +} +func (t *SceneFragment_Tags_TagFragment_Category) GetName() string { + if t == nil { + t = &SceneFragment_Tags_TagFragment_Category{} + } + return t.Name +} + type FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent struct { ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" @@ -566,6 +623,31 @@ func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragme return t.Name } +type FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category struct { + Description *string "json:\"description,omitempty\" graphql:\"description\"" + ID string "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" +} + +func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category) GetDescription() *string { + if t == nil { + t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category{} + } + return t.Description +} +func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category) GetID() string { + if t == nil { + t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category{} + } + return t.ID +} +func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category) GetName() string { + if t == nil { + t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category{} + } + return t.Name +} + type SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent struct { ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" @@ -584,6 +666,31 @@ func (t *SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent) Get return t.Name } +type SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category struct { + Description *string "json:\"description,omitempty\" graphql:\"description\"" + ID string "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" +} + +func (t *SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category) GetDescription() *string { + if t == nil { + t = &SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category{} + } + return t.Description +} +func (t *SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category) GetID() string { + if t == nil { + t = &SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category{} + } + return t.ID +} +func (t *SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category) GetName() string { + if t == nil { + t = &SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category{} + } + return t.Name +} + type FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent struct { ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" @@ -602,6 +709,31 @@ func (t *FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent) Get return t.Name } +type FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category struct { + Description *string "json:\"description,omitempty\" graphql:\"description\"" + ID string "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" +} + +func (t *FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category) GetDescription() *string { + if t == nil { + t = &FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category{} + } + return t.Description +} +func (t *FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category) GetID() string { + if t == nil { + t = &FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category{} + } + return t.ID +} +func (t *FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category) GetName() string { + if t == nil { + t = &FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category{} + } + return t.Name +} + type FindStudio_FindStudio_StudioFragment_Parent struct { ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" @@ -620,6 +752,56 @@ func (t *FindStudio_FindStudio_StudioFragment_Parent) GetName() string { return t.Name } +type FindTag_FindTag_TagFragment_Category struct { + Description *string "json:\"description,omitempty\" graphql:\"description\"" + ID string "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" +} + +func (t *FindTag_FindTag_TagFragment_Category) GetDescription() *string { + if t == nil { + t = &FindTag_FindTag_TagFragment_Category{} + } + return t.Description +} +func (t *FindTag_FindTag_TagFragment_Category) GetID() string { + if t == nil { + t = &FindTag_FindTag_TagFragment_Category{} + } + return t.ID +} +func (t *FindTag_FindTag_TagFragment_Category) GetName() string { + if t == nil { + t = &FindTag_FindTag_TagFragment_Category{} + } + return t.Name +} + +type QueryTags_QueryTags_Tags_TagFragment_Category struct { + Description *string "json:\"description,omitempty\" graphql:\"description\"" + ID string "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" +} + +func (t *QueryTags_QueryTags_Tags_TagFragment_Category) GetDescription() *string { + if t == nil { + t = &QueryTags_QueryTags_Tags_TagFragment_Category{} + } + return t.Description +} +func (t *QueryTags_QueryTags_Tags_TagFragment_Category) GetID() string { + if t == nil { + t = &QueryTags_QueryTags_Tags_TagFragment_Category{} + } + return t.ID +} +func (t *QueryTags_QueryTags_Tags_TagFragment_Category) GetName() string { + if t == nil { + t = &QueryTags_QueryTags_Tags_TagFragment_Category{} + } + return t.Name +} + type QueryTags_QueryTags struct { Count int "json:\"count\" graphql:\"count\"" Tags []*TagFragment "json:\"tags\" graphql:\"tags\"" @@ -865,6 +1047,11 @@ fragment TagFragment on Tag { id description aliases + category { + id + name + description + } } fragment PerformerAppearanceFragment on PerformerAppearance { as @@ -1003,6 +1190,11 @@ fragment TagFragment on Tag { id description aliases + category { + id + name + description + } } fragment PerformerAppearanceFragment on PerformerAppearance { as @@ -1299,6 +1491,11 @@ fragment TagFragment on Tag { id description aliases + category { + id + name + description + } } fragment PerformerAppearanceFragment on PerformerAppearance { as @@ -1435,6 +1632,11 @@ fragment TagFragment on Tag { id description aliases + category { + id + name + description + } } ` @@ -1469,6 +1671,11 @@ fragment TagFragment on Tag { id description aliases + category { + id + name + description + } } ` diff --git a/pkg/stashbox/tag.go b/pkg/stashbox/tag.go index 452dd9928..45bcf96c4 100644 --- a/pkg/stashbox/tag.go +++ b/pkg/stashbox/tag.go @@ -72,5 +72,12 @@ func tagFragmentToScrapedTag(t graphql.TagFragment) *models.ScrapedTag { ret.AliasList = t.Aliases } + if t.Category != nil { + ret.Parent = &models.ScrapedTag{ + Name: t.Category.Name, + Description: t.Category.Description, + } + } + return ret } diff --git a/ui/v2.5/graphql/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql index 7214c2064..0dae3c2d5 100644 --- a/ui/v2.5/graphql/data/scrapers.graphql +++ b/ui/v2.5/graphql/data/scrapers.graphql @@ -162,6 +162,11 @@ fragment ScrapedSceneTagData on ScrapedTag { name description alias_list + parent { + stored_id + name + description + } remote_site_id } diff --git a/ui/v2.5/src/components/Shared/BatchModals.tsx b/ui/v2.5/src/components/Shared/BatchModals.tsx new file mode 100644 index 000000000..0de8f5e1f --- /dev/null +++ b/ui/v2.5/src/components/Shared/BatchModals.tsx @@ -0,0 +1,242 @@ +import React, { useMemo, useRef, useState } from "react"; +import { Form } from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; + +import { ModalComponent } from "src/components/Shared/Modal"; +import { faStar, faTags } from "@fortawesome/free-solid-svg-icons"; + +interface IEntityWithStashIDs { + stash_ids: { endpoint: string }[]; +} + +interface IBatchUpdateModalProps { + entities: IEntityWithStashIDs[]; + isIdle: boolean; + selectedEndpoint: { endpoint: string; index: number }; + allCount: number | undefined; + onBatchUpdate: (queryAll: boolean, refresh: boolean) => void; + onRefreshChange?: (refresh: boolean) => void; + batchAddParents: boolean; + setBatchAddParents: (addParents: boolean) => void; + close: () => void; + localePrefix: string; + entityName: string; + countVariableName: string; +} + +export const BatchUpdateModal: React.FC = ({ + entities, + isIdle, + selectedEndpoint, + allCount, + onBatchUpdate, + onRefreshChange, + batchAddParents, + setBatchAddParents, + close, + localePrefix, + entityName, + countVariableName, +}) => { + const intl = useIntl(); + + const [queryAll, setQueryAll] = useState(false); + const [refresh, setRefreshState] = useState(false); + + const setRefresh = (value: boolean) => { + setRefreshState(value); + onRefreshChange?.(value); + }; + + const entityCount = useMemo(() => { + const filteredStashIDs = entities.map((e) => + e.stash_ids.filter((s) => s.endpoint === selectedEndpoint.endpoint) + ); + + return queryAll + ? allCount + : filteredStashIDs.filter((s) => + refresh ? s.length > 0 : s.length === 0 + ).length; + }, [queryAll, refresh, entities, allCount, selectedEndpoint.endpoint]); + + return ( + onBatchUpdate(queryAll, refresh), + }} + cancel={{ + text: intl.formatMessage({ id: "actions.cancel" }), + variant: "danger", + onClick: () => close(), + }} + disabled={!isIdle} + > + + +
        + +
        +
        + } + checked={!queryAll} + onChange={() => setQueryAll(false)} + /> + setQueryAll(true)} + /> +
        + + +
        + +
        +
        + setRefresh(false)} + /> + + + + setRefresh(true)} + /> + + + +
        +
        + setBatchAddParents(!batchAddParents)} + /> +
        + + + +
        + ); +}; + +interface IBatchAddModalProps { + isIdle: boolean; + onBatchAdd: (input: string) => void; + batchAddParents: boolean; + setBatchAddParents: (addParents: boolean) => void; + close: () => void; + localePrefix: string; + entityName: string; +} + +export const BatchAddModal: React.FC = ({ + isIdle, + onBatchAdd, + batchAddParents, + setBatchAddParents, + close, + localePrefix, + entityName, +}) => { + const intl = useIntl(); + + const inputRef = useRef(null); + + return ( + { + if (inputRef.current) { + onBatchAdd(inputRef.current.value); + } else { + close(); + } + }, + }} + cancel={{ + text: intl.formatMessage({ id: "actions.cancel" }), + variant: "danger", + onClick: () => close(), + }} + disabled={!isIdle} + > + + + + +
        + setBatchAddParents(!batchAddParents)} + /> +
        +
        + ); +}; diff --git a/ui/v2.5/src/components/Tagger/constants.ts b/ui/v2.5/src/components/Tagger/constants.ts index af9afcefb..646dbf4c3 100644 --- a/ui/v2.5/src/components/Tagger/constants.ts +++ b/ui/v2.5/src/components/Tagger/constants.ts @@ -38,6 +38,7 @@ export const initialConfig: ITaggerConfig = { excludedStudioFields: DEFAULT_EXCLUDED_STUDIO_FIELDS, excludedTagFields: DEFAULT_EXCLUDED_TAG_FIELDS, createParentStudios: true, + createParentTags: true, }; export type ParseMode = "auto" | "filename" | "dir" | "path" | "metadata"; @@ -56,6 +57,7 @@ export interface ITaggerConfig { excludedStudioFields?: string[]; excludedTagFields?: string[]; createParentStudios: boolean; + createParentTags: boolean; } export const PERFORMER_FIELDS = [ @@ -85,4 +87,4 @@ export const PERFORMER_FIELDS = [ ]; export const STUDIO_FIELDS = ["name", "image", "url", "parent_studio"]; -export const TAG_FIELDS = ["name", "description", "aliases"]; +export const TAG_FIELDS = ["name", "description", "aliases", "parent_tags"]; diff --git a/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx b/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx index 64bb99b72..adc58cc04 100644 --- a/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx +++ b/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useEffect, useState } from "react"; import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { Link } from "react-router-dom"; @@ -6,7 +6,6 @@ import { HashLink } from "react-router-hash-link"; import * as GQL from "src/core/generated-graphql"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; -import { ModalComponent } from "src/components/Shared/Modal"; import { stashBoxStudioQuery, useJobsSubscribe, @@ -25,11 +24,15 @@ import { ITaggerConfig, STUDIO_FIELDS } from "../constants"; import StudioModal from "../scenes/StudioModal"; import { useUpdateStudio } from "../queries"; import { apolloError } from "src/utils"; -import { faStar, faTags } from "@fortawesome/free-solid-svg-icons"; +import { faTags } from "@fortawesome/free-solid-svg-icons"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { mergeStudioStashIDs } from "../utils"; import { separateNamesAndStashIds } from "src/utils/stashIds"; import { useTaggerConfig } from "../config"; +import { + BatchUpdateModal, + BatchAddModal, +} from "src/components/Shared/BatchModals"; type JobFragment = Pick< GQL.Job, @@ -38,232 +41,6 @@ type JobFragment = Pick< const CLASSNAME = "StudioTagger"; -interface IStudioBatchUpdateModal { - studios: GQL.StudioDataFragment[]; - isIdle: boolean; - selectedEndpoint: { endpoint: string; index: number }; - onBatchUpdate: (queryAll: boolean, refresh: boolean) => void; - batchAddParents: boolean; - setBatchAddParents: (addParents: boolean) => void; - close: () => void; -} - -const StudioBatchUpdateModal: React.FC = ({ - studios, - isIdle, - selectedEndpoint, - onBatchUpdate, - batchAddParents, - setBatchAddParents, - close, -}) => { - const intl = useIntl(); - - const [queryAll, setQueryAll] = useState(false); - - const [refresh, setRefresh] = useState(false); - const { data: allStudios } = GQL.useFindStudiosQuery({ - variables: { - studio_filter: { - stash_id_endpoint: { - endpoint: selectedEndpoint.endpoint, - modifier: refresh - ? GQL.CriterionModifier.NotNull - : GQL.CriterionModifier.IsNull, - }, - }, - filter: { - per_page: 0, - }, - }, - }); - - const studioCount = useMemo(() => { - // get all stash ids for the selected endpoint - const filteredStashIDs = studios.map((p) => - p.stash_ids.filter((s) => s.endpoint === selectedEndpoint.endpoint) - ); - - return queryAll - ? allStudios?.findStudios.count - : filteredStashIDs.filter((s) => - // if refresh, then we filter out the studios without a stash id - // otherwise, we want untagged studios, filtering out those with a stash id - refresh ? s.length > 0 : s.length === 0 - ).length; - }, [queryAll, refresh, studios, allStudios, selectedEndpoint.endpoint]); - - return ( - onBatchUpdate(queryAll, refresh), - }} - cancel={{ - text: intl.formatMessage({ id: "actions.cancel" }), - variant: "danger", - onClick: () => close(), - }} - disabled={!isIdle} - > - - -
        - -
        -
        - } - checked={!queryAll} - onChange={() => setQueryAll(false)} - /> - setQueryAll(true)} - /> -
        - - -
        - -
        -
        - setRefresh(false)} - /> - - - - setRefresh(true)} - /> - - - -
        - setBatchAddParents(!batchAddParents)} - /> -
        -
        - - - -
        - ); -}; - -interface IStudioBatchAddModal { - isIdle: boolean; - onBatchAdd: (input: string) => void; - batchAddParents: boolean; - setBatchAddParents: (addParents: boolean) => void; - close: () => void; -} - -const StudioBatchAddModal: React.FC = ({ - isIdle, - onBatchAdd, - batchAddParents, - setBatchAddParents, - close, -}) => { - const intl = useIntl(); - - const studioInput = useRef(null); - - return ( - { - if (studioInput.current) { - onBatchAdd(studioInput.current.value); - } else { - close(); - } - }, - }} - cancel={{ - text: intl.formatMessage({ id: "actions.cancel" }), - variant: "danger", - onClick: () => close(), - }} - disabled={!isIdle} - > - - - - -
        - setBatchAddParents(!batchAddParents)} - /> -
        -
        - ); -}; - interface IStudioTaggerListProps { studios: GQL.StudioDataFragment[]; selectedEndpoint: { endpoint: string; index: number }; @@ -305,6 +82,24 @@ const StudioTaggerList: React.FC = ({ config.createParentStudios || false ); + const [batchUpdateRefresh, setBatchUpdateRefresh] = useState(false); + const { data: allStudios } = GQL.useFindStudiosQuery({ + skip: !showBatchUpdate, + variables: { + studio_filter: { + stash_id_endpoint: { + endpoint: selectedEndpoint.endpoint, + modifier: batchUpdateRefresh + ? GQL.CriterionModifier.NotNull + : GQL.CriterionModifier.IsNull, + }, + }, + filter: { + per_page: 0, + }, + }, + }); + const [error, setError] = useState< Record >({}); @@ -630,24 +425,31 @@ const StudioTaggerList: React.FC = ({ return ( {showBatchUpdate && ( - setShowBatchUpdate(false)} isIdle={isIdle} selectedEndpoint={selectedEndpoint} - studios={studios} + entities={studios} + allCount={allStudios?.findStudios.count} onBatchUpdate={handleBatchUpdate} + onRefreshChange={setBatchUpdateRefresh} batchAddParents={batchAddParents} setBatchAddParents={setBatchAddParents} + localePrefix="studio_tagger" + entityName="studio" + countVariableName="studio_count" /> )} {showBatchAdd && ( - setShowBatchAdd(false)} isIdle={isIdle} onBatchAdd={handleBatchAdd} batchAddParents={batchAddParents} setBatchAddParents={setBatchAddParents} + localePrefix="studio_tagger" + entityName="studio" /> )}
        diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index 5f6ece37d..1c05e574f 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -287,7 +287,8 @@ } } -.StudioTagger { +.StudioTagger, +.TagTagger { display: flex; flex-wrap: wrap; justify-content: center; @@ -342,7 +343,8 @@ vertical-align: bottom; } - &-studio-search { + &-studio-search, + &-tag-search { display: flex; flex-wrap: wrap; diff --git a/ui/v2.5/src/components/Tagger/tags/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/tags/StashSearchResult.tsx index cd6abca02..55b86c931 100644 --- a/ui/v2.5/src/components/Tagger/tags/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/tags/StashSearchResult.tsx @@ -7,6 +7,8 @@ import TagModal from "./TagModal"; import { faTags } from "@fortawesome/free-solid-svg-icons"; import { useIntl } from "react-intl"; import { mergeTagStashIDs } from "../utils"; +import { useTagCreate } from "src/core/StashService"; +import { apolloError } from "src/utils"; interface IStashSearchResultProps { tag: GQL.TagListDataFragment; @@ -34,13 +36,49 @@ const StashSearchResult: React.FC = ({ {} ); + const [createTag] = useTagCreate(); const updateTag = useUpdateTag(); - const handleSave = async (input: GQL.TagCreateInput) => { + function handleSaveError(name: string, message: string) { + setError({ + message: intl.formatMessage( + { id: "tag_tagger.failed_to_save_tag" }, + { tag: name } + ), + details: + message === "UNIQUE constraint failed: tags.name" + ? intl.formatMessage({ + id: "tag_tagger.name_already_exists", + }) + : message, + }); + } + + const handleSave = async ( + input: GQL.TagCreateInput, + parentInput?: GQL.TagCreateInput + ) => { setError({}); setModalTag(undefined); - setSaveState("Saving tag"); + if (parentInput) { + setSaveState("Saving parent tag"); + + try { + const parentRes = await createTag({ + variables: { input: parentInput }, + }); + input.parent_ids = [parentRes.data?.tagCreate?.id].filter( + Boolean + ) as string[]; + } catch (e) { + handleSaveError(parentInput.name, apolloError(e)); + setSaveState(""); + return; + } + } + + setSaveState("Saving tag"); const updateData: GQL.TagUpdateInput = { ...input, id: tag.id, @@ -54,18 +92,7 @@ const StashSearchResult: React.FC = ({ const res = await updateTag(updateData); if (!res?.data?.tagUpdate) { - setError({ - message: intl.formatMessage( - { id: "tag_tagger.failed_to_save_tag" }, - { tag: input.name ?? tag.name } - ), - details: - res?.errors?.[0]?.message === "UNIQUE constraint failed: tags.name" - ? intl.formatMessage({ - id: "tag_tagger.name_already_exists", - }) - : res?.errors?.[0]?.message ?? "", - }); + handleSaveError(input.name ?? tag.name, res?.errors?.[0]?.message ?? ""); } else { onTagTagged(tag); } @@ -74,7 +101,7 @@ const StashSearchResult: React.FC = ({ const tags = stashboxTags.map((p) => ( + {isSelectable && ( + + )} : @@ -85,15 +124,82 @@ const TagModal: React.FC = ({ ); } + function maybeRenderParentField( + id: string, + text: string | null | undefined, + isSelectable: boolean = true + ) { + if (!text) return; + + return ( +
        +
        + {isSelectable && ( + + )} + + : + +
        + +
        + ); + } + + function maybeRenderParentTagDetails() { + if (!createParentTag || !tag.parent) { + return; + } + + return ( +
        + {maybeRenderParentField("name", tag.parent.name, false)} + {maybeRenderParentField("description", tag.parent.description)} +
        + ); + } + + function maybeRenderParentTag() { + // No parent tag, or parent already exists locally + if (!tag.parent || tag.parent.stored_id || !sendParentTag) { + return; + } + + return ( +
        +
        + setCreateParentTag(!createParentTag)} + /> +
        + {maybeRenderParentTagDetails()} +
        + ); + } + function handleSave() { if (!tag.name) { throw new Error("tag name must be set"); } + const parentId = tag.parent?.stored_id ?? existingParentId; + const tagData: GQL.TagCreateInput = { name: tag.name, description: tag.description ?? undefined, aliases: tag.alias_list?.filter((a) => a) ?? undefined, + parent_ids: parentId ? [parentId] : undefined, }; // stashid handling code @@ -111,7 +217,27 @@ const TagModal: React.FC = ({ // handle exclusions excludeFields(tagData, excluded); - onSave(tagData); + let parentData: GQL.TagCreateInput | undefined = undefined; + + // Categories don't have stash IDs, so we only create new parent tags + if ( + createParentTag && + sendParentTag && + tag.parent && + !tag.parent.stored_id + ) { + parentData = { + name: tag.parent.name, + description: tag.parent.description ?? undefined, + }; + + // handle exclusions + // Can't exclude parent tag name when creating a new one + parentExcluded.name = false; + excludeFields(parentData, parentExcluded); + } + + onSave(tagData, parentData); } return ( @@ -133,10 +259,12 @@ const TagModal: React.FC = ({ {maybeRenderField("name", tag.name)} {maybeRenderField("description", tag.description)} {maybeRenderField("aliases", tag.alias_list?.join(", "))} + {maybeRenderField("parent_tags", tag.parent?.name, false)} {maybeRenderStashBoxLink()}
    + {maybeRenderParentTag()} ); }; diff --git a/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx b/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx index 1113bdfd4..21891724c 100644 --- a/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx +++ b/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useEffect, useState } from "react"; import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { Link } from "react-router-dom"; @@ -6,7 +6,6 @@ import { HashLink } from "react-router-hash-link"; import * as GQL from "src/core/generated-graphql"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; -import { ModalComponent } from "src/components/Shared/Modal"; import { stashBoxTagQuery, useJobsSubscribe, @@ -20,221 +19,33 @@ import StashSearchResult from "./StashSearchResult"; import TaggerConfig from "../TaggerConfig"; import { ITaggerConfig, TAG_FIELDS } from "../constants"; import { useUpdateTag } from "../queries"; -import { faStar, faTags } from "@fortawesome/free-solid-svg-icons"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { mergeTagStashIDs } from "../utils"; import { separateNamesAndStashIds } from "src/utils/stashIds"; import { useTaggerConfig } from "../config"; +import { + BatchUpdateModal, + BatchAddModal, +} from "src/components/Shared/BatchModals"; type JobFragment = Pick< GQL.Job, "id" | "status" | "subTasks" | "description" | "progress" >; -const CLASSNAME = "StudioTagger"; - -interface ITagBatchUpdateModal { - tags: GQL.TagListDataFragment[]; - isIdle: boolean; - selectedEndpoint: { endpoint: string; index: number }; - onBatchUpdate: (queryAll: boolean, refresh: boolean) => void; - close: () => void; -} - -const TagBatchUpdateModal: React.FC = ({ - tags, - isIdle, - selectedEndpoint, - onBatchUpdate, - close, -}) => { - const intl = useIntl(); - - const [queryAll, setQueryAll] = useState(false); - - const [refresh, setRefresh] = useState(false); - const { data: allTags } = GQL.useFindTagsQuery({ - variables: { - tag_filter: { - stash_id_endpoint: { - endpoint: selectedEndpoint.endpoint, - modifier: refresh - ? GQL.CriterionModifier.NotNull - : GQL.CriterionModifier.IsNull, - }, - }, - filter: { - per_page: 0, - }, - }, - }); - - const tagCount = useMemo(() => { - const filteredStashIDs = tags.map((t) => - t.stash_ids.filter((s) => s.endpoint === selectedEndpoint.endpoint) - ); - - return queryAll - ? allTags?.findTags.count - : filteredStashIDs.filter((s) => - refresh ? s.length > 0 : s.length === 0 - ).length; - }, [queryAll, refresh, tags, allTags, selectedEndpoint.endpoint]); - - return ( - onBatchUpdate(queryAll, refresh), - }} - cancel={{ - text: intl.formatMessage({ id: "actions.cancel" }), - variant: "danger", - onClick: () => close(), - }} - disabled={!isIdle} - > - - -
    - -
    -
    - } - checked={!queryAll} - onChange={() => setQueryAll(false)} - /> - setQueryAll(true)} - /> -
    - - -
    - -
    -
    - setRefresh(false)} - /> - - - - setRefresh(true)} - /> - - - -
    - - - -
    - ); -}; - -interface ITagBatchAddModal { - isIdle: boolean; - onBatchAdd: (input: string) => void; - close: () => void; -} - -const TagBatchAddModal: React.FC = ({ - isIdle, - onBatchAdd, - close, -}) => { - const intl = useIntl(); - - const tagInput = useRef(null); - - return ( - { - if (tagInput.current) { - onBatchAdd(tagInput.current.value); - } else { - close(); - } - }, - }} - cancel={{ - text: intl.formatMessage({ id: "actions.cancel" }), - variant: "danger", - onClick: () => close(), - }} - disabled={!isIdle} - > - - - - - - ); -}; +const CLASSNAME = "TagTagger"; interface ITagTaggerListProps { tags: GQL.TagListDataFragment[]; selectedEndpoint: { endpoint: string; index: number }; isIdle: boolean; config: ITaggerConfig; - onBatchAdd: (tagInput: string) => void; - onBatchUpdate: (ids: string[] | undefined, refresh: boolean) => void; + onBatchAdd: (tagInput: string, createParent: boolean) => void; + onBatchUpdate: ( + ids: string[] | undefined, + refresh: boolean, + createParent: boolean + ) => void; } const TagTaggerList: React.FC = ({ @@ -261,6 +72,27 @@ const TagTaggerList: React.FC = ({ const [showBatchAdd, setShowBatchAdd] = useState(false); const [showBatchUpdate, setShowBatchUpdate] = useState(false); + const [batchAddParents, setBatchAddParents] = useState( + config.createParentTags || false + ); + + const [batchUpdateRefresh, setBatchUpdateRefresh] = useState(false); + const { data: allTags } = GQL.useFindTagsQuery({ + skip: !showBatchUpdate, + variables: { + tag_filter: { + stash_id_endpoint: { + endpoint: selectedEndpoint.endpoint, + modifier: batchUpdateRefresh + ? GQL.CriterionModifier.NotNull + : GQL.CriterionModifier.IsNull, + }, + }, + filter: { + per_page: 0, + }, + }, + }); const [error, setError] = useState< Record @@ -360,12 +192,16 @@ const TagTaggerList: React.FC = ({ }; async function handleBatchAdd(input: string) { - onBatchAdd(input); + onBatchAdd(input, batchAddParents); setShowBatchAdd(false); } const handleBatchUpdate = (queryAll: boolean, refresh: boolean) => { - onBatchUpdate(!queryAll ? tags.map((t) => t.id) : undefined, refresh); + onBatchUpdate( + !queryAll ? tags.map((t) => t.id) : undefined, + refresh, + batchAddParents + ); setShowBatchUpdate(false); }; @@ -451,7 +287,7 @@ const TagTaggerList: React.FC = ({ subContent = (
    - + {link} - - - - - - :{" "} - - - -
    -
    + +
    ); diff --git a/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx b/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx index 76a67e306..a0ee46733 100755 --- a/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx @@ -4,7 +4,6 @@ import { SceneQueue } from "src/models/sceneQueue"; import { Button, Form } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; -import { Icon } from "src/components/Shared/Icon"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { OperationButton } from "src/components/Shared/OperationButton"; import { ISceneQueryResult, TaggerStateContext } from "../context"; @@ -13,8 +12,8 @@ import { TaggerScene } from "./TaggerScene"; import { SceneTaggerModals } from "./sceneTaggerModals"; import { SceneSearchResults } from "./StashSearchResult"; import { useConfigurationContext } from "src/hooks/Config"; -import { faCog } from "@fortawesome/free-solid-svg-icons"; import { useLightbox } from "src/hooks/Lightbox/hooks"; +import { ConfigButton } from "../TaggerConfig"; const Scene: React.FC<{ scene: GQL.SlimSceneDataFragment; @@ -154,16 +153,6 @@ export const Tagger: React.FC = ({ ); } - function renderConfigButton() { - return ( -
    - -
    - ); - } - const [spriteImage, setSpriteImage] = useState(null); const lightboxImage = useMemo( () => [{ paths: { thumbnail: spriteImage, image: spriteImage } }], @@ -293,7 +282,12 @@ export const Tagger: React.FC = ({ {maybeRenderShowHideUnmatchedButton()} {maybeRenderSubmitFingerprintsButton()} {renderFragmentScrapeButton()} - {renderConfigButton()} +
    + setShowConfig(!showConfig)} + /> +
    diff --git a/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx b/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx index adc58cc04..645fb19c2 100644 --- a/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx +++ b/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx @@ -15,11 +15,10 @@ import { useStudioCreate, evictQueries, } from "src/core/StashService"; -import { Manual } from "src/components/Help/Manual"; import { useConfigurationContext } from "src/hooks/Config"; import StashSearchResult from "./StashSearchResult"; -import TaggerConfig from "../TaggerConfig"; +import TaggerConfig, { ConfigButton } from "../TaggerConfig"; import { ITaggerConfig, STUDIO_FIELDS } from "../constants"; import StudioModal from "../scenes/StudioModal"; import { useUpdateStudio } from "../queries"; @@ -33,6 +32,7 @@ import { BatchUpdateModal, BatchAddModal, } from "src/components/Shared/BatchModals"; +import { StashBoxSelectorField } from "../StashBoxSelector"; type JobFragment = Pick< GQL.Job, @@ -471,11 +471,9 @@ interface ITaggerProps { export const StudioTagger: React.FC = ({ studios }) => { const jobsSubscribe = useJobsSubscribe(); - const intl = useIntl(); const { configuration: stashConfig } = useConfigurationContext(); const { config, setConfig } = useTaggerConfig(); const [showConfig, setShowConfig] = useState(false); - const [showManual, setShowManual] = useState(false); const [batchJobID, setBatchJobID] = useState(); const [batchJob, setBatchJob] = useState(); @@ -598,98 +596,102 @@ export const StudioTagger: React.FC = ({ studios }) => { } } - const showHideConfigId = showConfig - ? "actions.hide_configuration" - : "actions.show_configuration"; + if (selectedEndpointIndex === -1 || !selectedEndpoint) { + return ( +
    +

    + +

    +
    + + el.scrollIntoView({ behavior: "smooth", block: "center" }) + } + > + + + ), + }} + /> +
    +
    + ); + } return ( <> - setShowManual(false)} - defaultActiveTab="Tagger.md" - /> {renderStatus()}
    - {selectedEndpointIndex !== -1 && selectedEndpoint ? ( - <> -
    - - -
    - - - setConfig({ ...config, excludedStudioFields: fields }) - } - fields={STUDIO_FIELDS} - entityName="studios" - extraConfig={ - - - } - checked={config.createParentStudios} - onChange={(e: React.ChangeEvent) => - setConfig({ - ...config, - createParentStudios: e.currentTarget.checked, - }) - } - /> - - - - - } - /> - - - ) : ( -
    -

    - -

    -
    - Please see{" "} - - el.scrollIntoView({ behavior: "smooth", block: "center" }) +
    +
    +
    + + setConfig({ ...config, selectedEndpoint: endpoint }) } - > - Settings. - -
    + /> +
    +
    +
    + setShowConfig(!showConfig)} + /> +
    +
    - )} + + + setConfig({ ...config, excludedStudioFields: fields }) + } + fields={STUDIO_FIELDS} + entityName="studios" + extraConfig={ + + + } + checked={config.createParentStudios} + onChange={(e: React.ChangeEvent) => + setConfig({ + ...config, + createParentStudios: e.currentTarget.checked, + }) + } + /> + + + + + } + /> +
    + + ); diff --git a/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx b/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx index 21891724c..8b22a5920 100644 --- a/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx +++ b/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx @@ -12,11 +12,10 @@ import { mutateStashBoxBatchTagTag, getClient, } from "src/core/StashService"; -import { Manual } from "src/components/Help/Manual"; import { useConfigurationContext } from "src/hooks/Config"; import StashSearchResult from "./StashSearchResult"; -import TaggerConfig from "../TaggerConfig"; +import TaggerConfig, { ConfigButton } from "../TaggerConfig"; import { ITaggerConfig, TAG_FIELDS } from "../constants"; import { useUpdateTag } from "../queries"; import { ExternalLink } from "src/components/Shared/ExternalLink"; @@ -27,6 +26,7 @@ import { BatchUpdateModal, BatchAddModal, } from "src/components/Shared/BatchModals"; +import { StashBoxSelectorField } from "../StashBoxSelector"; type JobFragment = Pick< GQL.Job, @@ -414,11 +414,9 @@ interface ITaggerProps { export const TagTagger: React.FC = ({ tags }) => { const jobsSubscribe = useJobsSubscribe(); - const intl = useIntl(); const { configuration: stashConfig } = useConfigurationContext(); const { config, setConfig } = useTaggerConfig(); const [showConfig, setShowConfig] = useState(false); - const [showManual, setShowManual] = useState(false); const [batchJobID, setBatchJobID] = useState(); const [batchJob, setBatchJob] = useState(); @@ -533,98 +531,102 @@ export const TagTagger: React.FC = ({ tags }) => { } } - const showHideConfigId = showConfig - ? "actions.hide_configuration" - : "actions.show_configuration"; + if (selectedEndpointIndex === -1 || !selectedEndpoint) { + return ( +
    +

    + +

    +
    + + el.scrollIntoView({ behavior: "smooth", block: "center" }) + } + > + + + ), + }} + /> +
    +
    + ); + } return ( <> - setShowManual(false)} - defaultActiveTab="Tagger.md" - /> {renderStatus()}
    - {selectedEndpointIndex !== -1 && selectedEndpoint ? ( - <> -
    - - -
    - - - setConfig({ ...config, excludedTagFields: fields }) - } - fields={TAG_FIELDS} - entityName="tags" - extraConfig={ - - - } - checked={config.createParentTags} - onChange={(e: React.ChangeEvent) => - setConfig({ - ...config, - createParentTags: e.currentTarget.checked, - }) - } - /> - - - - - } - /> - - - ) : ( -
    -

    - -

    -
    - Please see{" "} - - el.scrollIntoView({ behavior: "smooth", block: "center" }) +
    +
    +
    + + setConfig({ ...config, selectedEndpoint: endpoint }) } - > - Settings. - -
    + /> +
    +
    +
    + setShowConfig(!showConfig)} + /> +
    +
    - )} + + + setConfig({ ...config, excludedTagFields: fields }) + } + fields={TAG_FIELDS} + entityName="tags" + extraConfig={ + + + } + checked={config.createParentTags} + onChange={(e: React.ChangeEvent) => + setConfig({ + ...config, + createParentTags: e.currentTarget.checked, + }) + } + /> + + + + + } + /> + + + ); diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 46de0e8b7..048a7d7d0 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1404,6 +1404,7 @@ "rating": "Rating", "recently_added_objects": "Recently Added {objects}", "recently_released_objects": "Recently Released {objects}", + "refer_to": "Please see {link}.", "release_notes": "Release Notes", "resolution": "Resolution", "resume_time": "Resume Time", From 79b6cb6fd28ee28d463696bd1a330aa841cfeea2 Mon Sep 17 00:00:00 2001 From: feederbox826 Date: Wed, 18 Mar 2026 22:36:58 -0400 Subject: [PATCH 144/177] Lint + build update and retooling (#6638) * update compiler and build process - assemble cross-builds in multi-build steps - clean up unnecessary dependences - use node docker image instead of nodesource (unsupported) - downgrade to freebsd12 to match compiler Co-authored-by: Gykes * [compiler] use new image instead of placeholder removes .gitignore, update README * [CI] lock pnpm action-setup to SHA hash * bump @actions/upload-artifact --------- Co-authored-by: feederbox826 Co-authored-by: Gykes Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- .github/workflows/build-compiler.yml | 28 +++ .github/workflows/build.yml | 257 +++++++++++++++++++-------- .github/workflows/golangci-lint.yml | 61 +------ Makefile | 2 +- docker/compiler/.gitignore | 1 - docker/compiler/Dockerfile | 138 +++++++------- docker/compiler/Makefile | 20 ++- docker/compiler/README.md | 2 +- docs/DEVELOPMENT.md | 4 +- ui/v2.5/package.json | 1 + 10 files changed, 310 insertions(+), 204 deletions(-) create mode 100644 .github/workflows/build-compiler.yml delete mode 100644 docker/compiler/.gitignore diff --git a/.github/workflows/build-compiler.yml b/.github/workflows/build-compiler.yml new file mode 100644 index 000000000..e7881720b --- /dev/null +++ b/.github/workflows/build-compiler.yml @@ -0,0 +1,28 @@ +name: Compiler Build + +on: + workflow_dispatch: + +env: + COMPILER_IMAGE: ghcr.io/stashapp/compiler:13 + +jobs: + build-compiler: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/setup-buildx-action@v3 + - uses: docker/build-push-action@v6 + with: + push: true + context: "{{defaultContext}}:docker/compiler" + tags: | + ${{ env.COMPILER_IMAGE }} + ghcr.io/stashapp/compiler:latest + cache-from: type=gha,scope=all,mode=max + cache-to: type=gha,scope=all,mode=max \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1e46ecd69..1dcde9f83 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,7 @@ name: Build on: push: - branches: + branches: - develop - master - 'releases/**' @@ -15,50 +15,160 @@ concurrency: cancel-in-progress: true env: - COMPILER_IMAGE: stashapp/compiler:12 + COMPILER_IMAGE: ghcr.io/stashapp/compiler:13 jobs: - build: - runs-on: ubuntu-22.04 + # Job 1: Generate code and build UI + # Runs natively (no Docker) — go generate/gqlgen and node don't need cross-compilers. + # Produces artifacts (generated Go files + UI build) consumed by test and build jobs. + generate: + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 + - name: Setup Go + uses: actions/setup-go@v6 - - name: Checkout - run: git fetch --prune --unshallow --tags + # pnpm version is read from the packageManager field in package.json + # very broken (4.3, 4.4) + - name: Install pnpm + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 + with: + package_json_file: ui/v2.5/package.json + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'pnpm' + cache-dependency-path: ui/v2.5/pnpm-lock.yaml + + - name: Install UI dependencies + run: cd ui/v2.5 && pnpm install --frozen-lockfile + + - name: Generate + run: make generate + + - name: Cache UI build + uses: actions/cache@v5 + id: cache-ui + with: + path: ui/v2.5/build + key: ${{ runner.os }}-ui-build-${{ hashFiles('ui/v2.5/pnpm-lock.yaml', 'ui/v2.5/public/**', 'ui/v2.5/src/**', 'graphql/**/*.graphql') }} + + - name: Validate UI + # skip UI validation for pull requests if UI is unchanged + if: ${{ github.event_name != 'pull_request' || steps.cache-ui.outputs.cache-hit != 'true' }} + run: make validate-ui + + - name: Build UI + # skip UI build for pull requests if UI is unchanged (UI was cached) + if: ${{ github.event_name != 'pull_request' || steps.cache-ui.outputs.cache-hit != 'true' }} + run: make ui + + # Bundle generated Go files + UI build for downstream jobs (test + build) + - name: Upload generated artifacts + uses: actions/upload-artifact@v7 + with: + name: generated + retention-days: 1 + path: | + internal/api/generated_exec.go + internal/api/generated_models.go + ui/v2.5/build/ + ui/login/locales/ + + # Job 2: Integration tests + # Runs natively (no Docker) — only needs Go + GCC (for CGO/SQLite), both on ubuntu-22.04. + # Runs in parallel with the build matrix jobs. + test: + needs: generate + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: 'go.mod' - - name: Pull compiler image - run: docker pull $COMPILER_IMAGE - - - name: Cache node modules - uses: actions/cache@v3 - env: - cache-name: cache-node_modules + # Places generated Go files + UI build into the working tree so the build compiles + - name: Download generated artifacts + uses: actions/download-artifact@v8 with: - path: ui/v2.5/node_modules - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('ui/v2.5/pnpm-lock.yaml') }} + name: generated - - name: Cache UI build - uses: actions/cache@v3 - id: cache-ui - env: - cache-name: cache-ui + - name: Test Backend + run: make it + + # Job 3: Cross-compile for all platforms + # Each platform gets its own runner and Docker container (ghcr.io/stashapp/compiler:13). + # Each build-cc-* make target is self-contained (sets its own GOOS/GOARCH/CC), + # so running them in separate containers is functionally identical to one container. + # Runs in parallel with the test job. + build: + needs: generate + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + include: + - platform: windows + make-target: build-cc-windows + artifact-paths: | + dist/stash-win.exe + tag: win + - platform: macos + make-target: build-cc-macos + artifact-paths: | + dist/stash-macos + dist/Stash.app.zip + tag: osx + - platform: linux + make-target: build-cc-linux + artifact-paths: | + dist/stash-linux + tag: linux + - platform: linux-arm64v8 + make-target: build-cc-linux-arm64v8 + artifact-paths: | + dist/stash-linux-arm64v8 + tag: arm + - platform: linux-arm32v7 + make-target: build-cc-linux-arm32v7 + artifact-paths: | + dist/stash-linux-arm32v7 + tag: arm + - platform: linux-arm32v6 + make-target: build-cc-linux-arm32v6 + artifact-paths: | + dist/stash-linux-arm32v6 + tag: arm + - platform: freebsd + make-target: build-cc-freebsd + artifact-paths: | + dist/stash-freebsd + tag: freebsd + + steps: + - uses: actions/checkout@v6 with: - path: ui/v2.5/build - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('ui/v2.5/pnpm-lock.yaml', 'ui/v2.5/public/**', 'ui/v2.5/src/**', 'graphql/**/*.graphql') }} + fetch-depth: 1 + fetch-tags: true - - name: Cache go build - uses: actions/cache@v3 - env: - # increment the number suffix to bump the cache - cache-name: cache-go-cache-1 + - name: Download generated artifacts + uses: actions/download-artifact@v8 + with: + name: generated + + - name: Cache Go build + uses: actions/cache@v5 with: path: .go-cache - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('go.mod', '**/go.sum') }} + key: ${{ runner.os }}-go-cache-${{ matrix.platform }}-${{ hashFiles('go.mod', '**/go.sum') }} + + # kept seperate to test timings + - name: pull compiler image + run: docker pull $COMPILER_IMAGE - name: Start build container env: @@ -67,45 +177,48 @@ jobs: mkdir -p .go-cache docker run -d --name build --mount type=bind,source="$(pwd)",target=/stash,consistency=delegated --mount type=bind,source="$(pwd)/.go-cache",target=/root/.cache/go-build,consistency=delegated --env OFFICIAL_BUILD=${{ env.official-build }} -w /stash $COMPILER_IMAGE tail -f /dev/null - - name: Pre-install - run: docker exec -t build /bin/bash -c "make CI=1 pre-ui" - - - name: Generate - run: docker exec -t build /bin/bash -c "make generate" - - - name: Validate UI - # skip UI validation for pull requests if UI is unchanged - if: ${{ github.event_name != 'pull_request' || steps.cache-ui.outputs.cache-hit != 'true' }} - run: docker exec -t build /bin/bash -c "make validate-ui" - - # Static validation happens in the linter workflow in parallel to this workflow - # Run Dynamic validation here, to make sure we pass all the projects integration tests - - name: Test Backend - run: docker exec -t build /bin/bash -c "make it" - - - name: Build UI - # skip UI build for pull requests if UI is unchanged (UI was cached) - # this means that the build version/time may be incorrect if the UI is - # not changed in a pull request - if: ${{ github.event_name != 'pull_request' || steps.cache-ui.outputs.cache-hit != 'true' }} - run: docker exec -t build /bin/bash -c "make ui" - - - name: Compile for all supported platforms - run: | - docker exec -t build /bin/bash -c "make build-cc-windows" - docker exec -t build /bin/bash -c "make build-cc-macos" - docker exec -t build /bin/bash -c "make build-cc-linux" - docker exec -t build /bin/bash -c "make build-cc-linux-arm64v8" - docker exec -t build /bin/bash -c "make build-cc-linux-arm32v7" - docker exec -t build /bin/bash -c "make build-cc-linux-arm32v6" - docker exec -t build /bin/bash -c "make build-cc-freebsd" - - - name: Zip UI - run: docker exec -t build /bin/bash -c "make zip-ui" + - name: Build (${{ matrix.platform }}) + run: docker exec -t build /bin/bash -c "make ${{ matrix.make-target }}" - name: Cleanup build container run: docker rm -f -v build + - name: Upload build artifact + uses: actions/upload-artifact@v7 + with: + name: build-${{ matrix.platform }} + retention-days: 1 + path: ${{ matrix.artifact-paths }} + + # Job 4: Release + # Waits for both test and build to pass, then collects all platform artifacts + # into dist/ for checksums, GitHub releases, and multi-arch Docker push. + release: + needs: [test, build] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + + # Downloads all artifacts (generated + 7 platform builds) into artifacts/ subdirectories + - name: Download all build artifacts + uses: actions/download-artifact@v8 + with: + path: artifacts + + # Reassemble platform binaries from matrix job artifacts into a single dist/ directory + # upload-artifact@v4 strips the common path prefix (dist/), so files are at the artifact root + - name: Collect binaries + run: | + mkdir -p dist + cp artifacts/build-*/* dist/ + + - name: Zip UI + run: | + cd artifacts/generated/ui/v2.5/build && zip -r ../../../../../dist/stash-ui.zip . + - name: Generate checksums run: | git describe --tags --exclude latest_develop | tee CHECKSUMS_SHA1 @@ -116,7 +229,7 @@ jobs: - name: Upload Windows binary # only upload binaries for pull requests if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: stash-win.exe path: dist/stash-win.exe @@ -124,7 +237,7 @@ jobs: - name: Upload macOS binary # only upload binaries for pull requests if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: stash-macos path: dist/stash-macos @@ -132,7 +245,7 @@ jobs: - name: Upload Linux binary # only upload binaries for pull requests if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: stash-linux path: dist/stash-linux @@ -140,14 +253,14 @@ jobs: - name: Upload UI # only upload for pull requests if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: stash-ui.zip path: dist/stash-ui.zip - name: Update latest_develop tag if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }} - run : git tag -f latest_develop; git push -f --tags + run: git tag -f latest_develop; git push -f --tags - name: Development Release if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }} @@ -197,7 +310,7 @@ jobs: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} run: | - docker run --rm --privileged docker/binfmt:a7996909642ee92942dcd6cff44b9b95f08dad64 + docker run --rm --privileged tonistiigi/binfmt docker info docker buildx create --name builder --use docker buildx inspect --bootstrap @@ -213,7 +326,7 @@ jobs: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} run: | - docker run --rm --privileged docker/binfmt:a7996909642ee92942dcd6cff44b9b95f08dad64 + docker run --rm --privileged tonistiigi/binfmt docker info docker buildx create --name builder --use docker buildx inspect --bootstrap diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 71c743ced..19a6d62bd 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -9,65 +9,20 @@ on: - 'releases/**' pull_request: -env: - COMPILER_IMAGE: stashapp/compiler:12 - jobs: golangci: name: lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - - name: Checkout - run: git fetch --prune --unshallow --tags - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version-file: 'go.mod' - - - name: Pull compiler image - run: docker pull $COMPILER_IMAGE - - - name: Start build container - run: | - mkdir -p .go-cache - docker run -d --name build --mount type=bind,source="$(pwd)",target=/stash,consistency=delegated --mount type=bind,source="$(pwd)/.go-cache",target=/root/.cache/go-build,consistency=delegated -w /stash $COMPILER_IMAGE tail -f /dev/null + # no tags or depth needed for lint + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + # generate-backend runs natively (just go generate + touch-ui) — no Docker needed - name: Generate Backend - run: docker exec -t build /bin/bash -c "make generate-backend" + run: make generate-backend + ## WARN + ## using v1, update in a later PR - name: Run golangci-lint - uses: golangci/golangci-lint-action@v6 - with: - # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version - version: latest - - # Optional: working directory, useful for monorepos - # working-directory: somedir - - # Optional: golangci-lint command line arguments. - # - # Note: By default, the `.golangci.yml` file should be at the root of the repository. - # The location of the configuration file can be changed by using `--config=` - args: --timeout=5m - - # Optional: show only new issues if it's a pull request. The default value is `false`. - # only-new-issues: true - - # Optional: if set to true, then all caching functionality will be completely disabled, - # takes precedence over all other caching options. - # skip-cache: true - - # Optional: if set to true, then the action won't cache or restore ~/go/pkg. - # skip-pkg-cache: true - - # Optional: if set to true, then the action won't cache or restore ~/.cache/go-build. - # skip-build-cache: true - - # Optional: The mode to install golangci-lint. It can be 'binary' or 'goinstall'. - # install-mode: "goinstall" - - - name: Cleanup build container - run: docker rm -f -v build + uses: golangci/golangci-lint-action@v6 \ No newline at end of file diff --git a/Makefile b/Makefile index 7e19063a3..4f8d9cadd 100644 --- a/Makefile +++ b/Makefile @@ -50,7 +50,7 @@ export CGO_ENABLED := 1 # define COMPILER_IMAGE for cross-compilation docker container ifndef COMPILER_IMAGE - COMPILER_IMAGE := stashapp/compiler:latest + COMPILER_IMAGE := ghcr.io/stashapp/compiler:latest endif .PHONY: release diff --git a/docker/compiler/.gitignore b/docker/compiler/.gitignore deleted file mode 100644 index 7012bfd63..000000000 --- a/docker/compiler/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.sdk.tar.* \ No newline at end of file diff --git a/docker/compiler/Dockerfile b/docker/compiler/Dockerfile index 0154d7e61..c9dfb9c7c 100644 --- a/docker/compiler/Dockerfile +++ b/docker/compiler/Dockerfile @@ -1,82 +1,86 @@ -FROM golang:1.24.3 +### OSXCROSS +FROM debian:bookworm AS osxcross +# add osxcross +WORKDIR /tmp/osxcross +ARG OSXCROSS_REVISION=5e1b71fcceb23952f3229995edca1b6231525b5b +ADD --checksum=sha256:d3f771bbc20612fea577b18a71be3af2eb5ad2dd44624196cf55de866d008647 https://codeload.github.com/tpoechtrager/osxcross/tar.gz/${OSXCROSS_REVISION} /tmp/osxcross.tar.gz -LABEL maintainer="https://discord.gg/2TsNFKt" +ARG OSX_SDK_VERSION=11.3 +ARG OSX_SDK_DOWNLOAD_FILE=MacOSX${OSX_SDK_VERSION}.sdk.tar.xz +ARG OSX_SDK_DOWNLOAD_URL=https://github.com/phracker/MacOSX-SDKs/releases/download/${OSX_SDK_VERSION}/${OSX_SDK_DOWNLOAD_FILE} +ADD --checksum=sha256:cd4f08a75577145b8f05245a2975f7c81401d75e9535dcffbb879ee1deefcbf4 ${OSX_SDK_DOWNLOAD_URL} /tmp/osxcross/tarballs/${OSX_SDK_DOWNLOAD_FILE} -RUN apt-get update && apt-get install -y apt-transport-https ca-certificates gnupg +ENV UNATTENDED=yes \ + SDK_VERSION=${OSX_SDK_VERSION} \ + OSX_VERSION_MIN=10.10 +RUN apt update && \ + apt install -y --no-install-recommends \ + bash ca-certificates clang cmake git patch libssl-dev bzip2 cpio libbz2-dev libxml2-dev make python3 xz-utils zlib1g-dev +# lzma-dev libxml2-dev xz +RUN tar --strip=1 -C /tmp/osxcross -xf /tmp/osxcross.tar.gz +RUN ./build.sh -RUN mkdir -p /etc/apt/keyrings +### FREEBSD cross-compilation stage +# use alpine for cacheable image since apt is notorous for not caching +FROM alpine:3 AS freebsd +# match golang latest +# https://go.dev/wiki/FreeBSD +ARG FREEBSD_VERSION=12.4 +ADD --checksum=sha256:581c7edacfd2fca2bdf5791f667402d22fccd8a5e184635e0cac075564d57aa8 \ + http://ftp-archive.freebsd.org/mirror/FreeBSD-Archive/old-releases/amd64/${FREEBSD_VERSION}-RELEASE/base.txz \ + /tmp/base.txz -ADD https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key nodesource.gpg.key -RUN cat nodesource.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && rm nodesource.gpg.key -RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_24.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list +WORKDIR /opt/cross-freebsd +RUN apk add --no-cache tar xz +RUN tar -xf /tmp/base.txz --strip-components=1 ./usr/lib ./usr/include ./lib +RUN cd /opt/cross-freebsd/usr/lib && \ + find . -type l -exec sh -c ' \ + for link; do \ + target=$(readlink "$link"); \ + case "$target" in \ + /lib/*) ln -sf "/opt/cross-freebsd$target" "$link";; \ + esac; \ + done \ + ' sh {} + && \ + ln -s libc++.a libstdc++.a && \ + ln -s libc++.so libstdc++.so -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - git make tar bash nodejs zip \ - clang llvm-dev cmake patch libxml2-dev uuid-dev libssl-dev xz-utils \ - bzip2 gzip sed cpio libbz2-dev zlib1g-dev \ - gcc-mingw-w64 \ - gcc-arm-linux-gnueabi libc-dev-armel-cross linux-libc-dev-armel-cross \ - gcc-aarch64-linux-gnu libc-dev-arm64-cross && \ - rm -rf /var/lib/apt/lists/*; +### BUILDER +FROM golang:1.24.3 AS builder +ENV PATH=/opt/osx-ndk-x86/bin:$PATH + +# copy in nodejs instead of using nodesource :thumbsup: +COPY --from=docker.io/library/node:24-bookworm /usr/local /usr/local +# copy in osxcross +COPY --from=osxcross /tmp/osxcross/target/lib /usr/lib +COPY --from=osxcross /tmp/osxcross/target /opt/osx-ndk-x86 +# copy in cross-freebsd +COPY --from=freebsd /opt/cross-freebsd /opt/cross-freebsd # pnpm install with npm RUN npm install -g pnpm -# FreeBSD cross-compilation setup -# https://github.com/smartmontools/docker-build/blob/6b8c92560d17d325310ba02d9f5a4b250cb0764a/Dockerfile#L66 -ENV FREEBSD_VERSION 13.4 -ENV FREEBSD_DOWNLOAD_URL http://ftp.plusline.de/FreeBSD/releases/amd64/${FREEBSD_VERSION}-RELEASE/base.txz -ENV FREEBSD_SHA 8e13b0a93daba349b8d28ad246d7beb327659b2ef4fe44d89f447392daec5a7c +# git for getting hash +# make and bash for building -RUN cd /tmp && \ - curl -o base.txz $FREEBSD_DOWNLOAD_URL && \ - echo "$FREEBSD_SHA base.txz" | sha256sum -c - && \ - mkdir -p /opt/cross-freebsd && \ - cd /opt/cross-freebsd && \ - tar -xf /tmp/base.txz ./lib/ ./usr/lib/ ./usr/include/ && \ - rm -f /tmp/base.txz && \ - cd /opt/cross-freebsd/usr/lib && \ - find . -xtype l | xargs ls -l | grep ' /lib/' | awk '{print "ln -sf /opt/cross-freebsd"$11 " " $9}' | /bin/sh && \ - ln -s libc++.a libstdc++.a && \ - ln -s libc++.so libstdc++.so - -# macOS cross-compilation setup -ENV OSX_SDK_VERSION 11.3 -ENV OSX_SDK_DOWNLOAD_FILE MacOSX${OSX_SDK_VERSION}.sdk.tar.xz -ENV OSX_SDK_DOWNLOAD_URL https://github.com/phracker/MacOSX-SDKs/releases/download/${OSX_SDK_VERSION}/${OSX_SDK_DOWNLOAD_FILE} -ENV OSX_SDK_SHA cd4f08a75577145b8f05245a2975f7c81401d75e9535dcffbb879ee1deefcbf4 -ENV OSXCROSS_REVISION 5e1b71fcceb23952f3229995edca1b6231525b5b -ENV OSXCROSS_DOWNLOAD_URL https://codeload.github.com/tpoechtrager/osxcross/tar.gz/${OSXCROSS_REVISION} -ENV OSXCROSS_SHA d3f771bbc20612fea577b18a71be3af2eb5ad2dd44624196cf55de866d008647 - -RUN cd /tmp && \ - curl -o osxcross.tar.gz $OSXCROSS_DOWNLOAD_URL && \ - echo "$OSXCROSS_SHA osxcross.tar.gz" | sha256sum -c - && \ - mkdir osxcross && \ - tar --strip=1 -C osxcross -xf osxcross.tar.gz && \ - rm -f osxcross.tar.gz && \ - curl -Lo $OSX_SDK_DOWNLOAD_FILE $OSX_SDK_DOWNLOAD_URL && \ - echo "$OSX_SDK_SHA $OSX_SDK_DOWNLOAD_FILE" | sha256sum -c - && \ - mv $OSX_SDK_DOWNLOAD_FILE osxcross/tarballs/ && \ - UNATTENDED=yes SDK_VERSION=$OSX_SDK_VERSION OSX_VERSION_MIN=10.10 osxcross/build.sh && \ - cp osxcross/target/lib/* /usr/lib/ && \ - mv osxcross/target /opt/osx-ndk-x86 && \ - rm -rf /tmp/osxcross - -ENV PATH /opt/osx-ndk-x86/bin:$PATH - -RUN mkdir -p /root/.ssh && \ - chmod 0700 /root/.ssh && \ - ssh-keyscan github.com > /root/.ssh/known_hosts - -# ignore "dubious ownership" errors +# clang for macos +# zip for stashapp.zip +# gcc-extensions for cross-arch build +# we still target arm soft float? +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + git make bash \ + clang zip \ + gcc-mingw-w64 \ + gcc-arm-linux-gnueabi \ + libc-dev-armel-cross linux-libc-dev-armel-cross \ + gcc-aarch64-linux-gnu libc-dev-arm64-cross && \ + rm -rf /var/lib/apt/lists/*; RUN git config --global safe.directory '*' - # To test locally: # make generate # make ui # cd docker/compiler -# make build -# docker run --rm -v /PATH_TO_STASH:/stash -w /stash -i -t stashapp/compiler:latest make build-cc-all -# # binaries will show up in /dist +# docker build . -t ghcr.io/stashapp/compiler:latest +# docker run --rm -v /PATH_TO_STASH:/stash -w /stash -i -t ghcr.io/stashapp/compiler:latest make build-cc-all +# # binaries will show up in /dist \ No newline at end of file diff --git a/docker/compiler/Makefile b/docker/compiler/Makefile index ed6a9a285..66f19f5d6 100644 --- a/docker/compiler/Makefile +++ b/docker/compiler/Makefile @@ -1,16 +1,22 @@ +host=ghcr.io user=stashapp repo=compiler -version=12 +version=13 + +VERSION_IMAGE = ${host}/${user}/${repo}:${version} +LATEST_IMAGE = ${host}/${user}/${repo}:latest latest: - docker build -t ${user}/${repo}:latest . + docker build -t ${LATEST_IMAGE} . build: - docker build -t ${user}/${repo}:${version} -t ${user}/${repo}:latest . + docker build -t ${VERSION_IMAGE} -t ${LATEST_IMAGE} . build-no-cache: - docker build --no-cache -t ${user}/${repo}:${version} -t ${user}/${repo}:latest . + docker build --no-cache -t ${VERSION_IMAGE} -t ${LATEST_IMAGE} . -install: build - docker push ${user}/${repo}:${version} - docker push ${user}/${repo}:latest +# requires docker login ghcr.io +# echo $CR_PAT | docker login ghcr.io -u USERNAME --password-stdin +push: + docker push ${VERSION_IMAGE} + docker push ${LATEST_IMAGE} \ No newline at end of file diff --git a/docker/compiler/README.md b/docker/compiler/README.md index 6bb7d8d99..c7b4840f9 100644 --- a/docker/compiler/README.md +++ b/docker/compiler/README.md @@ -1,3 +1,3 @@ Modified from https://github.com/bep/dockerfiles/tree/master/ci-goreleaser -When the Dockerfile is changed, the version number should be incremented in the Makefile and the new version tag should be pushed to Docker Hub. The GitHub workflow files also need to be updated to pull the correct image tag. +When the Dockerfile is changed, the version number should be incremented in [.github/workflows/build-compiler.yml](../../.github/workflows/build-compiler.yml) and the workflow [manually ran](). `env: COMPILER_IMAGE` in [.github/workflows/build.yml](../../.github/workflows/build.yml) also needs to be updated to pull the correct image tag. \ No newline at end of file diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 85c2f6f23..a26ce6817 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -118,8 +118,8 @@ This project uses a modification of the [CI-GoReleaser](https://github.com/bep/d To cross-compile the app yourself: 1. Run `make pre-ui`, `make generate` and `make ui` outside the container, to generate files and build the UI. -2. Pull the latest compiler image from Docker Hub: `docker pull stashapp/compiler` -3. Run `docker run --rm --mount type=bind,source="$(pwd)",target=/stash -w /stash -it stashapp/compiler /bin/bash` to open a shell inside the container. +2. Pull the latest compiler image from GHCR: `docker pull ghcr.io/stashapp/compiler` +3. Run `docker run --rm --mount type=bind,source="$(pwd)",target=/stash -w /stash -it ghcr.io/stashapp/compiler /bin/bash` to open a shell inside the container. 4. From inside the container, run `make build-cc-all` to build for all platforms, or run `make build-cc-{platform}` to build for a specific platform (have a look at the `Makefile` for the list of targets). 5. You will find the compiled binaries in `dist/`. diff --git a/ui/v2.5/package.json b/ui/v2.5/package.json index e024a0053..001e7fb60 100644 --- a/ui/v2.5/package.json +++ b/ui/v2.5/package.json @@ -3,6 +3,7 @@ "private": true, "homepage": "./", "type": "module", + "packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017", "scripts": { "start": "vite", "build": "vite build", From 58cf6307cb28cebfb667711a871f72615ac1f157 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:59:42 +1100 Subject: [PATCH 145/177] Update changelog --- ui/v2.5/src/docs/en/Changelog/v0310.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ui/v2.5/src/docs/en/Changelog/v0310.md b/ui/v2.5/src/docs/en/Changelog/v0310.md index af7ed159b..afb618507 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0310.md +++ b/ui/v2.5/src/docs/en/Changelog/v0310.md @@ -37,6 +37,8 @@ * Added button to delete scene cover. ([#6444](https://github.com/stashapp/stash/pull/6444)) * Duplicate aliases are now silently removed. ([#6514](https://github.com/stashapp/stash/pull/6514)) * Image query now includes image details field. ([#6673](https://github.com/stashapp/stash/pull/6673)) +* Select scene/performer/studio/tag dropdowns now accept stash-ids as input. ([#6709](https://github.com/stashapp/stash/pull/6709)) +* Volume when hovering over a scene preview is now configurable. ([#6712](https://github.com/stashapp/stash/pull/6712)) * Added non-binary gender icon. ([#6489](https://github.com/stashapp/stash/pull/6489)) * Transgender icons are now coloured by their presented gender. ([#6489](https://github.com/stashapp/stash/pull/6489)) * It is now possible to add a library path to a non-existing directory (useful for disconnected network paths). ([#6644](https://github.com/stashapp/stash/pull/6644)) @@ -47,9 +49,11 @@ * Added support for sorting performers, studios and tags by total scene file size. ([#6642](https://github.com/stashapp/stash/pull/6642)) * Added support for filtering by stash ID count. ([#6437](https://github.com/stashapp/stash/pull/6437)) * Added support for filtering group by scene count. ([#6593](https://github.com/stashapp/stash/pull/6593)) +* Updated Tag list view to be consistent with other list views. ([#6703](https://github.com/stashapp/stash/pull/6703)) * Installed plugins/scrapers no longer show in the available list. ([#6443](https://github.com/stashapp/stash/pull/6443)) * Name is now populated when searching by stash-box. ([#6447](https://github.com/stashapp/stash/pull/6447)) * Improved performance of group queries on large systems. ([#6478](https://github.com/stashapp/stash/pull/6478)) +* Search input is now focused when opening the scraper menu. ([#6704](https://github.com/stashapp/stash/pull/6704)) * Added support for `{phash}` in `queryURL` scraper field. ([#6701](https://github.com/stashapp/stash/pull/6701)) * Systray notification now shows the port stash is running on. ([#6448](https://github.com/stashapp/stash/pull/6448)) @@ -62,6 +66,7 @@ * Improved scanning algorithm to prevent creation of orphaned folders and handle missing parent folders. ([#6608](https://github.com/stashapp/stash/pull/6608)) * Scanning no longer scans zip contents when the zip file is unchanged. ([#6633](https://github.com/stashapp/stash/pull/6633)) * Captions are now correctly detected in a single scan. ([#6634](https://github.com/stashapp/stash/pull/6634)) +* Fixed galleries not being linked to scenes when scanning a matching file. ([#6705](https://github.com/stashapp/stash/pull/6705)) * Fixed mis-clicks on cards navigating to new page when selecting items. ([#6599](https://github.com/stashapp/stash/pull/6599), [#6649](https://github.com/stashapp/stash/pull/6649)) * Select dropdown now retains focus after creating a new option. ([#6697](https://github.com/stashapp/stash/pull/6697)) * Fixed custom field filtering not working correctly when query value was provided. ([#6614](https://github.com/stashapp/stash/pull/6614)) From 640d62cf59868951109c6a54207d8171a44b873f Mon Sep 17 00:00:00 2001 From: feederbox826 Date: Thu, 19 Mar 2026 00:10:04 -0400 Subject: [PATCH 146/177] [CI] ensure artifacts have +x bit set (#6715) --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1dcde9f83..556df6be4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -209,11 +209,13 @@ jobs: path: artifacts # Reassemble platform binaries from matrix job artifacts into a single dist/ directory + # make sure that artifacts have executable bit set # upload-artifact@v4 strips the common path prefix (dist/), so files are at the artifact root - name: Collect binaries run: | mkdir -p dist cp artifacts/build-*/* dist/ + chmod +x dist/* - name: Zip UI run: | From ee9a852ec9ec864c2d12f2f139961f63ff207078 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:35:12 +1100 Subject: [PATCH 147/177] Remove phasher from build target [skip ci] --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 4f8d9cadd..5a56df3ea 100644 --- a/Makefile +++ b/Makefile @@ -129,7 +129,7 @@ phasher: build-flags # builds dynamically-linked debug binaries .PHONY: build -build: stash phasher +build: stash # builds dynamically-linked PIE release binaries .PHONY: build-release From c832e1a8a29250cd2720bac3b3b042b0b62aee49 Mon Sep 17 00:00:00 2001 From: feederbox826 Date: Thu, 19 Mar 2026 03:31:14 -0400 Subject: [PATCH 148/177] remove phasher target from bundle (#6717) [skip ci] --- Makefile | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 5a56df3ea..d9caf0ee5 100644 --- a/Makefile +++ b/Makefile @@ -187,8 +187,6 @@ build-cc-macos: # Combine into universal binaries lipo -create -output dist/stash-macos dist/stash-macos-intel dist/stash-macos-arm rm dist/stash-macos-intel dist/stash-macos-arm - lipo -create -output dist/phasher-macos dist/phasher-macos-intel dist/phasher-macos-arm - rm dist/phasher-macos-intel dist/phasher-macos-arm # Place into bundle and zip up rm -rf dist/Stash.app @@ -198,6 +196,16 @@ build-cc-macos: cd dist && rm -f Stash.app.zip && zip -r Stash.app.zip Stash.app rm -rf dist/Stash.app +.PHONY: build-cc-macos-phasher +build-cc-macos-phasher: + make build-cc-macos-arm + make build-cc-macos-intel + + # Combine into universal binaries + lipo -create -output dist/phasher-macos dist/phasher-macos-intel dist/phasher-macos-arm + rm dist/phasher-macos-intel dist/phasher-macos-arm + # do not bundle phasher + .PHONY: build-cc-freebsd build-cc-freebsd: export GOOS := freebsd build-cc-freebsd: export GOARCH := amd64 From 865c50d615c0c7b60509f9fa0345a544c3c601f9 Mon Sep 17 00:00:00 2001 From: feederbox826 Date: Sun, 22 Mar 2026 18:02:38 -0400 Subject: [PATCH 149/177] [ui] Fix Tag Modal cutting off (#6734) --- ui/v2.5/src/components/Tagger/tags/TagModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/tags/TagModal.tsx b/ui/v2.5/src/components/Tagger/tags/TagModal.tsx index d9a7d99b8..804e5b55c 100644 --- a/ui/v2.5/src/components/Tagger/tags/TagModal.tsx +++ b/ui/v2.5/src/components/Tagger/tags/TagModal.tsx @@ -103,7 +103,7 @@ const TagModal: React.FC = ({ : - + ); } @@ -147,7 +147,7 @@ const TagModal: React.FC = ({ : - + ); } From 7a18b5310b6fdb408b2d183be4c3264a277720c0 Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Mon, 23 Mar 2026 00:06:20 +0200 Subject: [PATCH 150/177] Add GitHub Sponsors and forum links to about section (#6718) * Add GitHub sponsors link to about section * Add forum link to about section * Fix casing in 'latest_version_build_hash' string in localization file --- .../Settings/SettingsAboutPanel.tsx | 20 ++++++++++++++----- ui/v2.5/src/locales/en-GB.json | 10 +++++----- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/ui/v2.5/src/components/Settings/SettingsAboutPanel.tsx b/ui/v2.5/src/components/Settings/SettingsAboutPanel.tsx index 9d9922330..316f471be 100644 --- a/ui/v2.5/src/components/Settings/SettingsAboutPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsAboutPanel.tsx @@ -129,7 +129,7 @@ export const SettingsAboutPanel: React.FC = () => { { url: ( - Documentation + documentation ), } @@ -137,9 +137,14 @@ export const SettingsAboutPanel: React.FC = () => {

    {intl.formatMessage( - { id: "config.about.stash_discord" }, + { id: "config.about.stash_community" }, { - url: ( + forumUrl: ( + + forum + + ), + discordUrl: ( Discord @@ -149,13 +154,18 @@ export const SettingsAboutPanel: React.FC = () => {

    {intl.formatMessage( - { id: "config.about.stash_open_collective" }, + { id: "config.about.support_us" }, { - url: ( + openCollectiveUrl: ( Open Collective ), + githubSponsorsUrl: ( + + GitHub Sponsors + + ), } )}

    diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 048a7d7d0..e27d51310 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -251,13 +251,13 @@ "build_time": "Build time:", "check_for_new_version": "Check for new version", "latest_version": "Latest Version", - "latest_version_build_hash": "Latest Version Build Hash:", + "latest_version_build_hash": "Latest version build hash:", "new_version_notice": "[NEW]", "release_date": "Release date:", - "stash_discord": "Join our {url} channel", - "stash_home": "Stash home at {url}", - "stash_open_collective": "Support us through {url}", - "stash_wiki": "Stash {url} page", + "stash_home": "Visit Stash on {url}.", + "stash_wiki": "Check out Stash {url} website.", + "stash_community": "Join our {forumUrl} or {discordUrl} community server.", + "support_us": "Support us through {openCollectiveUrl} or {githubSponsorsUrl}.", "version": "Version" }, "advanced_mode": "Advanced mode", From b11be4807ad11bcc23db35188c703af7714521de Mon Sep 17 00:00:00 2001 From: feederbox826 Date: Sun, 22 Mar 2026 18:07:13 -0400 Subject: [PATCH 151/177] fetch full depth of git history for compiler (#6726) [ci] run generate with fetch depth --- .github/workflows/build.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 556df6be4..46346136c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,6 +25,9 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true - name: Setup Go uses: actions/setup-go@v6 @@ -152,7 +155,7 @@ jobs: steps: - uses: actions/checkout@v6 with: - fetch-depth: 1 + fetch-depth: 0 fetch-tags: true - name: Download generated artifacts From 11f9e7ac51d888e7a0da8a9f4255941ad0493c37 Mon Sep 17 00:00:00 2001 From: feederbox826 Date: Sun, 22 Mar 2026 18:07:47 -0400 Subject: [PATCH 152/177] [ci] add macos bundle (#6727) --- .github/workflows/build.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 46346136c..c068b46f0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -247,6 +247,14 @@ jobs: name: stash-macos path: dist/stash-macos + - name: Upload macOS bundle + # only upload binaries for pull requests + if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}} + uses: actions/upload-artifact@v7 + with: + name: Stash.app.zip + path: dist/Stash.app.zip + - name: Upload Linux binary # only upload binaries for pull requests if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}} From feb4346e13b8f19af79a20e3582ddd6d02074184 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 23 Mar 2026 12:31:48 +1100 Subject: [PATCH 153/177] Maintain sub-folders selection when reselecting folder in filter --- ui/v2.5/src/components/List/Filters/FolderFilter.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/List/Filters/FolderFilter.tsx b/ui/v2.5/src/components/List/Filters/FolderFilter.tsx index 38e9d21ec..bf09af9cb 100644 --- a/ui/v2.5/src/components/List/Filters/FolderFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/FolderFilter.tsx @@ -487,16 +487,21 @@ export const SidebarFolderFilter: React.FC< return newCriterion; }, [option.type, filter]); + const subDirsSelected = criterion.value?.depth === -1; + // if there are multiple values or excluded values, then we show none of the // current values const multipleSelected = criterion.value.items.length > 1 || criterion.value.excluded.length > 0; function onSelect(folder: IFolder) { + // maintain sub-folder select if present + const depth = subDirsSelected ? -1 : 0; + const c = criterion.clone() as FolderCriterion; c.value = { items: [{ id: folder.id, label: folder.path }], - depth: 0, + depth, excluded: [], }; @@ -550,8 +555,6 @@ export const SidebarFolderFilter: React.FC< } } - const subDirsSelected = criterion.value?.depth === -1; - const selectedList = useMemo(() => { if (multipleSelected) { return null; From 2bb1df84437d09775cf859644826aacafc9de99e Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 23 Mar 2026 13:45:31 +1100 Subject: [PATCH 154/177] Fix incorrect where clause for gallery parent folder filter (#6737) --- pkg/sqlite/gallery_filter.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/sqlite/gallery_filter.go b/pkg/sqlite/gallery_filter.go index 28f3b8fac..0435f3f57 100644 --- a/pkg/sqlite/gallery_filter.go +++ b/pkg/sqlite/gallery_filter.go @@ -285,7 +285,7 @@ func (qb *galleryFilterHandler) parentFolderCriterionHandler(folder *models.Hier return } - galleryRepository.addFoldersTable(f) + galleryRepository.addFilesTable(f) f.addLeftJoin(folderTable, "gallery_folder", "galleries.folder_id = gallery_folder.id") criterion := *folder @@ -320,7 +320,7 @@ func (qb *galleryFilterHandler) parentFolderCriterionHandler(folder *models.Hier } // combine clauses with OR to handle zip file or folder - c1 := makeClause(fmt.Sprintf("folders.parent_folder_id IN (SELECT column2 FROM (%s))", valuesClause)) + c1 := makeClause(fmt.Sprintf("files.parent_folder_id IN (SELECT column2 FROM (%s))", valuesClause)) c2 := makeClause(fmt.Sprintf("gallery_folder.parent_folder_id IN (SELECT column2 FROM (%s))", valuesClause)) f.whereClauses = append(f.whereClauses, orClauses(c1, c2)) } @@ -332,7 +332,7 @@ func (qb *galleryFilterHandler) parentFolderCriterionHandler(folder *models.Hier return } - f.addWhere(fmt.Sprintf("folders.parent_folder_id NOT IN (SELECT column2 FROM (%s)) OR folders.parent_folder_id IS NULL", valuesClause)) + f.addWhere(fmt.Sprintf("files.parent_folder_id NOT IN (SELECT column2 FROM (%s)) OR folders.parent_folder_id IS NULL", valuesClause)) f.addWhere(fmt.Sprintf("gallery_folder.parent_folder_id NOT IN (SELECT column2 FROM (%s)) OR gallery_folder.parent_folder_id IS NULL", valuesClause)) } } From 3dbb0fcfc9bcad4b0ef2d8b898425b151ad474d8 Mon Sep 17 00:00:00 2001 From: feederbox826 Date: Mon, 23 Mar 2026 01:10:22 -0400 Subject: [PATCH 155/177] [hwaccel] add envvar for /dev/dri device (#6728) --- pkg/ffmpeg/codec_hardware.go | 8 +++++++- ui/v2.5/src/docs/en/Manual/Configuration.md | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/pkg/ffmpeg/codec_hardware.go b/pkg/ffmpeg/codec_hardware.go index aa8c75dcc..66480c5bb 100644 --- a/pkg/ffmpeg/codec_hardware.go +++ b/pkg/ffmpeg/codec_hardware.go @@ -185,6 +185,12 @@ func (f *FFMpeg) hwCanFullHWTranscode(ctx context.Context, codec VideoCodec, vf // Prepend input for hardware encoding only func (f *FFMpeg) hwDeviceInit(args Args, toCodec VideoCodec, fullhw bool) Args { + // check for custom /dev/dri device #6435 + driDevice := os.Getenv("STASH_HW_DRI_DEVICE") + if driDevice == "" { + driDevice = "/dev/dri/renderD128" + } + switch toCodec { case VideoCodecN264, VideoCodecN264H: @@ -201,7 +207,7 @@ func (f *FFMpeg) hwDeviceInit(args Args, toCodec VideoCodec, fullhw bool) Args { case VideoCodecV264, VideoCodecVVP9: args = append(args, "-vaapi_device") - args = append(args, "/dev/dri/renderD128") + args = append(args, driDevice) if fullhw { args = append(args, "-hwaccel") args = append(args, "vaapi") diff --git a/ui/v2.5/src/docs/en/Manual/Configuration.md b/ui/v2.5/src/docs/en/Manual/Configuration.md index 2d08f9750..3a856b2d4 100644 --- a/ui/v2.5/src/docs/en/Manual/Configuration.md +++ b/ui/v2.5/src/docs/en/Manual/Configuration.md @@ -194,6 +194,8 @@ The following environment variables are also supported: | Environment variable | Remarks | |----------------------|---------| | `STASH_SQLITE_CACHE_SIZE` | Sets the SQLite cache size. See https://www.sqlite.org/pragma.html#pragma_cache_size. Default is `-2000` which is 2MB. | +| `STASH_HW_TEST_TIMEOUT` | Sets the Hardware Acceleration test timeout in seconds. Default is 10 seconds +| `STASH_HW_DRI_DEVICE` | Overrides the default `/dev/dri` device used for VAAPI hardware acceleration. Default is `/dev/dri/renderD128` ### Custom favicon From c9d0afee56d4bff5d1049b8e81ef6a200dc411d7 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:14:25 +1100 Subject: [PATCH 156/177] Fix tagger modal issues (#6736) * Make modal field/value styling consistent Fixes URL list in studio list styling * Add stash id pill to studio and tag modals * Fix create parent check box * Allow excluding parent studio Disabled the create checkbox if parent studio is not excluded and does not exist. * Don't render modal on every studio * Show dialog when refreshing tags --- .../src/components/Tagger/PerformerModal.tsx | 10 +- .../components/Tagger/scenes/StudioModal.tsx | 69 ++++---- .../Tagger/studios/StudioTagger.tsx | 30 ++-- ui/v2.5/src/components/Tagger/styles.scss | 57 +++--- .../src/components/Tagger/tags/TagModal.tsx | 59 ++++--- .../src/components/Tagger/tags/TagTagger.tsx | 165 ++++++++++++------ 6 files changed, 232 insertions(+), 158 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/PerformerModal.tsx b/ui/v2.5/src/components/Tagger/PerformerModal.tsx index 9b2434165..b872bcd31 100755 --- a/ui/v2.5/src/components/Tagger/PerformerModal.tsx +++ b/ui/v2.5/src/components/Tagger/PerformerModal.tsx @@ -92,7 +92,7 @@ const PerformerModal: React.FC = ({ return (
    -
    +
    {!create && (
    {truncate ? ( -
    +
    ) : ( - {text} + {text} )}
    ); @@ -126,7 +126,7 @@ const PerformerModal: React.FC = ({ return (
    -
    +
    {!create && (
    -
    +
      {text.map((t, i) => (
    • diff --git a/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx b/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx index a77025d57..1d5149b79 100644 --- a/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import cx from "classnames"; import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; @@ -7,19 +7,16 @@ import * as GQL from "src/core/generated-graphql"; import { useFindStudio } from "src/core/StashService"; import { Icon } from "src/components/Shared/Icon"; import { ModalComponent } from "src/components/Shared/Modal"; -import { - faCheck, - faExternalLinkAlt, - faTimes, -} from "@fortawesome/free-solid-svg-icons"; +import { faCheck, faTimes } from "@fortawesome/free-solid-svg-icons"; import { Button, Form } from "react-bootstrap"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { excludeFields } from "src/utils/data"; import { ExternalLink } from "src/components/Shared/ExternalLink"; +import { StashIDPill } from "src/components/Shared/StashID"; interface IStudioDetailsProps { studio: GQL.ScrapedSceneStudioDataFragment; - link?: string; + endpoint?: string; excluded: Record; toggleField: (field: string) => void; isNew?: boolean; @@ -27,7 +24,7 @@ interface IStudioDetailsProps { const StudioDetails: React.FC = ({ studio, - link, + endpoint, excluded, toggleField, isNew = false, @@ -59,13 +56,15 @@ const StudioDetails: React.FC = ({ function maybeRenderField( id: string, text: string | null | undefined, - isSelectable: boolean = true + isSelectable: boolean = true, + messageId?: string ) { if (!text) return; + if (!messageId) messageId = id; return (
      -
      +
      {isSelectable && ( )} - : + :
      @@ -93,7 +92,7 @@ const StudioDetails: React.FC = ({ return (
      -
      +
      {!isNew && (
      -
      +
        {text.map((t, i) => (
      • @@ -123,15 +122,14 @@ const StudioDetails: React.FC = ({ } function maybeRenderStashBoxLink() { - if (!link) return; + const base = endpoint?.match(/https?:\/\/.*?\//)?.[0]; + if (!base || !studio.remote_site_id) return; return ( -
        - - - - -
        + ); } @@ -145,7 +143,12 @@ const StudioDetails: React.FC = ({ {maybeRenderField("details", studio.details)} {maybeRenderField("aliases", studio.aliases)} {maybeRenderField("tags", studio.tags?.map((t) => t.name).join(", "))} - {maybeRenderField("parent_studio", studio.parent?.name, false)} + {maybeRenderField( + "parent_id", + studio.parent?.name, + true, + "parent_studio" + )} {maybeRenderStashBoxLink()}
      @@ -207,6 +210,10 @@ const StudioModal: React.FC = ({ !!studio.parent ); + useEffect(() => { + setCreateParentStudio(!excluded.parent_id && !!studio.parent); + }, [excluded.parent_id, studio.parent]); + let sendParentStudio = true; // The parent studio exists, need to check if it has a Stash ID. const queryResult = useFindStudio(studio.parent?.stored_id ?? ""); @@ -303,30 +310,28 @@ const StudioModal: React.FC = ({ handleStudioCreate(studioData, parentData); } - const base = endpoint?.match(/https?:\/\/.*?\//)?.[0]; - const link = base ? `${base}studios/${studio.remote_site_id}` : undefined; - const parentLink = base - ? `${base}studios/${studio.parent?.remote_site_id}` - : undefined; - function maybeRenderParentStudio() { // There is no parent studio or it already has a Stash ID - if (!studio.parent || !sendParentStudio) { + if (!studio.parent || !sendParentStudio || excluded.parent_id) { return; } + // force create if there is no current parent studio and parent studio is not excluded + const mustCreateParent = !studio.parent.stored_id; + return (
      -
      + setCreateParentStudio(!createParentStudio)} /> -
      + {maybeRenderParentStudioDetails()}
      ); @@ -342,7 +347,7 @@ const StudioModal: React.FC = ({ studio={studio.parent} excluded={parentExcluded} toggleField={(field) => toggleParentField(field)} - link={parentLink} + endpoint={endpoint} isNew /> ); @@ -365,7 +370,7 @@ const StudioModal: React.FC = ({ studio={studio} excluded={excluded} toggleField={(field) => toggleField(field)} - link={link} + endpoint={endpoint} /> {maybeRenderParentStudio()} diff --git a/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx b/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx index 645fb19c2..b7b717f7b 100644 --- a/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx +++ b/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx @@ -385,20 +385,6 @@ const StudioTaggerList: React.FC = ({ return (
      - {modalStudio && ( - setModalStudio(undefined)} - modalVisible={modalStudio.stored_id === studio.id} - studio={modalStudio} - handleStudioCreate={handleStudioUpdate} - excludedStudioFields={config.excludedStudioFields} - icon={faTags} - header={intl.formatMessage({ - id: "studio_tagger.update_studio", - })} - endpoint={selectedEndpoint.endpoint} - /> - )}
      @@ -452,6 +438,20 @@ const StudioTaggerList: React.FC = ({ entityName="studio" /> )} + {modalStudio && ( + setModalStudio(undefined)} + modalVisible={!!modalStudio.stored_id} + studio={modalStudio} + handleStudioCreate={handleStudioUpdate} + excludedStudioFields={config.excludedStudioFields} + icon={faTags} + header={intl.formatMessage({ + id: "studio_tagger.update_studio", + })} + endpoint={selectedEndpoint.endpoint} + /> + )}
      )} - : + :
      @@ -110,17 +113,13 @@ const TagModal: React.FC = ({ function maybeRenderStashBoxLink() { const base = endpoint?.match(/https?:\/\/.*?\//)?.[0]; - const link = base ? `${base}tags/${tag.remote_site_id}` : undefined; - - if (!link) return; + if (!base || !tag.remote_site_id) return; return ( -
      - - - - -
      + ); } @@ -133,7 +132,7 @@ const TagModal: React.FC = ({ return (
      -
      +
      {isSelectable && (
      diff --git a/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx b/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx index 8b22a5920..e3674635d 100644 --- a/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx +++ b/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx @@ -11,6 +11,7 @@ import { useJobsSubscribe, mutateStashBoxBatchTagTag, getClient, + useTagCreate, } from "src/core/StashService"; import { useConfigurationContext } from "src/hooks/Config"; @@ -27,6 +28,10 @@ import { BatchAddModal, } from "src/components/Shared/BatchModals"; import { StashBoxSelectorField } from "../StashBoxSelector"; +import { apolloError } from "src/utils"; +import TagModal from "./TagModal"; +import { faTags } from "@fortawesome/free-solid-svg-icons"; +import { uniq } from "lodash-es"; type JobFragment = Pick< GQL.Job, @@ -59,6 +64,7 @@ const TagTaggerList: React.FC = ({ const intl = useIntl(); const [loading, setLoading] = useState(false); + const [searchResults, setSearchResults] = useState< Record >({}); @@ -94,6 +100,13 @@ const TagTaggerList: React.FC = ({ }, }); + const [modalTag, setModalTag] = useState< + | { + existingTag: GQL.TagListDataFragment; + scrapedTag: GQL.ScrapedSceneTagDataFragment; + } + | undefined + >(); const [error, setError] = useState< Record >({}); @@ -128,64 +141,30 @@ const TagTaggerList: React.FC = ({ setLoading(true); }; + const [createTag] = useTagCreate(); const updateTag = useUpdateTag(); - const doBoxUpdate = (tagID: string, stashID: string, endpoint: string) => { + const doBoxUpdate = ( + tag: GQL.TagListDataFragment, + stashID: string, + endpoint: string + ) => { setLoadingUpdate(stashID); setError({ ...error, - [tagID]: undefined, + [tag.id]: undefined, }); stashBoxTagQuery(stashID, endpoint) .then(async (queryData) => { const data = queryData.data?.scrapeSingleTag ?? []; if (data.length > 0) { - const stashboxTag = data[0]; - const updateData: GQL.TagUpdateInput = { - id: tagID, - }; - - if ( - !(config.excludedTagFields ?? []).includes("name") && - stashboxTag.name - ) { - updateData.name = stashboxTag.name; - } - - if ( - stashboxTag.description && - !(config.excludedTagFields ?? []).includes("description") - ) { - updateData.description = stashboxTag.description; - } - - if ( - stashboxTag.alias_list && - stashboxTag.alias_list.length > 0 && - !(config.excludedTagFields ?? []).includes("aliases") - ) { - updateData.aliases = stashboxTag.alias_list; - } - - if (stashboxTag.remote_site_id) { - updateData.stash_ids = await mergeTagStashIDs(tagID, [ - { - endpoint, - stash_id: stashboxTag.remote_site_id, - }, - ]); - } - - const res = await updateTag(updateData); - if (!res?.data?.tagUpdate) { - setError({ - ...error, - [tagID]: { - message: `Failed to update tag`, - details: res?.errors?.[0]?.message ?? "", - }, - }); - } + setModalTag({ + scrapedTag: { + ...data[0], + stored_id: tag.id, + }, + existingTag: tag, + }); } }) .finally(() => setLoadingUpdate(undefined)); @@ -205,6 +184,75 @@ const TagTaggerList: React.FC = ({ setShowBatchUpdate(false); }; + function handleSaveError(tagID: string, name: string, message: string) { + setError({ + ...error, + [tagID]: { + message: intl.formatMessage( + { id: "tag_tagger.failed_to_save_tag" }, + { tag: name } + ), + details: + message === "UNIQUE constraint failed: tags.name" + ? intl.formatMessage({ + id: "tag_tagger.name_already_exists", + }) + : message, + }, + }); + } + + const handleTagUpdate = async ( + input: GQL.TagCreateInput, + parentInput?: GQL.TagCreateInput + ) => { + const { existingTag, scrapedTag: tag } = modalTag!; + const tagID = existingTag.id; + setModalTag(undefined); + + if (tagID) { + if (parentInput) { + try { + // cannot update parent tags, since there may be many + if (!!input.parent_ids?.length) { + // ignore + } else { + const parentRes = await createTag({ + variables: { input: parentInput }, + }); + const parentID = parentRes.data?.tagCreate?.id; + if (parentID) { + // merge parent ids below + input.parent_ids = [parentID]; + } + } + } catch (e) { + handleSaveError(tagID, parentInput.name, apolloError(e)); + } + } + + // always merge parent ids if included + if (input.parent_ids) { + input.parent_ids = uniq( + existingTag.parents.map((p) => p.id).concat(input.parent_ids) + ); + } + + const updateData: GQL.TagUpdateInput = { + ...input, + id: tagID, + }; + updateData.stash_ids = await mergeTagStashIDs( + tagID, + input.stash_ids ?? [] + ); + + const res = await updateTag(updateData); + if (!res?.data?.tagUpdate) + handleSaveError(tagID, tag.name ?? "", res?.errors?.[0]?.message ?? ""); + } + }; + const handleTaggedTag = ( tag: Pick & Partial> @@ -292,7 +340,7 @@ const TagTaggerList: React.FC = ({ } /> + + {children}
      ); diff --git a/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx b/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx index 6e9c13ea4..eace4b7dc 100644 --- a/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx @@ -19,6 +19,10 @@ import { BooleanSetting, Setting, SettingGroup } from "../Inputs"; import { ManualLink } from "src/components/Help/context"; import { Icon } from "src/components/Shared/Icon"; import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; +import { + AutoTagConfirmDialog, + AutoTagWarning, +} from "src/components/Shared/AutoTagConfirmDialog"; import { useSettings } from "../context"; interface IAutoTagOptions { @@ -78,6 +82,7 @@ export const LibraryTasks: React.FC = () => { const [dialogOpen, setDialogOpenState] = useState({ scan: false, autoTag: false, + autoTagAlert: false, identify: false, generate: false, }); @@ -224,12 +229,29 @@ export const LibraryTasks: React.FC = () => { } } + function renderAutoTagAlert() { + return ( + { + setDialogOpen({ autoTagAlert: false }); + runAutoTag(); + }} + onCancel={() => setDialogOpen({ autoTagAlert: false })} + /> + ); + } + function renderAutoTagDialog() { if (!dialogOpen.autoTag) { return; } - return ; + return ( + + + + ); } function onAutoTagDialogClosed(paths?: string[]) { @@ -341,6 +363,7 @@ export const LibraryTasks: React.FC = () => { return ( {renderScanDialog()} + {renderAutoTagAlert()} {renderAutoTagDialog()} {maybeRenderIdentifyDialog()} {renderGenerateDialog()} @@ -426,9 +449,9 @@ export const LibraryTasks: React.FC = () => { variant="secondary" type="submit" className="mr-2" - onClick={() => runAutoTag()} + onClick={() => setDialogOpen({ autoTagAlert: true })} > - + + { + setIsAutoTagAlertOpen(false); if (props.onAutoTag) { props.onAutoTag(); } }} - > - - + onCancel={() => setIsAutoTagAlertOpen(false)} + />
      ); } diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 21e6eb696..024cf1ff5 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -127,11 +127,15 @@ .folder-list { list-style-type: none; margin: 0; - max-height: 30vw; + max-height: 300px; overflow-x: auto; padding-bottom: 0.5rem; padding-top: 1rem; + &:not(:last-child) { + margin-bottom: 1rem; + } + &-item { white-space: nowrap; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index e27d51310..cafbb87dc 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -512,6 +512,8 @@ "auto_tagging_paths": "Auto tagging the following paths" }, "auto_tag_based_on_filenames": "Auto tag content based on file paths.", + "auto_tag_confirm": "This will attempt to match your content against existing metadata.", + "auto_tag_warning": "This process cannot be undone and may produce incorrect matches.", "auto_tagging": "Auto tagging", "backing_up_database": "Backing up database", "backup_and_download": "Performs a backup of the database and downloads the resulting file.", From b4c7ad4b8120f28364ae9d7d6a5858cf5994d8f0 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:29:49 +1100 Subject: [PATCH 159/177] Match exact tag names for batch tagger and show exact matches first for query (#6739) * Enforce exact name matching for tag batch tagger * Sort exact matches first for tag stashbox query --- internal/api/resolver_query_scraper.go | 20 +++++++++++++++++++- internal/manager/task_stash_box_tag.go | 23 ++++++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/internal/api/resolver_query_scraper.go b/internal/api/resolver_query_scraper.go index 86d449921..353bb1a32 100644 --- a/internal/api/resolver_query_scraper.go +++ b/internal/api/resolver_query_scraper.go @@ -6,6 +6,7 @@ import ( "fmt" "slices" "strconv" + "strings" "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" @@ -363,7 +364,8 @@ func (r *queryResolver) ScrapeSingleTag(ctx context.Context, source scraper.Sour client := r.newStashBoxClient(*b) var ret []*models.ScrapedTag - out, err := client.QueryTag(ctx, *input.Query) + query := *input.Query + out, err := client.QueryTag(ctx, query) if err != nil { return nil, err @@ -383,6 +385,22 @@ func (r *queryResolver) ScrapeSingleTag(ctx context.Context, source scraper.Sour }); err != nil { return nil, err } + + // tag name query returns results that may not match the query exactly. + // if there is an exact match, it should be first + if query != "" { + for i, result := range ret { + if strings.EqualFold(result.Name, query) { + // prepend exact match to the front of the slice + if i != 0 { + ret = append([]*models.ScrapedTag{result}, append(ret[:i], ret[i+1:]...)...) + } + + break + } + } + } + return ret, nil } diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index ec17fac06..264e7e96c 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strconv" + "strings" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/match" @@ -589,8 +590,11 @@ func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models. client := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns())) + nameQuery := "" + switch { case t.name != nil: + nameQuery = *t.name results, err = client.QueryTag(ctx, *t.name) case t.stashID != nil: results, err = client.QueryTag(ctx, *t.stashID) @@ -616,6 +620,7 @@ func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models. if remoteID != "" { results, err = client.QueryTag(ctx, remoteID) } else { + nameQuery = t.tag.Name results, err = client.QueryTag(ctx, t.tag.Name) } } @@ -628,7 +633,23 @@ func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models. return nil, nil } - result := results[0] + var result *models.ScrapedTag + + // QueryTag returns tags that partially match the name, so find the exact match if searching by name + if nameQuery != "" { + for _, r := range results { + if strings.EqualFold(r.Name, nameQuery) { + result = r + break + } + } + } else { + result = results[0] + } + + if result == nil { + return nil, nil + } if err := r.WithReadTxn(ctx, func(ctx context.Context) error { return match.ScrapedTagHierarchy(ctx, r.Tag, result, t.box.Endpoint) From 87eabf08710b36d8304b5b0ab3183430c256e702 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:13:34 +1100 Subject: [PATCH 160/177] Show studio name if studio image not set on detail pages (#6716) * Add StudioLogo component If no studio image is set, shows the studio icon with the studio name. * Add option to always show studio text * Implement studio as text option * Add studio logo to image * Clarify existing show studio as text option --- .../Galleries/GalleryDetails/Gallery.tsx | 21 +++-------- ui/v2.5/src/components/Galleries/styles.scss | 2 +- .../components/Images/ImageDetails/Image.tsx | 16 +++------ ui/v2.5/src/components/Images/styles.scss | 2 +- .../components/Scenes/SceneDetails/Scene.tsx | 16 +++------ ui/v2.5/src/components/Scenes/styles.scss | 2 +- .../SettingsInterfacePanel.tsx | 8 +++++ ui/v2.5/src/components/Shared/StudioLogo.tsx | 35 +++++++++++++++++++ ui/v2.5/src/components/Shared/styles.scss | 12 +++++++ ui/v2.5/src/core/config.ts | 2 ++ ui/v2.5/src/docs/en/Manual/Interface.md | 4 +-- ui/v2.5/src/locales/en-GB.json | 7 +++- 12 files changed, 80 insertions(+), 47 deletions(-) create mode 100644 ui/v2.5/src/components/Shared/StudioLogo.tsx diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx index 18cbeff96..1fce02b32 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx @@ -1,11 +1,6 @@ import { Button, Tab, Nav, Dropdown } from "react-bootstrap"; import React, { useEffect, useMemo, useState } from "react"; -import { - useHistory, - Link, - RouteComponentProps, - Redirect, -} from "react-router-dom"; +import { useHistory, RouteComponentProps, Redirect } from "react-router-dom"; import { FormattedMessage, useIntl } from "react-intl"; import { Helmet } from "react-helmet"; import * as GQL from "src/core/generated-graphql"; @@ -50,6 +45,7 @@ import { useConfigurationContext } from "src/hooks/Config"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { goBackOrReplace } from "src/utils/history"; import { FormattedDate } from "src/components/Shared/Date"; +import { StudioLogo } from "src/components/Shared/StudioLogo"; interface IProps { gallery: GQL.GalleryDataFragment; @@ -66,6 +62,7 @@ export const GalleryPage: React.FC = ({ gallery, add }) => { const Toast = useToast(); const intl = useIntl(); const { configuration } = useConfigurationContext(); + const { showStudioText } = configuration?.ui ?? {}; const showLightbox = useGalleryLightbox(gallery.id, gallery.chapters); const [collapsed, setCollapsed] = useState(false); @@ -415,17 +412,7 @@ export const GalleryPage: React.FC = ({ gallery, add }) => {
      - {gallery.studio && ( -

      - - {`${gallery.studio.name} - -

      - )} +

      diff --git a/ui/v2.5/src/components/Galleries/styles.scss b/ui/v2.5/src/components/Galleries/styles.scss index b59da415e..b05be7856 100644 --- a/ui/v2.5/src/components/Galleries/styles.scss +++ b/ui/v2.5/src/components/Galleries/styles.scss @@ -17,7 +17,7 @@ order: 1; } - .gallery-studio-image { + .studio-logo { flex: 0 0 25%; order: 2; } diff --git a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx index f79d95fca..f885c21bb 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx @@ -1,7 +1,7 @@ import { Tab, Nav, Dropdown } from "react-bootstrap"; import React, { useEffect, useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import { useHistory, Link, RouteComponentProps } from "react-router-dom"; +import { useHistory, RouteComponentProps } from "react-router-dom"; import { Helmet } from "react-helmet"; import { useFindImage, @@ -37,6 +37,7 @@ import { TruncatedText } from "src/components/Shared/TruncatedText"; import { goBackOrReplace } from "src/utils/history"; import { FormattedDate } from "src/components/Shared/Date"; import { GenerateDialog } from "src/components/Dialogs/GenerateDialog"; +import { StudioLogo } from "src/components/Shared/StudioLogo"; interface IProps { image: GQL.ImageDataFragment; @@ -51,6 +52,7 @@ const ImagePage: React.FC = ({ image }) => { const Toast = useToast(); const intl = useIntl(); const { configuration } = useConfigurationContext(); + const { showStudioText } = configuration?.ui ?? {}; const [incrementO] = useImageIncrementO(image.id); const [decrementO] = useImageDecrementO(image.id); @@ -326,17 +328,7 @@ const ImagePage: React.FC = ({ image }) => {
      - {image.studio && ( -

      - - {`${image.studio.name} - -

      - )} +

      diff --git a/ui/v2.5/src/components/Images/styles.scss b/ui/v2.5/src/components/Images/styles.scss index 43ac56590..0a8ca760e 100644 --- a/ui/v2.5/src/components/Images/styles.scss +++ b/ui/v2.5/src/components/Images/styles.scss @@ -9,7 +9,7 @@ order: 1; } - .image-studio-image { + .studio-logo { flex: 0 0 25%; order: 2; } diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index 435b9dce2..35bef7efb 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -7,7 +7,7 @@ import React, { useLayoutEffect, } from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import { useHistory, Link, RouteComponentProps } from "react-router-dom"; +import { useHistory, RouteComponentProps } from "react-router-dom"; import { Helmet } from "react-helmet"; import * as GQL from "src/core/generated-graphql"; import { @@ -56,6 +56,7 @@ import { PatchComponent, PatchContainerComponent } from "src/patch"; import { SceneMergeModal } from "../SceneMergeDialog"; import { goBackOrReplace } from "src/utils/history"; import { FormattedDate } from "src/components/Shared/Date"; +import { StudioLogo } from "src/components/Shared/StudioLogo"; const SubmitStashBoxDraft = lazyComponent( () => import("src/components/Dialogs/SubmitDraft") @@ -190,6 +191,7 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { const [updateScene] = useSceneUpdate(); const [generateScreenshot] = useSceneGenerateScreenshot(); const { configuration } = useConfigurationContext(); + const { showStudioText } = configuration?.ui ?? {}; const [showDraftModal, setShowDraftModal] = useState(false); const boxes = configuration?.general?.stashBoxes ?? []; @@ -674,17 +676,7 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { >
      - {scene.studio && ( -

      - - {`${scene.studio.name} - -

      - )} +

      diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index 8eb63f378..dda699744 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -74,7 +74,7 @@ order: 1; } - .scene-studio-image { + .studio-logo { flex: 0 0 25%; order: 2; } diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index 84608107d..5b4e8c5de 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -372,6 +372,7 @@ export const SettingsInterfacePanel: React.FC = PatchComponent( saveInterface({ showStudioAsText: v })} /> @@ -692,6 +693,13 @@ export const SettingsInterfacePanel: React.FC = PatchComponent( checked={ui.compactExpandedDetails ?? undefined} onChange={(v) => saveUI({ compactExpandedDetails: v })} /> + saveUI({ showStudioText: v })} + /> diff --git a/ui/v2.5/src/components/Shared/StudioLogo.tsx b/ui/v2.5/src/components/Shared/StudioLogo.tsx new file mode 100644 index 000000000..0da9d692a --- /dev/null +++ b/ui/v2.5/src/components/Shared/StudioLogo.tsx @@ -0,0 +1,35 @@ +import { Link } from "react-router-dom"; +import { Studio } from "src/core/generated-graphql"; +import { Icon } from "./Icon"; +import { faVideo } from "@fortawesome/free-solid-svg-icons"; + +export const StudioLogo: React.FC<{ + studio: Pick | undefined | null; + showText?: boolean; +}> = ({ studio, showText = false }) => { + if (!studio) return null; + + const hasLogo = + !showText && + studio.image_path && + !studio.image_path.endsWith("default=true"); + + return ( +

      + + {hasLogo ? ( + {`${studio.name} + ) : ( + + + {studio.name} + + )} + +

      + ); +}; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 024cf1ff5..acc8556eb 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -1256,3 +1256,15 @@ input[type="range"].double-range-slider-max { .text-input + .input-group-append .btn.minimal { background-color: $textfield-bg; } + +.studio-logo a:hover { + color: inherit; +} + +.studio-logo .studio-name { + color: $text-color; + font-size: 1.5rem; + font-weight: 500; + margin-top: 0.5rem; + text-align: center; +} diff --git a/ui/v2.5/src/core/config.ts b/ui/v2.5/src/core/config.ts index a757c7a06..e0cf008b5 100644 --- a/ui/v2.5/src/core/config.ts +++ b/ui/v2.5/src/core/config.ts @@ -49,6 +49,8 @@ export interface IUIConfig { showLinksOnPerformerCard?: boolean; showTagCardOnHover?: boolean; + showStudioText?: boolean; + previewVolume?: number; abbreviateCounters?: boolean; diff --git a/ui/v2.5/src/docs/en/Manual/Interface.md b/ui/v2.5/src/docs/en/Manual/Interface.md index 951fb3323..a045421a3 100644 --- a/ui/v2.5/src/docs/en/Manual/Interface.md +++ b/ui/v2.5/src/docs/en/Manual/Interface.md @@ -19,9 +19,9 @@ The Scene Wall and Marker pages display scene preview videos (mp4) by default. T > **⚠️ Note:** scene/marker preview videos must be generated to see them in the applicable wall page if Video preview type is selected. Likewise, if Animated image is selected, then Image Previews must be generated. -## Show Studios as text +## Show studio overlay as text -By default, a scene's studio will be shown as an image overlay. Checking this option changes this to display studios as a text name instead. +By default, in the grid card view the studio will be shown as an image overlay of the studio logo. Checking this option changes this to display studios as a text name instead. ## Scene Player options diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index cafbb87dc..37b6b6d44 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -712,6 +712,10 @@ "show_all_details": { "description": "When enabled, all content details will be shown by default and each detail item will fit under a single column.", "heading": "Show all details" + }, + "show_studio_text": { + "description": "Always display studio name as text on details views instead of an icon.", + "heading": "Show studio as text" } }, "editing": { @@ -817,7 +821,8 @@ "scene_list": { "heading": "Grid View", "options": { - "show_studio_as_text": "Display studio overlay as text" + "show_studio_as_text": "Display studio overlay as text", + "show_studio_as_text_desc": "By default, the studio logo is shown on cards in the grid. Enable this option to always show the studio name as text instead." } }, "scene_player": { From 2e48dbfc634b29805e7a784fc095db0c1815a70b Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:32:30 +1100 Subject: [PATCH 161/177] Update changelog --- ui/v2.5/src/docs/en/Changelog/v0310.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/v2.5/src/docs/en/Changelog/v0310.md b/ui/v2.5/src/docs/en/Changelog/v0310.md index afb618507..d7403c3b7 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0310.md +++ b/ui/v2.5/src/docs/en/Changelog/v0310.md @@ -50,10 +50,13 @@ * Added support for filtering by stash ID count. ([#6437](https://github.com/stashapp/stash/pull/6437)) * Added support for filtering group by scene count. ([#6593](https://github.com/stashapp/stash/pull/6593)) * Updated Tag list view to be consistent with other list views. ([#6703](https://github.com/stashapp/stash/pull/6703)) +* Added confirmation dialog to Auto Tag task. ([#6735](https://github.com/stashapp/stash/pull/6735)) +* Studio now shows the studio name instead of the studio image if the image is not set or if (new) `Show studio as text` is true. ([#6716](https://github.com/stashapp/stash/pull/6716)) * Installed plugins/scrapers no longer show in the available list. ([#6443](https://github.com/stashapp/stash/pull/6443)) * Name is now populated when searching by stash-box. ([#6447](https://github.com/stashapp/stash/pull/6447)) * Improved performance of group queries on large systems. ([#6478](https://github.com/stashapp/stash/pull/6478)) * Search input is now focused when opening the scraper menu. ([#6704](https://github.com/stashapp/stash/pull/6704)) +* VAAPI dri device can now be overridden using `STASH_HW_DRI_DEVICE` environment variable. ([#6728](https://github.com/stashapp/stash/pull/6728)) * Added support for `{phash}` in `queryURL` scraper field. ([#6701](https://github.com/stashapp/stash/pull/6701)) * Systray notification now shows the port stash is running on. ([#6448](https://github.com/stashapp/stash/pull/6448)) From fd480c5a3ef60e5b9545d39c70e9f3c301be53d8 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:03:58 +1100 Subject: [PATCH 162/177] Exclude zip folders when browsing scenes and galleries (#6740) * Add short cuts when only getting zip/folder ids * Don't show zip folders when viewing scenes and galleries. Zip folders have no results for scenes and galleries, but will for images. --- internal/api/resolver.go | 8 ++ internal/api/resolver_model_folder.go | 31 +++++++ ui/v2.5/graphql/queries/folder.graphql | 11 ++- .../components/List/Filters/FolderFilter.tsx | 83 ++++++++++++++----- ui/v2.5/src/core/StashService.ts | 5 +- 5 files changed, 114 insertions(+), 24 deletions(-) diff --git a/internal/api/resolver.go b/internal/api/resolver.go index 061d0e1a9..b1cec1c9d 100644 --- a/internal/api/resolver.go +++ b/internal/api/resolver.go @@ -7,6 +7,7 @@ import ( "sort" "strconv" + "github.com/99designs/gqlgen/graphql" "github.com/stashapp/stash/internal/build" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/pkg/logger" @@ -145,6 +146,13 @@ func (r *Resolver) withReadTxn(ctx context.Context, fn func(ctx context.Context) return r.repository.WithReadTxn(ctx, fn) } +// idOnly returns true if the query is only asking for the id field. +// This can be used to optimize certain queries where we don't need to load the full object if we're only getting the id. +func (r *Resolver) idOnly(ctx context.Context) bool { + fields := graphql.CollectAllFields(ctx) + return len(fields) == 1 && fields[0] == "id" +} + func (r *queryResolver) MarkerWall(ctx context.Context, q *string) (ret []*models.SceneMarker, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.SceneMarker.Wall(ctx, q) diff --git a/internal/api/resolver_model_folder.go b/internal/api/resolver_model_folder.go index 1fcc144f3..725ca34f8 100644 --- a/internal/api/resolver_model_folder.go +++ b/internal/api/resolver_model_folder.go @@ -17,15 +17,31 @@ func (r *folderResolver) ParentFolder(ctx context.Context, obj *models.Folder) ( return nil, nil } + if r.idOnly(ctx) { + return &models.Folder{ID: *obj.ParentFolderID}, nil + } + return loaders.From(ctx).FolderByID.Load(*obj.ParentFolderID) } +func foldersFromIDs(ids []models.FolderID) []*models.Folder { + ret := make([]*models.Folder, len(ids)) + for i, id := range ids { + ret[i] = &models.Folder{ID: id} + } + return ret +} + func (r *folderResolver) ParentFolders(ctx context.Context, obj *models.Folder) ([]*models.Folder, error) { ids, err := loaders.From(ctx).FolderParentFolderIDs.Load(obj.ID) if err != nil { return nil, err } + if r.idOnly(ctx) { + return foldersFromIDs(ids), nil + } + var errs []error ret, errs := loaders.From(ctx).FolderByID.LoadAll(ids) return ret, firstError(errs) @@ -37,11 +53,26 @@ func (r *folderResolver) SubFolders(ctx context.Context, obj *models.Folder) ([] return nil, err } + if r.idOnly(ctx) { + return foldersFromIDs(ids), nil + } + var errs []error ret, errs := loaders.From(ctx).FolderByID.LoadAll(ids) return ret, firstError(errs) } func (r *folderResolver) ZipFile(ctx context.Context, obj *models.Folder) (*BasicFile, error) { + // shortcut for id only queries + if r.idOnly(ctx) { + if obj.ZipFileID == nil { + return nil, nil + } + + return &BasicFile{ + BaseFile: &models.BaseFile{ID: *obj.ZipFileID}, + }, nil + } + return zipFileResolver(ctx, obj.ZipFileID) } diff --git a/ui/v2.5/graphql/queries/folder.graphql b/ui/v2.5/graphql/queries/folder.graphql index 81f431786..b1119cd61 100644 --- a/ui/v2.5/graphql/queries/folder.graphql +++ b/ui/v2.5/graphql/queries/folder.graphql @@ -1,7 +1,10 @@ -query FindRootFoldersForSelect { +query FindRootFoldersForSelect($zip_file_filter: MultiCriterionInput) { findFolders( filter: { per_page: -1, sort: "path", direction: ASC } - folder_filter: { parent_folder: { modifier: IS_NULL } } + folder_filter: { + parent_folder: { modifier: IS_NULL } + zip_file: $zip_file_filter + } ) { count folders { @@ -34,6 +37,10 @@ query FindFolderHierarchyForIDs($ids: [ID!]!) { # the parent folders will be expanded, so we need the child folders sub_folders { ...SelectFolderData + # get zip file so we can filter out zip folders if needed + zip_file { + id + } } } } diff --git a/ui/v2.5/src/components/List/Filters/FolderFilter.tsx b/ui/v2.5/src/components/List/Filters/FolderFilter.tsx index 95b5121f9..3eaaf0427 100644 --- a/ui/v2.5/src/components/List/Filters/FolderFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/FolderFilter.tsx @@ -1,6 +1,9 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { + CriterionModifier, + FilterMode, FolderDataFragment, + MultiCriterionInput, useFindFolderHierarchyForIDsQuery, useFindFoldersForQueryQuery, useFindRootFoldersForSelectQuery, @@ -159,21 +162,47 @@ function mergeFolderMaps(base: IFolder[], update: IFolder[]): IFolder[] { return ret; } -function useFolderMap( - query: string, - skip?: boolean, - initialSelected?: string[] -) { +function useFolderMap(props: { + query: string; + skip?: boolean; + initialSelected?: string[]; + mode?: FilterMode; +}) { + const { query, skip = false, initialSelected, mode } = props; + const [cachedInitialSelected] = useState(initialSelected ?? []); + // exclude zip folders for scenes and galleries + const excludeZipFolders = + mode === FilterMode.Scenes || mode === FilterMode.Galleries; + + const zipFileFilter: MultiCriterionInput | undefined = useMemo( + () => + excludeZipFolders + ? { + modifier: CriterionModifier.IsNull, + } + : undefined, + [excludeZipFolders] + ); + + const folderFilterForQuery = useMemo( + () => (zipFileFilter ? { zip_file: zipFileFilter } : undefined), + [zipFileFilter] + ); + const { data: rootFoldersResult } = useFindRootFoldersForSelectQuery({ skip, + variables: { + zip_file_filter: zipFileFilter, + }, }); const { data: queryFoldersResult } = useFindFoldersForQueryQuery({ skip: !query, variables: { filter: { q: query, per_page: 200 }, + folder_filter: folderFilterForQuery, }, }); @@ -213,11 +242,14 @@ function useFolderMap( existing = { ...folder.parent_folders[i], expanded: true, - children: folder.parent_folders[i].sub_folders.map((f) => ({ - ...f, - expanded: false, - children: undefined, - })), + children: folder.parent_folders[i].sub_folders + // filter out zip folders if needed + .filter((f) => f.zip_file === null || !excludeZipFolders) + .map((f) => ({ + ...f, + expanded: false, + children: undefined, + })), }; ret.push(existing); } @@ -243,11 +275,14 @@ function useFolderMap( existing = { ...existing, expanded: true, - children: thisFolder.sub_folders.map((f) => ({ - ...f, - expanded: false, - children: undefined, - })), + // filter out zip folders if needed + children: thisFolder.sub_folders + .filter((f) => f.zip_file === null || !excludeZipFolders) + .map((f) => ({ + ...f, + expanded: false, + children: undefined, + })), }; currentParent!.children![existingIndex] = existing; @@ -255,7 +290,7 @@ function useFolderMap( } }); return ret; - }, [initialSelectedResult]); + }, [initialSelectedResult, excludeZipFolders]); const mergedRootFolders = useMemo(() => { if (query) { @@ -347,7 +382,10 @@ function useFolderMap( // query children folders if not already loaded if (folder.children === undefined) { - const subFolderResult = await queryFindSubFolders(folder.id); + const subFolderResult = await queryFindSubFolders( + folder.id, + excludeZipFolders + ); setFolderMap((current) => current.map( replaceFolder({ @@ -419,17 +457,19 @@ export const FolderSelector: React.FC<{ interface IInputFilterProps { criterion: FolderCriterion; setCriterion: (c: FolderCriterion) => void; + mode?: FilterMode; } export const FolderFilter: React.FC = ({ criterion, setCriterion, + mode, }) => { const intl = useIntl(); const [query, setQuery] = useState(""); const [displayQuery, onQueryChange] = useDebouncedState(query, setQuery, 250); - const { folderMap, onToggleExpanded } = useFolderMap(query); + const { folderMap, onToggleExpanded } = useFolderMap({ query, mode }); const messages = defineMessages({ sub_folder_depth: { @@ -599,11 +639,12 @@ export const SidebarFolderFilter: React.FC< const multipleSelected = criterion.value.items.length > 1 || criterion.value.excluded.length > 0; - const { folderMap, onToggleExpanded } = useFolderMap( + const { folderMap, onToggleExpanded } = useFolderMap({ query, skip, - criterion.value.items.map((i) => i.id) - ); + initialSelected: criterion.value.items.map((i) => i.id), + mode: filter.mode, + }); function onSelect(folder: IFolder) { // maintain sub-folder select if present diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index bfd76f7ee..33b778343 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -515,12 +515,15 @@ export const useFindSavedFilters = (mode?: GQL.FilterMode) => variables: { mode }, }); -export const queryFindSubFolders = (id: string) => +export const queryFindSubFolders = (id: string, excludeZipFolders?: boolean) => client.query({ query: GQL.FindFoldersForQueryDocument, variables: { folder_filter: { parent_folder: { value: id, modifier: GQL.CriterionModifier.Equals }, + zip_file: excludeZipFolders + ? { modifier: GQL.CriterionModifier.IsNull } + : undefined, }, filter: { per_page: -1, From eeee081eb7d6979be5a8f399e5cce136882be06e Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:36:31 +0200 Subject: [PATCH 163/177] Refactor README.md for better clarity and structure --- README.md | 58 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 5ccefe4bc..2d90a76ea 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,10 @@ ![Screenshot of Stash web application interface](docs/readme_assets/demo_image.png) -* Stash gathers information about videos in your collection from the internet, and is extensible through the use of community-built plugins for a large number of content producers and sites. -* Stash supports a wide variety of both video and image formats. -* You can tag videos and find them later. -* Stash provides statistics about performers, tags, studios and more. +- Stash gathers information about videos in your collection from the internet, and is extensible through the use of community-built plugins for a large number of content producers and sites. +- Stash supports a wide variety of both video and image formats. +- You can tag videos and find them later. +- Stash provides statistics about performers, tags, studios and more. You can [watch a SFW demo video](https://vimeo.com/545323354) to see it in action. @@ -24,17 +24,19 @@ For further information you can consult the [documentation](https://docs.stashap # Installing Stash +> [!tip] Step-by-step instructions are available at [docs.stashapp.cc/installation](https://docs.stashapp.cc/installation/). -#### Windows Users: - -As of version 0.27.0, Stash no longer supports _Windows 7, 8, Server 2008 and Server 2012._ -At least Windows 10 or Server 2016 is required. - -#### Mac Users: - -As of version 0.29.0, Stash requires _macOS 11 Big Sur_ or later. -Stash can still be run through docker on older versions of macOS. +> [!important] +>**Windows Users** +> +>As of version 0.27.0, Stash no longer supports _Windows 7, 8, Server 2008 and Server 2012._ +>At least Windows 10 or Server 2016 is required. +> +>**macOS Users** +> +> As of version 0.29.0, Stash requires _macOS 11 Big Sur_ or later. +> Stash can still be run through docker on older versions of macOS. Windows | macOS | Linux | Docker :---:|:---:|:---:|:---: @@ -85,23 +87,23 @@ The badge below shows the current translation status of Stash across all support Need help or want to get involved? Start with the documentation, then reach out to the community if you need further assistance. -- Documentation - - Official docs: https://docs.stashapp.cc - official guides guides and troubleshooting. - - In-app manual: press Shift + ? in the app or view the manual online: https://docs.stashapp.cc/in-app-manual. - - FAQ: https://discourse.stashapp.cc/c/support/faq/28 - common questions and answers. - - Community wiki: https://discourse.stashapp.cc/tags/c/community-wiki/22/stash - guides, how-to’s and tips. +### Documentation +- [Official documentation](https://docs.stashapp.cc) - official guides guides and troubleshooting. +- [In-app manual](https://docs.stashapp.cc/in-app-manual) press Shift + ? in the app or view the manual online. +- [FAQ](https://discourse.stashapp.cc/c/support/faq/28) - common questions and answers. +- [Community wiki](https://discourse.stashapp.cc/tags/c/community-wiki/22/stash) - guides, how-to’s and tips. -- Community & discussion - - Community forum: https://discourse.stashapp.cc - community support, feature requests and discussions. - - Discord: https://discord.gg/2TsNFKt - real-time chat and community support. - - GitHub discussions: https://github.com/stashapp/stash/discussions - community support and feature discussions. - - Lemmy community: https://discuss.online/c/stashapp - Reddit-style community space. +### Community & discussion +- [Community forum](https://discourse.stashapp.cc) - community support, feature requests and discussions. +- [Discord](https://discord.gg/2TsNFKt) - real-time chat and community support. +- [GitHub discussions](https://github.com/stashapp/stash/discussions) - community support and feature discussions. +- [Lemmy community](https://discuss.online/c/stashapp) - board-style community space. -- Community scrapers & plugins - - Metadata sources: https://docs.stashapp.cc/metadata-sources/ - - Plugins: https://docs.stashapp.cc/plugins/ - - Themes: https://docs.stashapp.cc/themes/ - - Other projects: https://docs.stashapp.cc/other-projects/ +### Community scrapers & plugins +- [Metadata sources](https://docs.stashapp.cc/metadata-sources/) +- [Plugins](https://docs.stashapp.cc/plugins/) +- [Themes](https://docs.stashapp.cc/themes/) +- [Other projects](https://docs.stashapp.cc/other-projects/) # For Developers From c861d3991a9aa4404ff6739b2515343957340355 Mon Sep 17 00:00:00 2001 From: "(Moai Emoji)" <25407129+SandiyosDev@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:01:43 -0500 Subject: [PATCH 164/177] Fix 'not equals' custom field to include unset objects (#6742) * Fix custom field 'not equals' to include unset objects * also fix Excludes and NotBetween null handling --- pkg/sqlite/custom_fields.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/sqlite/custom_fields.go b/pkg/sqlite/custom_fields.go index d78e3f9ab..22dbbfeb2 100644 --- a/pkg/sqlite/custom_fields.go +++ b/pkg/sqlite/custom_fields.go @@ -261,8 +261,8 @@ func (h *customFieldsFilterHandler) handleCriterion(f *filterBuilder, joinAs str h.innerJoin(f, joinAs, cc.Field) f.addWhere(fmt.Sprintf("%[1]s.value IN %s", joinAs, getInBinding(len(cv))), cv...) case models.CriterionModifierNotEquals: - h.innerJoin(f, joinAs, cc.Field) - f.addWhere(fmt.Sprintf("%[1]s.value NOT IN %s", joinAs, getInBinding(len(cv))), cv...) + h.leftJoin(f, joinAs, cc.Field) + f.addWhere(fmt.Sprintf("(%[1]s.value NOT IN %s OR %[1]s.value IS NULL)", joinAs, getInBinding(len(cv))), cv...) case models.CriterionModifierIncludes: clauses := make([]sqlClause, len(cv)) for i, v := range cv { @@ -272,7 +272,7 @@ func (h *customFieldsFilterHandler) handleCriterion(f *filterBuilder, joinAs str f.whereClauses = append(f.whereClauses, clauses...) case models.CriterionModifierExcludes: for _, v := range cv { - f.addWhere(fmt.Sprintf("%[1]s.value NOT LIKE ?", joinAs), fmt.Sprintf("%%%v%%", v)) + f.addWhere(fmt.Sprintf("(%[1]s.value NOT LIKE ? OR %[1]s.value IS NULL)", joinAs), fmt.Sprintf("%%%v%%", v)) } h.leftJoin(f, joinAs, cc.Field) case models.CriterionModifierMatchesRegex: @@ -315,8 +315,8 @@ func (h *customFieldsFilterHandler) handleCriterion(f *filterBuilder, joinAs str h.innerJoin(f, joinAs, cc.Field) f.addWhere(fmt.Sprintf("%s.value BETWEEN ? AND ?", joinAs), cv[0], cv[1]) case models.CriterionModifierNotBetween: - h.innerJoin(f, joinAs, cc.Field) - f.addWhere(fmt.Sprintf("%s.value NOT BETWEEN ? AND ?", joinAs), cv[0], cv[1]) + h.leftJoin(f, joinAs, cc.Field) + f.addWhere(fmt.Sprintf("(%s.value NOT BETWEEN ? AND ? OR %[1]s.value IS NULL)", joinAs), cv[0], cv[1]) case models.CriterionModifierLessThan: if len(cv) != 1 { f.setError(fmt.Errorf("expected 1 value for custom field criterion modifier LESS_THAN, got %d", len(cv))) From 020c242ea6c42cec7e0b38ce37c57a0af76a9bfe Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Sun, 29 Mar 2026 15:07:54 -0700 Subject: [PATCH 165/177] Fix: Remove padFuzzyDate From Performer (#6757) --- pkg/stashbox/performer.go | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/pkg/stashbox/performer.go b/pkg/stashbox/performer.go index 589fd29b6..5b25b4a59 100644 --- a/pkg/stashbox/performer.go +++ b/pkg/stashbox/performer.go @@ -242,11 +242,11 @@ func performerFragmentToScrapedPerformer(p graphql.PerformerFragment) *models.Sc } if p.BirthDate != nil { - sp.Birthdate = padFuzzyDate(p.BirthDate) + sp.Birthdate = p.BirthDate } if p.DeathDate != nil { - sp.DeathDate = padFuzzyDate(p.DeathDate) + sp.DeathDate = p.DeathDate } if p.Gender != nil { @@ -290,23 +290,6 @@ func performerFragmentToScrapedPerformer(p graphql.PerformerFragment) *models.Sc return sp } -func padFuzzyDate(date *string) *string { - if date == nil { - return nil - } - - var paddedDate string - switch len(*date) { - case 10: - paddedDate = *date - case 7: - paddedDate = fmt.Sprintf("%s-01", *date) - case 4: - paddedDate = fmt.Sprintf("%s-01-01", *date) - } - return &paddedDate -} - // FindPerformerByID queries stash-box for a performer by ID. func (c Client) FindPerformerByID(ctx context.Context, id string) (*models.ScrapedPerformer, error) { performer, err := c.client.FindPerformerByID(ctx, id) From 8af2cfe5255f70d8bc0864e3a275571d2db96de0 Mon Sep 17 00:00:00 2001 From: "(Moai Emoji)" <25407129+SandiyosDev@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:09:28 -0500 Subject: [PATCH 166/177] Add mutex to repositoryCache for thread safety (#6741) * Add mutex to package cache to prevent concurrent map write crash * use sync.Once for cache init --- pkg/pkg/cache.go | 32 +++++++++++++++++++++++--------- pkg/pkg/manager.go | 8 +++++--- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/pkg/pkg/cache.go b/pkg/pkg/cache.go index 9d36bdd1d..e94b2cb41 100644 --- a/pkg/pkg/cache.go +++ b/pkg/pkg/cache.go @@ -1,6 +1,7 @@ package pkg import ( + "sync" "time" ) @@ -10,22 +11,23 @@ type cacheEntry struct { } type repositoryCache struct { + mu sync.RWMutex // cache maps the URL to the last modified time and the data cache map[string]cacheEntry } -func (c *repositoryCache) ensureCache() { - if c.cache == nil { - c.cache = make(map[string]cacheEntry) - } -} - func (c *repositoryCache) lastModified(url string) *time.Time { if c == nil { return nil } - c.ensureCache() + c.mu.RLock() + defer c.mu.RUnlock() + + if c.cache == nil { + return nil + } + e, found := c.cache[url] if !found { @@ -36,7 +38,13 @@ func (c *repositoryCache) lastModified(url string) *time.Time { } func (c *repositoryCache) getPackageList(url string) []RemotePackage { - c.ensureCache() + c.mu.RLock() + defer c.mu.RUnlock() + + if c.cache == nil { + return nil + } + e, found := c.cache[url] if !found { @@ -51,7 +59,13 @@ func (c *repositoryCache) cacheList(url string, lastModified time.Time, data []R return } - c.ensureCache() + c.mu.Lock() + defer c.mu.Unlock() + + if c.cache == nil { + c.cache = make(map[string]cacheEntry) + } + c.cache[url] = cacheEntry{ lastModified: lastModified, data: data, diff --git a/pkg/pkg/manager.go b/pkg/pkg/manager.go index 18fa4e0d1..4024191ad 100644 --- a/pkg/pkg/manager.go +++ b/pkg/pkg/manager.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "path/filepath" + "sync" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" @@ -31,13 +32,14 @@ type Manager struct { Client *http.Client - cache *repositoryCache + cacheOnce sync.Once + cache *repositoryCache } func (m *Manager) getCache() *repositoryCache { - if m.cache == nil { + m.cacheOnce.Do(func() { m.cache = &repositoryCache{} - } + }) return m.cache } From af07fea2890003fc2e7ad05ec1ebc5af28d51cdf Mon Sep 17 00:00:00 2001 From: feederbox826 Date: Sun, 29 Mar 2026 19:57:16 -0400 Subject: [PATCH 167/177] [CI] add vips-heif (#6765) --- docker/ci/x86_64/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/ci/x86_64/Dockerfile b/docker/ci/x86_64/Dockerfile index 6a9c6b76d..2161cb6af 100644 --- a/docker/ci/x86_64/Dockerfile +++ b/docker/ci/x86_64/Dockerfile @@ -12,7 +12,7 @@ RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then BIN=stash-linux-arm32v6; \ FROM --platform=$TARGETPLATFORM alpine:latest AS app COPY --from=binary /stash /usr/bin/ -RUN apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg tzdata vips vips-tools \ +RUN apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg tzdata vips vips-tools vips-heif \ && pip install --break-system-packages mechanicalsoup cloudscraper stashapp-tools ENV STASH_CONFIG_FILE=/root/.stash/config.yml From fe2a8eb0fdeb1180e5377bc28f4161abd7ac515a Mon Sep 17 00:00:00 2001 From: eb2292 <46068962+eb2292@users.noreply.github.com> Date: Sun, 29 Mar 2026 20:04:10 -0400 Subject: [PATCH 168/177] Add keyboard shortcut "d d" to delete scene (#6755) Co-authored-by: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> --- ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx | 2 ++ ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md | 1 + 2 files changed, 3 insertions(+) diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index 35bef7efb..7d1b245fc 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -256,6 +256,7 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { Mousetrap.bind("p p", () => onQueuePrevious()); Mousetrap.bind("p r", () => onQueueRandom()); Mousetrap.bind(",", () => setCollapsed(!collapsed)); + Mousetrap.bind("d d", () => setIsDeleteAlertOpen(true)); Mousetrap.bind("c c", () => { onGenerateScreenshot(getPlayerPosition()); }); @@ -271,6 +272,7 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { Mousetrap.unbind("i"); Mousetrap.unbind("h"); Mousetrap.unbind("o"); + Mousetrap.unbind("d d"); Mousetrap.unbind("p n"); Mousetrap.unbind("p p"); Mousetrap.unbind("p r"); diff --git a/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md b/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md index f6cd29334..93ba817f6 100644 --- a/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md +++ b/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md @@ -64,6 +64,7 @@ | `,` | Hide/Show sidebar | | `.` | Hide/Show scene scrubber | | `o` | Increment O-Counter | +| `d d` | Delete scene | | Ratings || | `r {1-5}` | Set rating (stars) | | `r 0` | Unset rating (stars) | From 86188e5ff7067c6fe78d7ad5785555cf0b15d5ef Mon Sep 17 00:00:00 2001 From: smith113-p <205463041+smith113-p@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:07:04 +0100 Subject: [PATCH 169/177] Use StashIDPill for displaying the scraped stash ID (#6761) This is more consistent with other places that stash IDs are shown, simplifies the code a bit, and lets you see at a glance which stash box is being used. --- .../Tagger/scenes/StashSearchResult.tsx | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx index f39fef103..add295c49 100755 --- a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx @@ -29,9 +29,9 @@ import { SceneTaggerModalsState } from "./sceneTaggerModals"; import PerformerResult from "./PerformerResult"; import StudioResult from "./StudioResult"; import { useInitialState } from "src/hooks/state"; -import { getStashboxBase } from "src/utils/stashbox"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { compareScenesForSort } from "./utils"; +import { StashIDPill } from "src/components/Shared/StashID"; const getDurationIcon = (matchPercentage: number) => { if (matchPercentage > 65) @@ -325,15 +325,6 @@ const StashSearchResult: React.FC = ({ } }, [isActive, loading, stashScene, index, resolveScene, scene]); - const stashBoxBaseURL = currentSource?.sourceInput.stash_box_endpoint - ? getStashboxBase(currentSource.sourceInput.stash_box_endpoint) - : undefined; - const stashBoxURL = useMemo(() => { - if (stashBoxBaseURL) { - return `${stashBoxBaseURL}scenes/${scene.remote_site_id}`; - } - }, [scene, stashBoxBaseURL]); - const setExcludedField = (name: string, value: boolean) => setExcludedFields({ ...excludedFields, @@ -680,16 +671,20 @@ const StashSearchResult: React.FC = ({ }; const maybeRenderStashBoxID = () => { - if (scene.remote_site_id && stashBoxURL) { + if (scene.remote_site_id && currentSource?.sourceInput.stash_box_endpoint) { return (
      setExcludedField(fields.stash_ids, v)} > - - {scene.remote_site_id} - +
      ); From 0a4b427e1df109ae0092f9c2e0a84d8f272f5f1a Mon Sep 17 00:00:00 2001 From: smith113-p <205463041+smith113-p@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:09:17 +0100 Subject: [PATCH 170/177] Show stash-box name in studio/performer tagger (#6759) --- ui/v2.5/src/components/Tagger/StashBoxSelector.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Tagger/StashBoxSelector.tsx b/ui/v2.5/src/components/Tagger/StashBoxSelector.tsx index 3f5f103a6..c47bc73f1 100644 --- a/ui/v2.5/src/components/Tagger/StashBoxSelector.tsx +++ b/ui/v2.5/src/components/Tagger/StashBoxSelector.tsx @@ -1,6 +1,7 @@ import { Form } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import { StashBox } from "src/core/generated-graphql"; +import { useConfigurationContext } from "src/hooks/Config"; interface IStashBoxSelectorProps { stashBoxes: StashBox[]; @@ -13,6 +14,15 @@ export const StashBoxSelector: React.FC = ({ selectedEndpoint, onEndpointChange, }) => { + const { configuration } = useConfigurationContext(); + + function stashboxNameForEndpoint(endpoint: string) { + let box = configuration?.general.stashBoxes.find( + (sb) => sb.endpoint === endpoint + ); + return `stash-box: ${box?.name ?? endpoint}`; + } + return ( = ({ )} {stashBoxes.map((i) => ( ))} From 1e0b9902a34bc3324f0b052987ff0d9b3ab44524 Mon Sep 17 00:00:00 2001 From: "(Moai Emoji)" <25407129+SandiyosDev@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:18:45 -0500 Subject: [PATCH 171/177] Fix lightbox not reading scale-up setting from config (#6743) --- ui/v2.5/src/hooks/Lightbox/Lightbox.tsx | 43 ++++++++++++++++--------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx index 65c15024c..41c6d4fad 100644 --- a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx +++ b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx @@ -206,8 +206,25 @@ export const LightboxComponent: React.FC = ({ setLightboxSettings({ slideshowDelay: v }); } + const scaleUp = + lightboxSettings?.scaleUp ?? + config?.interface.imageLightbox.scaleUp ?? + false; + + const resetZoomOnNav = + lightboxSettings?.resetZoomOnNav ?? + config?.interface.imageLightbox.resetZoomOnNav ?? + false; + + const scrollMode = + lightboxSettings?.scrollMode ?? + config?.interface.imageLightbox.scrollMode ?? + GQL.ImageLightboxScrollMode.Zoom; + const displayMode = - lightboxSettings?.displayMode ?? GQL.ImageLightboxDisplayMode.FitXy; + lightboxSettings?.displayMode ?? + config?.interface.imageLightbox.displayMode ?? + GQL.ImageLightboxDisplayMode.FitXy; const oldDisplayMode = useRef(displayMode); function setDisplayMode(v: GQL.ImageLightboxDisplayMode) { @@ -250,13 +267,13 @@ export const LightboxComponent: React.FC = ({ // reset zoom status // setResetZoom((r) => !r); // setZoomed(false); - if (lightboxSettings?.resetZoomOnNav) { + if (resetZoomOnNav) { setZoom(1); } setResetPosition((r) => !r); oldIndex.current = index; - }, [index, images.length, lightboxSettings?.resetZoomOnNav]); + }, [index, images.length, resetZoomOnNav]); const getNavOffset = useCallback(() => { if (images.length < 2) return; @@ -288,13 +305,13 @@ export const LightboxComponent: React.FC = ({ // reset zoom status // setResetZoom((r) => !r); // setZoomed(false); - if (lightboxSettings?.resetZoomOnNav) { + if (resetZoomOnNav) { setZoom(1); } setResetPosition((r) => !r); } oldDisplayMode.current = displayMode; - }, [displayMode, lightboxSettings?.resetZoomOnNav]); + }, [displayMode, resetZoomOnNav]); const selectIndex = (e: React.MouseEvent, i: number) => { setIndex(i); @@ -635,7 +652,7 @@ export const LightboxComponent: React.FC = ({ label={intl.formatMessage({ id: "dialogs.lightbox.scale_up.label", })} - checked={lightboxSettings?.scaleUp ?? false} + checked={scaleUp} disabled={displayMode === GQL.ImageLightboxDisplayMode.Original} onChange={(v) => setScaleUp(v.currentTarget.checked)} /> @@ -655,7 +672,7 @@ export const LightboxComponent: React.FC = ({ label={intl.formatMessage({ id: "dialogs.lightbox.reset_zoom_on_nav", })} - checked={lightboxSettings?.resetZoomOnNav ?? false} + checked={resetZoomOnNav} onChange={(v) => setResetZoomOnNav(v.currentTarget.checked)} /> @@ -674,10 +691,7 @@ export const LightboxComponent: React.FC = ({ onChange={(e) => setScrollMode(e.target.value as GQL.ImageLightboxScrollMode) } - value={ - lightboxSettings?.scrollMode ?? - GQL.ImageLightboxScrollMode.Zoom - } + value={scrollMode} className="btn-secondary mx-1 mb-1" >