From cacaf36347fa0ef707be17963d462a3e024dc335 Mon Sep 17 00:00:00 2001 From: smith113-p <205463041+smith113-p@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:01:46 -0400 Subject: [PATCH 001/113] 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 002/113] 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 003/113] 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 004/113] 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 005/113] 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 006/113] 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 007/113] 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} - - - - - - :{" "} - - - -
- - ); - }); - - 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 023/113] 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 024/113] 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 025/113] 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 026/113] 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 027/113] 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" + /> + + + ); 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 029/113] 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 030/113] 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 031/113] [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 032/113] 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 033/113] 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 034/113] [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 035/113] 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 036/113] 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 037/113] [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 038/113] 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 039/113] 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 040/113] [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 041/113] 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 044/113] 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 045/113] 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 046/113] 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 047/113] 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 048/113] 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 049/113] 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 050/113] 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 051/113] 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 052/113] [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 053/113] 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 054/113] 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 055/113] 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 056/113] 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" >