From 1dac5987551b21a296d24a9efe8b56a459a4d689 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 5 Sep 2024 09:35:56 +1000 Subject: [PATCH 01/91] Remove console.log. Remove vestigial property (#5217) --- ui/v2.5/src/components/Galleries/GalleryCard.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryCard.tsx b/ui/v2.5/src/components/Galleries/GalleryCard.tsx index 130f6ffd54e..01b21bcc744 100644 --- a/ui/v2.5/src/components/Galleries/GalleryCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryCard.tsx @@ -18,15 +18,13 @@ import { GalleryPreviewScrubber } from "./GalleryPreviewScrubber"; import cx from "classnames"; import { useHistory } from "react-router-dom"; -interface IScenePreviewProps { - isPortrait?: boolean; +interface IGalleryPreviewProps { gallery: GQL.SlimGalleryDataFragment; onScrubberClick?: (index: number) => void; } -export const GalleryPreview: React.FC = ({ +export const GalleryPreview: React.FC = ({ gallery, - isPortrait = false, onScrubberClick, }) => { const [imgSrc, setImgSrc] = useState( @@ -34,7 +32,7 @@ export const GalleryPreview: React.FC = ({ ); return ( -
+
{!!imgSrc && ( = (props) => { { - console.log(i); history.push(`/galleries/${props.gallery.id}/images/${i}`); }} /> From 15da2c1f4ce5445f7680719b34ee069d104fa030 Mon Sep 17 00:00:00 2001 From: Gykes Date: Wed, 4 Sep 2024 18:25:05 -0700 Subject: [PATCH 02/91] Fix select field alias odd spacing (#5218) * Fix Tag and Alias odd spacing As Echo6ix brough up the HTML Engine doesn't generate whitespace at the beginning of a string. Modifying it to use ` ` so that the spacing will be correct. fixes https://github.com/stashapp/stash/issues/4997 * update for performerSelect and studioSelect --- ui/v2.5/src/components/Performers/PerformerSelect.tsx | 4 +++- ui/v2.5/src/components/Studios/StudioSelect.tsx | 2 +- ui/v2.5/src/components/Tags/TagSelect.tsx | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/Performers/PerformerSelect.tsx b/ui/v2.5/src/components/Performers/PerformerSelect.tsx index 1b24b737a13..bd7141f7c9c 100644 --- a/ui/v2.5/src/components/Performers/PerformerSelect.tsx +++ b/ui/v2.5/src/components/Performers/PerformerSelect.tsx @@ -140,7 +140,9 @@ const _PerformerSelect: React.FC< {name} {alias && ( - {` (${alias})`} + +  ({alias}) + )} } diff --git a/ui/v2.5/src/components/Studios/StudioSelect.tsx b/ui/v2.5/src/components/Studios/StudioSelect.tsx index d1ab69e6b7e..7b22b7f22f8 100644 --- a/ui/v2.5/src/components/Studios/StudioSelect.tsx +++ b/ui/v2.5/src/components/Studios/StudioSelect.tsx @@ -117,7 +117,7 @@ const _StudioSelect: React.FC< children: ( {name} - {alias && {` (${alias})`}} + {alias &&  ({alias})} ), }; diff --git a/ui/v2.5/src/components/Tags/TagSelect.tsx b/ui/v2.5/src/components/Tags/TagSelect.tsx index dbd7f4fe2aa..64a58d1217b 100644 --- a/ui/v2.5/src/components/Tags/TagSelect.tsx +++ b/ui/v2.5/src/components/Tags/TagSelect.tsx @@ -133,7 +133,7 @@ const _TagSelect: React.FC< */} {name} - {alias && {` (${alias})`}} + {alias &&  ({alias})} ), From fb8286651265c9540897ce2884be59e61aa78ae4 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 5 Sep 2024 11:25:30 +1000 Subject: [PATCH 03/91] Don't show move drop target on non-move drag operations (#5219) --- .../Shared/GridCard/dragMoveSelect.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Shared/GridCard/dragMoveSelect.ts b/ui/v2.5/src/components/Shared/GridCard/dragMoveSelect.ts index caae45260d9..d5ca0ecb9b7 100644 --- a/ui/v2.5/src/components/Shared/GridCard/dragMoveSelect.ts +++ b/ui/v2.5/src/components/Shared/GridCard/dragMoveSelect.ts @@ -1,11 +1,26 @@ import { useState } from "react"; import { useListContextOptional } from "src/components/List/ListProvider"; +// Enum representing the possible sides for a drag operation. export enum DragSide { BEFORE, AFTER, } +/** + * Hook to manage drag and move selection functionality. + * Dragging while selecting will allow the user to select multiple items. + * Dragging from the drag handle will allow the user to move the item or selected items. + * + * @param props - The properties for the hook. + * @param props.selecting - Whether the one or more items have been selected. + * @param props.selected - Whether this item is currently selected. + * @param props.onSelectedChanged - Callback when the selected state changes. + * @param props.objectId - The ID of this object. + * @param props.onMove - Callback when a move operation occurs. + * + * @returns An object containing the drag event handlers and state. + */ export function useDragMoveSelect(props: { selecting: boolean; selected: boolean; @@ -15,8 +30,12 @@ export function useDragMoveSelect(props: { }) { const { selectedIds } = useListContextOptional(); + // true if the mouse is over the drag handle const [inHandle, setInHandle] = useState(false); + + // true if this is the source of a move operation const [moveSrc, setMoveSrc] = useState(false); + // the target side for a move operation const [moveTarget, setMoveTarget] = useState(); const canSelect = props.onSelectedChanged && props.selecting; @@ -75,6 +94,7 @@ export function useDragMoveSelect(props: { ev.dataTransfer.dropEffect = "copy"; ev.preventDefault(); } else if (ev.dataTransfer.effectAllowed === "move" && !moveSrc) { + // don't allow move on self doSetMoveTarget(event); ev.dataTransfer.dropEffect = "move"; ev.preventDefault(); @@ -92,7 +112,8 @@ export function useDragMoveSelect(props: { } function onDragOver(event: React.DragEvent) { - if (event.dataTransfer.effectAllowed === "move" && moveSrc) { + // only set move target if move is allowed, or if this is not the source of the move + if (event.dataTransfer.effectAllowed !== "move" || moveSrc) { return; } From 7c09f24f340838353ee7c4d561eedd14715c9fe0 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 5 Sep 2024 11:25:56 +1000 Subject: [PATCH 04/91] Don't try to migrate non-existent vtt files (#5216) --- pkg/scene/migrate_hash.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pkg/scene/migrate_hash.go b/pkg/scene/migrate_hash.go index 9b74e571dc5..a7135340345 100644 --- a/pkg/scene/migrate_hash.go +++ b/pkg/scene/migrate_hash.go @@ -65,6 +65,17 @@ func migrateSceneFiles(oldName, newName string) { // #2481: migrate vtt file contents in addition to renaming func migrateVttFile(vttPath, oldSpritePath, newSpritePath string) { + // #3356 - don't try to migrate if the file doesn't exist + exists, err := fsutil.FileExists(vttPath) + if err != nil && !os.IsNotExist(err) { + logger.Errorf("Error checking existence of %s: %s", vttPath, err.Error()) + return + } + + if !exists { + return + } + contents, err := os.ReadFile(vttPath) if err != nil { logger.Errorf("Error reading %s for vtt migration: %v", vttPath, err) From 7a2e59fcef330257e9b3ad555219d14a723fc13a Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 5 Sep 2024 11:26:25 +1000 Subject: [PATCH 05/91] Fix scene filter panel colour slider range (#5221) * Refactor SceneVideoFilterPanel sliders. Fix colour values to go between 0-200%. * Add cursor for filter slider values to hint interaction --- .../SceneDetails/SceneVideoFilterPanel.tsx | 374 ++++++++---------- ui/v2.5/src/components/Scenes/styles.scss | 4 + 2 files changed, 166 insertions(+), 212 deletions(-) diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneVideoFilterPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneVideoFilterPanel.tsx index e547e750f7d..d76eba1cc6f 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneVideoFilterPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneVideoFilterPanel.tsx @@ -21,6 +21,47 @@ type SliderRange = { divider: number; }; +function getMatrixValue(value: number, range: SliderRange) { + return (value - range.default) / range.divider; +} + +interface ISliderProps { + title: string; + className?: string; + range: SliderRange; + value: number; + setValue: (value: React.SetStateAction) => void; + displayValue: string; +} + +const Slider: React.FC = (sliderProps: ISliderProps) => { + return ( +
+ {sliderProps.title} + + ) => + sliderProps.setValue(Number.parseInt(e.currentTarget.value, 10)) + } + /> + + sliderProps.setValue(sliderProps.range.default)} + onKeyPress={() => sliderProps.setValue(sliderProps.range.default)} + > + + +
+ ); +}; + export const SceneVideoFilterPanel: React.FC = ( props: ISceneVideoFilterPanelProps ) => { @@ -64,7 +105,7 @@ export const SceneVideoFilterPanel: React.FC = ( min: 0, default: 100, max: 200, - divider: 1, + divider: 100, }; const blurRange: SliderRange = { min: 0, default: 0, max: 250, divider: 10 }; const rotateRange: SliderRange = { @@ -231,20 +272,20 @@ export const SceneVideoFilterPanel: React.FC = ( "http://www.w3.org/2000/svg", "feColorMatrix" ); + + const wbMatrixValue = getMatrixValue( + whiteBalanceValue, + whiteBalanceRange + ); + feColorMatrix.setAttribute( "values", `${ - 1 + - (whiteBalanceValue - whiteBalanceRange.default) / - whiteBalanceRange.divider + - (redValue - colourRange.default) / colourRange.divider + 1 + wbMatrixValue + getMatrixValue(redValue, colourRange) } 0 0 0 0 0 ${ - 1.0 + (greenValue - colourRange.default) / colourRange.divider + 1.0 + getMatrixValue(greenValue, colourRange) } 0 0 0 0 0 ${ - 1 - - (whiteBalanceValue - whiteBalanceRange.default) / - whiteBalanceRange.divider + - (blueValue - colourRange.default) / colourRange.divider + 1 - wbMatrixValue + getMatrixValue(blueValue, colourRange) } 0 0 0 0 0 1.0 0` ); videoFilter.appendChild(feColorMatrix); @@ -324,195 +365,6 @@ export const SceneVideoFilterPanel: React.FC = ( } } - interface ISliderProps { - title: string; - className?: string; - range: SliderRange; - value: number; - setValue: (value: React.SetStateAction) => void; - displayValue: string; - } - - function renderSlider(sliderProps: ISliderProps) { - return ( -
- {sliderProps.title} - - ) => - sliderProps.setValue(Number.parseInt(e.currentTarget.value, 10)) - } - /> - - sliderProps.setValue(sliderProps.range.default)} - onKeyPress={() => sliderProps.setValue(sliderProps.range.default)} - > - - -
- ); - } - - function renderBlur() { - return renderSlider({ - title: intl.formatMessage({ id: "effect_filters.blur" }), - range: blurRange, - value: blurValue, - setValue: setBlurValue, - displayValue: `${blurValue / blurRange.divider}px`, - }); - } - - function renderContrast() { - return renderSlider({ - title: intl.formatMessage({ id: "effect_filters.contrast" }), - className: "contrast-slider", - range: contrastRange, - value: contrastValue, - setValue: setContrastValue, - displayValue: `${contrastValue / brightnessRange.divider}%`, - }); - } - - function renderBrightness() { - return renderSlider({ - title: intl.formatMessage({ id: "effect_filters.brightness" }), - className: "brightness-slider", - range: brightnessRange, - value: brightnessValue, - setValue: setBrightnessValue, - displayValue: `${brightnessValue / brightnessRange.divider}%`, - }); - } - - function renderGammaSlider() { - return renderSlider({ - title: intl.formatMessage({ id: "effect_filters.gamma" }), - className: "gamma-slider", - range: gammaRange, - value: gammaValue, - setValue: setGammaValue, - displayValue: `${(gammaValue - gammaRange.default) / gammaRange.divider}`, - }); - } - - function renderSaturate() { - return renderSlider({ - title: intl.formatMessage({ id: "effect_filters.saturation" }), - className: "saturation-slider", - range: saturateRange, - value: saturateValue, - setValue: setSaturateValue, - displayValue: `${saturateValue / saturateRange.divider}%`, - }); - } - - function renderHueRotateSlider() { - return renderSlider({ - title: intl.formatMessage({ id: "effect_filters.hue" }), - className: "hue-rotate-slider", - range: hueRotateRange, - value: hueRotateValue, - setValue: setHueRotateValue, - displayValue: `${hueRotateValue / hueRotateRange.divider}\xB0`, - }); - } - - function renderWhiteBalance() { - return renderSlider({ - title: intl.formatMessage({ id: "effect_filters.warmth" }), - className: "white-balance-slider", - range: whiteBalanceRange, - value: whiteBalanceValue, - setValue: setWhiteBalanceValue, - displayValue: `${ - (whiteBalanceValue - whiteBalanceRange.default) / - whiteBalanceRange.divider - }`, - }); - } - - function renderRedSlider() { - return renderSlider({ - title: intl.formatMessage({ id: "effect_filters.red" }), - className: "red-slider", - range: colourRange, - value: redValue, - setValue: setRedValue, - displayValue: `${ - (redValue - colourRange.default) / colourRange.divider - }%`, - }); - } - - function renderGreenSlider() { - return renderSlider({ - title: intl.formatMessage({ id: "effect_filters.green" }), - className: "green-slider", - range: colourRange, - value: greenValue, - setValue: setGreenValue, - displayValue: `${ - (greenValue - colourRange.default) / colourRange.divider - }%`, - }); - } - - function renderBlueSlider() { - return renderSlider({ - title: intl.formatMessage({ id: "effect_filters.blue" }), - className: "blue-slider", - range: colourRange, - value: blueValue, - setValue: setBlueValue, - displayValue: `${ - (blueValue - colourRange.default) / colourRange.divider - }%`, - }); - } - - function renderRotate() { - return renderSlider({ - title: intl.formatMessage({ id: "effect_filters.rotate" }), - range: rotateRange, - value: rotateValue, - setValue: setRotateValue, - displayValue: `${ - (rotateValue - rotateRange.default) / rotateRange.divider - }\xB0`, - }); - } - - function renderScale() { - return renderSlider({ - title: intl.formatMessage({ id: "effect_filters.scale" }), - range: scaleRange, - value: scaleValue, - setValue: setScaleValue, - displayValue: `${scaleValue / scaleRange.divider}%`, - }); - } - - function renderAspectRatio() { - return renderSlider({ - title: intl.formatMessage({ id: "effect_filters.aspect" }), - range: aspectRatioRange, - value: aspectRatioValue, - setValue: setAspectRatioValue, - displayValue: `${ - (aspectRatioValue - aspectRatioRange.default) / aspectRatioRange.divider - }`, - }); - } - function onRotateAndScale(direction: number) { if (direction === 0) { // Left -90 @@ -657,16 +509,91 @@ export const SceneVideoFilterPanel: React.FC = (
- {renderBrightness()} - {renderContrast()} - {renderGammaSlider()} - {renderSaturate()} - {renderHueRotateSlider()} - {renderWhiteBalance()} - {renderRedSlider()} - {renderGreenSlider()} - {renderBlueSlider()} - {renderBlur()} + + + + + + + + + + +
@@ -674,9 +601,32 @@ export const SceneVideoFilterPanel: React.FC = (
- {renderRotate()} - {renderScale()} - {renderAspectRatio()} + + +
diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index b9df2f7b5c6..ca1d051cd02 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -341,6 +341,10 @@ input[type="range"].filter-slider { padding-right: 0; } +.filter-slider-value { + cursor: pointer; +} + @mixin contrast-slider() { background: rgb(255, 255, 255); background: linear-gradient( From ad17e7defe07cd345937140e650667b22aedd529 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 5 Sep 2024 11:27:31 +1000 Subject: [PATCH 06/91] Fix handling of files to delete during delete Gallery operation (#5213) * Only remove file in zip from image if deleting from zip file * Only remove file in folder from image if deleting from folder --- pkg/gallery/delete.go | 37 +----------- pkg/gallery/service.go | 1 + pkg/image/delete.go | 81 ++++++++++++++++++++++++++- pkg/models/mocks/ImageReaderWriter.go | 14 +++++ pkg/models/mocks/SceneReaderWriter.go | 63 --------------------- pkg/models/repository_image.go | 1 + pkg/sqlite/image.go | 15 +++++ pkg/sqlite/table.go | 23 ++++++++ 8 files changed, 136 insertions(+), 99 deletions(-) diff --git a/pkg/gallery/delete.go b/pkg/gallery/delete.go index 5609b2f4bac..f5186f948b9 100644 --- a/pkg/gallery/delete.go +++ b/pkg/gallery/delete.go @@ -22,8 +22,8 @@ func (s *Service) Destroy(ctx context.Context, i *models.Gallery, fileDeleter *i imgsDestroyed = zipImgsDestroyed // only delete folder based gallery images if we're deleting the folder - if deleteFile { - folderImgsDestroyed, err := s.destroyFolderImages(ctx, i, fileDeleter, deleteGenerated, deleteFile) + if deleteFile && i.FolderID != nil { + folderImgsDestroyed, err := s.ImageService.DestroyFolderImages(ctx, *i.FolderID, fileDeleter, deleteGenerated, deleteFile) if err != nil { return nil, err } @@ -86,36 +86,3 @@ func (s *Service) destroyZipFileImages(ctx context.Context, i *models.Gallery, f return imgsDestroyed, nil } - -func (s *Service) destroyFolderImages(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) { - if i.FolderID == nil { - return nil, nil - } - - var imgsDestroyed []*models.Image - - // find images in this folder - imgs, err := s.ImageFinder.FindByFolderID(ctx, *i.FolderID) - if err != nil { - return nil, err - } - - for _, img := range imgs { - if err := img.LoadGalleryIDs(ctx, s.ImageFinder); err != nil { - return nil, err - } - - // only destroy images that are not attached to other galleries - if len(img.GalleryIDs.List()) > 1 { - continue - } - - if err := s.ImageService.Destroy(ctx, img, fileDeleter, deleteGenerated, deleteFile); err != nil { - return nil, err - } - - imgsDestroyed = append(imgsDestroyed, img) - } - - return imgsDestroyed, nil -} diff --git a/pkg/gallery/service.go b/pkg/gallery/service.go index a764e982c60..62604e0c543 100644 --- a/pkg/gallery/service.go +++ b/pkg/gallery/service.go @@ -18,6 +18,7 @@ type ImageFinder interface { type ImageService interface { Destroy(ctx context.Context, i *models.Image, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) error DestroyZipImages(ctx context.Context, zipFile models.File, fileDeleter *image.FileDeleter, deleteGenerated bool) ([]*models.Image, error) + DestroyFolderImages(ctx context.Context, folderID models.FolderID, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) } type Service struct { diff --git a/pkg/image/delete.go b/pkg/image/delete.go index 89f4c181153..69fba9bd6e5 100644 --- a/pkg/image/delete.go +++ b/pkg/image/delete.go @@ -2,6 +2,7 @@ package image import ( "context" + "fmt" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/fsutil" @@ -43,8 +44,9 @@ func (s *Service) Destroy(ctx context.Context, i *models.Image, fileDeleter *Fil // Returns a slice of images that were destroyed. func (s *Service) DestroyZipImages(ctx context.Context, zipFile models.File, fileDeleter *FileDeleter, deleteGenerated bool) ([]*models.Image, error) { var imgsDestroyed []*models.Image + zipFileID := zipFile.Base().ID - imgs, err := s.Repository.FindByZipFileID(ctx, zipFile.Base().ID) + imgs, err := s.Repository.FindByZipFileID(ctx, zipFileID) if err != nil { return nil, err } @@ -54,6 +56,23 @@ func (s *Service) DestroyZipImages(ctx context.Context, zipFile models.File, fil return nil, err } + // #5048 - if the image has multiple files, we just want to remove the file in the zip file, + // not delete the image entirely + if len(img.Files.List()) > 1 { + for _, f := range img.Files.List() { + if f.Base().ZipFileID == nil || *f.Base().ZipFileID != zipFileID { + continue + } + + if err := s.Repository.RemoveFileID(ctx, img.ID, f.Base().ID); err != nil { + return nil, fmt.Errorf("failed to remove file from image: %w", err) + } + } + + // don't delete the image + continue + } + const deleteFileInZip = false if err := s.destroyImage(ctx, img, fileDeleter, deleteGenerated, deleteFileInZip); err != nil { return nil, err @@ -65,6 +84,66 @@ func (s *Service) DestroyZipImages(ctx context.Context, zipFile models.File, fil return imgsDestroyed, nil } +// DestroyFolderImages destroys all images in a folder, optionally marking the files and generated files for deletion. +// It will not delete images that are attached to more than one gallery. +// Returns a slice of images that were destroyed. +func (s *Service) DestroyFolderImages(ctx context.Context, folderID models.FolderID, fileDeleter *FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) { + var imgsDestroyed []*models.Image + + // find images in this folder + imgs, err := s.Repository.FindByFolderID(ctx, folderID) + if err != nil { + return nil, err + } + + for _, img := range imgs { + if err := img.LoadFiles(ctx, s.Repository); err != nil { + return nil, err + } + + // #5048 - if the image has multiple files, we just want to remove the file + // in the folder + if len(img.Files.List()) > 1 { + for _, f := range img.Files.List() { + if f.Base().ParentFolderID != folderID { + continue + } + + if err := s.Repository.RemoveFileID(ctx, img.ID, f.Base().ID); err != nil { + return nil, fmt.Errorf("failed to remove file from image: %w", err) + } + + // we still want to delete the file from the folder, if applicable + if deleteFile { + if err := file.Destroy(ctx, s.File, f, fileDeleter.Deleter, deleteFile); err != nil { + return nil, fmt.Errorf("failed to delete image file: %w", err) + } + } + } + + // don't delete the image + continue + } + + if err := img.LoadGalleryIDs(ctx, s.Repository); err != nil { + return nil, err + } + + // only destroy images that are not attached to other galleries + if len(img.GalleryIDs.List()) > 1 { + continue + } + + if err := s.Destroy(ctx, img, fileDeleter, deleteGenerated, deleteFile); err != nil { + return nil, err + } + + imgsDestroyed = append(imgsDestroyed, img) + } + + return imgsDestroyed, nil +} + // Destroy destroys an image, optionally marking the file and generated files for deletion. func (s *Service) destroyImage(ctx context.Context, i *models.Image, fileDeleter *FileDeleter, deleteGenerated, deleteFile bool) error { if deleteFile { diff --git a/pkg/models/mocks/ImageReaderWriter.go b/pkg/models/mocks/ImageReaderWriter.go index 04fd6690004..2bbf4ceebeb 100644 --- a/pkg/models/mocks/ImageReaderWriter.go +++ b/pkg/models/mocks/ImageReaderWriter.go @@ -638,6 +638,20 @@ func (_m *ImageReaderWriter) QueryCount(ctx context.Context, imageFilter *models return r0, r1 } +// RemoveFileID provides a mock function with given fields: ctx, id, fileID +func (_m *ImageReaderWriter) RemoveFileID(ctx context.Context, id int, fileID models.FileID) error { + ret := _m.Called(ctx, id, fileID) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, models.FileID) error); ok { + r0 = rf(ctx, id, fileID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // ResetOCounter provides a mock function with given fields: ctx, id func (_m *ImageReaderWriter) ResetOCounter(ctx context.Context, id int) (int, error) { ret := _m.Called(ctx, id) diff --git a/pkg/models/mocks/SceneReaderWriter.go b/pkg/models/mocks/SceneReaderWriter.go index e12ae999c6f..95462985360 100644 --- a/pkg/models/mocks/SceneReaderWriter.go +++ b/pkg/models/mocks/SceneReaderWriter.go @@ -190,27 +190,6 @@ func (_m *SceneReaderWriter) CountByFileID(ctx context.Context, fileID models.Fi return r0, r1 } -// CountByGroupID provides a mock function with given fields: ctx, groupID -func (_m *SceneReaderWriter) CountByGroupID(ctx context.Context, groupID int) (int, error) { - ret := _m.Called(ctx, groupID) - - var r0 int - if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { - r0 = rf(ctx, groupID) - } else { - r0 = ret.Get(0).(int) - } - - var r1 error - if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { - r1 = rf(ctx, groupID) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - // CountByPerformerID provides a mock function with given fields: ctx, performerID func (_m *SceneReaderWriter) CountByPerformerID(ctx context.Context, performerID int) (int, error) { ret := _m.Called(ctx, performerID) @@ -232,48 +211,6 @@ func (_m *SceneReaderWriter) CountByPerformerID(ctx context.Context, performerID return r0, r1 } -// CountByStudioID provides a mock function with given fields: ctx, studioID -func (_m *SceneReaderWriter) CountByStudioID(ctx context.Context, studioID int) (int, error) { - ret := _m.Called(ctx, studioID) - - var r0 int - if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { - r0 = rf(ctx, studioID) - } else { - r0 = ret.Get(0).(int) - } - - var r1 error - if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { - r1 = rf(ctx, studioID) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// CountByTagID provides a mock function with given fields: ctx, tagID -func (_m *SceneReaderWriter) CountByTagID(ctx context.Context, tagID int) (int, error) { - ret := _m.Called(ctx, tagID) - - var r0 int - if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { - r0 = rf(ctx, tagID) - } else { - r0 = ret.Get(0).(int) - } - - var r1 error - if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { - r1 = rf(ctx, tagID) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - // CountMissingChecksum provides a mock function with given fields: ctx func (_m *SceneReaderWriter) CountMissingChecksum(ctx context.Context) (int, error) { ret := _m.Called(ctx) diff --git a/pkg/models/repository_image.go b/pkg/models/repository_image.go index 1d42a84ff6d..1455d776211 100644 --- a/pkg/models/repository_image.go +++ b/pkg/models/repository_image.go @@ -89,6 +89,7 @@ type ImageWriter interface { ImageDestroyer AddFileID(ctx context.Context, id int, fileID FileID) error + RemoveFileID(ctx context.Context, id int, fileID FileID) error IncrementOCounter(ctx context.Context, id int) (int, error) DecrementOCounter(ctx context.Context, id int) (int, error) ResetOCounter(ctx context.Context, id int) (int, error) diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 8248427a8eb..973e45a934a 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -997,6 +997,21 @@ func (qb *ImageStore) AddFileID(ctx context.Context, id int, fileID models.FileI return imagesFilesTableMgr.insertJoins(ctx, id, firstPrimary, []models.FileID{fileID}) } +// RemoveFileID removes the file ID from the image. +// If the file ID is the primary file, then the next file in the list is set as the primary file. +func (qb *ImageStore) RemoveFileID(ctx context.Context, id int, fileID models.FileID) error { + fileIDs, err := imagesFilesTableMgr.get(ctx, id) + if err != nil { + return fmt.Errorf("getting file IDs for image %d: %w", id, err) + } + + fileIDs = sliceutil.Filter(fileIDs, func(f models.FileID) bool { + return f != fileID + }) + + return imagesFilesTableMgr.replaceJoins(ctx, id, fileIDs) +} + func (qb *ImageStore) GetGalleryIDs(ctx context.Context, imageID int) ([]int, error) { return imageRepository.galleries.getIDs(ctx, imageID) } diff --git a/pkg/sqlite/table.go b/pkg/sqlite/table.go index 2ae3bf9458c..8d72bdcae44 100644 --- a/pkg/sqlite/table.go +++ b/pkg/sqlite/table.go @@ -759,6 +759,29 @@ type relatedFilesTable struct { // FileID models.FileID `db:"file_id"` // } +// get returns the file IDs related to the provided scene ID +// the primary file is returned first +func (t *relatedFilesTable) get(ctx context.Context, id int) ([]models.FileID, error) { + q := dialect.Select("file_id").From(t.table.table).Where(t.idColumn.Eq(id)).Order(t.table.table.Col("primary").Desc()) + + const single = false + var ret []models.FileID + if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { + var v models.FileID + if err := rows.Scan(&v); err != nil { + return err + } + + ret = append(ret, v) + + return nil + }); err != nil { + return nil, fmt.Errorf("getting related files from %s: %w", t.table.table.GetTable(), err) + } + + return ret, nil +} + func (t *relatedFilesTable) insertJoin(ctx context.Context, id int, primary bool, fileID models.FileID) error { q := dialect.Insert(t.table.table).Cols(t.idColumn.GetCol(), "primary", "file_id").Vals( goqu.Vals{id, primary, fileID}, From 283f76240f1a771f0e39bae2937aba97e17649f1 Mon Sep 17 00:00:00 2001 From: yoshnopa <40072150+yoshnopa@users.noreply.github.com> Date: Thu, 5 Sep 2024 03:43:31 +0200 Subject: [PATCH 07/91] Make Scrubbers touchscreen capable (#5183) --- .../src/components/Shared/HoverScrubber.tsx | 51 +++++++++++++++---- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/ui/v2.5/src/components/Shared/HoverScrubber.tsx b/ui/v2.5/src/components/Shared/HoverScrubber.tsx index f658e1fa23e..d42f06ae754 100644 --- a/ui/v2.5/src/components/Shared/HoverScrubber.tsx +++ b/ui/v2.5/src/components/Shared/HoverScrubber.tsx @@ -14,9 +14,21 @@ export const HoverScrubber: React.FC = ({ setActiveIndex, onClick, }) => { - function getActiveIndex(e: React.MouseEvent) { + function getActiveIndex( + e: + | React.MouseEvent + | React.TouchEvent + ) { const { width } = e.currentTarget.getBoundingClientRect(); - const x = e.nativeEvent.offsetX; + + let x = 0; + if (e.nativeEvent instanceof MouseEvent) { + x = e.nativeEvent.offsetX; + } else if (e.nativeEvent instanceof TouchEvent) { + x = + e.nativeEvent.touches[0].clientX - + e.currentTarget.getBoundingClientRect().x; + } const i = Math.round((x / width) * (totalSprites - 1)); @@ -26,24 +38,42 @@ export const HoverScrubber: React.FC = ({ return i; } - function onMouseMove(e: React.MouseEvent) { + function onMove( + e: + | React.MouseEvent + | React.TouchEvent + ) { const relatedTarget = e.currentTarget; - if (relatedTarget !== e.target) return; + if ( + (e instanceof MouseEvent && relatedTarget !== e.target) || + (e instanceof TouchEvent && + document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY)) + ) + return; setActiveIndex(getActiveIndex(e)); } - function onMouseLeave() { + function onLeave() { setActiveIndex(undefined); } - function onScrubberClick(e: React.MouseEvent) { + function onScrubberClick( + e: + | React.MouseEvent + | React.TouchEvent + ) { if (!onClick) return; const relatedTarget = e.currentTarget; - if (relatedTarget !== e.target) return; + if ( + (e instanceof MouseEvent && relatedTarget !== e.target) || + (e instanceof TouchEvent && + document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY)) + ) + return; e.preventDefault(); onClick(); @@ -67,9 +97,12 @@ export const HoverScrubber: React.FC = ({ >
{activeIndex !== undefined && ( From 879c20efc7bc3d3cdbe08f295cb7a481eedcd4dc Mon Sep 17 00:00:00 2001 From: dogwithakeyboard <128322708+dogwithakeyboard@users.noreply.github.com> Date: Thu, 5 Sep 2024 03:55:19 +0100 Subject: [PATCH 08/91] Add linkTypes to performer popover (#5195) --- ui/v2.5/src/components/Galleries/GalleryCard.tsx | 7 ++++++- ui/v2.5/src/components/Images/ImageCard.tsx | 7 ++++++- ui/v2.5/src/components/Scenes/SceneCard.tsx | 7 ++++++- ui/v2.5/src/components/Shared/PerformerPopoverButton.tsx | 9 +++++++-- ui/v2.5/src/components/Shared/TagLink.tsx | 2 ++ 5 files changed, 27 insertions(+), 5 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryCard.tsx b/ui/v2.5/src/components/Galleries/GalleryCard.tsx index 01b21bcc744..423693eff80 100644 --- a/ui/v2.5/src/components/Galleries/GalleryCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryCard.tsx @@ -142,7 +142,12 @@ export const GalleryCard: React.FC = (props) => { function maybeRenderPerformerPopoverButton() { if (props.gallery.performers.length <= 0) return; - return ; + return ( + + ); } function maybeRenderImagesPopoverButton() { diff --git a/ui/v2.5/src/components/Images/ImageCard.tsx b/ui/v2.5/src/components/Images/ImageCard.tsx index 12e8b9e0012..e4c050a5115 100644 --- a/ui/v2.5/src/components/Images/ImageCard.tsx +++ b/ui/v2.5/src/components/Images/ImageCard.tsx @@ -100,7 +100,12 @@ export const ImageCard: React.FC = ( function maybeRenderPerformerPopoverButton() { if (props.image.performers.length <= 0) return; - return ; + return ( + + ); } function maybeRenderOCounter() { diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index b5d053c67e7..18261b4bf5c 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -165,7 +165,12 @@ const SceneCardPopovers = PatchComponent( function maybeRenderPerformerPopoverButton() { if (props.scene.performers.length <= 0) return; - return ; + return ( + + ); } function maybeRenderGroupPopoverButton() { diff --git a/ui/v2.5/src/components/Shared/PerformerPopoverButton.tsx b/ui/v2.5/src/components/Shared/PerformerPopoverButton.tsx index 577faca8b0d..a2d4210ae42 100644 --- a/ui/v2.5/src/components/Shared/PerformerPopoverButton.tsx +++ b/ui/v2.5/src/components/Shared/PerformerPopoverButton.tsx @@ -6,16 +6,20 @@ import * as GQL from "src/core/generated-graphql"; import { sortPerformers } from "src/core/performers"; import { HoverPopover } from "./HoverPopover"; import { Icon } from "./Icon"; -import { PerformerLink } from "./TagLink"; +import { PerformerLink, PerformerLinkType } from "./TagLink"; interface IProps { performers: Pick< GQL.Performer, "id" | "name" | "image_path" | "disambiguation" | "gender" >[]; + linkType?: PerformerLinkType; } -export const PerformerPopoverButton: React.FC = ({ performers }) => { +export const PerformerPopoverButton: React.FC = ({ + performers, + linkType, +}) => { const sorted = sortPerformers(performers); const popoverContent = sorted.map((performer) => (
@@ -33,6 +37,7 @@ export const PerformerPopoverButton: React.FC = ({ performers }) => { key={performer.id} performer={performer} className="d-block" + linkType={linkType} />
)); diff --git a/ui/v2.5/src/components/Shared/TagLink.tsx b/ui/v2.5/src/components/Shared/TagLink.tsx index d01eee2d5ff..b61de8bff70 100644 --- a/ui/v2.5/src/components/Shared/TagLink.tsx +++ b/ui/v2.5/src/components/Shared/TagLink.tsx @@ -42,6 +42,8 @@ interface IPerformerLinkProps { className?: string; } +export type PerformerLinkType = IPerformerLinkProps["linkType"]; + export const PerformerLink: React.FC = ({ performer, linkType = "scene", From 601a16b5cb56b4fa2716ceb79c55b7e7fd1f6acc Mon Sep 17 00:00:00 2001 From: DirtyRacer1337 Date: Thu, 5 Sep 2024 09:59:05 +0700 Subject: [PATCH 09/91] replace stashBox validation (#5187) --- internal/manager/config/config.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index d56d3359bab..3cba5e1f261 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "net/url" "os" "path/filepath" "reflect" @@ -1097,7 +1098,10 @@ func (i *Config) ValidateCredentials(username string, password string) bool { return username == authUser && err == nil } -var stashBoxRe = regexp.MustCompile("^http.*graphql$") +func stashBoxValidate(str string) bool { + u, err := url.Parse(str) + return err == nil && u.Scheme != "" && u.Host != "" && strings.HasSuffix(u.Path, "/graphql") +} type StashBoxInput struct { Endpoint string `json:"endpoint"` @@ -1118,7 +1122,7 @@ func (i *Config) ValidateStashBoxes(boxes []*StashBoxInput) error { return &StashBoxError{msg: "endpoint cannot be blank"} } - if !stashBoxRe.Match([]byte(box.Endpoint)) { + if !stashBoxValidate(box.Endpoint) { return &StashBoxError{msg: "endpoint is invalid"} } From 8c2a25b833df6a7fd25869a88bb79e3a58b63662 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 5 Sep 2024 12:59:20 +1000 Subject: [PATCH 10/91] Fix gallery scrubber image order (#5222) --- pkg/sqlite/image.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 973e45a934a..db40d4f474b 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -602,6 +602,11 @@ func (qb *ImageStore) FindByChecksum(ctx context.Context, checksum string) ([]*m }) } +var defaultGalleryOrder = []exp.OrderedExpression{ + goqu.L("COALESCE(folders.path, '') || COALESCE(files.basename, '') COLLATE NATURAL_CI").Asc(), + goqu.L("COALESCE(images.title, images.id) COLLATE NATURAL_CI").Asc(), +} + func (qb *ImageStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.Image, error) { table := qb.table() @@ -618,7 +623,7 @@ func (qb *ImageStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*mo table.Col(idColumn).Eq( sq, ), - ).Order(goqu.L("COALESCE(folders.path, '') || COALESCE(files.basename, '') COLLATE NATURAL_CI").Asc()) + ).Order(defaultGalleryOrder...) ret, err := qb.getMany(ctx, q) if err != nil { @@ -630,8 +635,6 @@ func (qb *ImageStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*mo func (qb *ImageStore) FindByGalleryIDIndex(ctx context.Context, galleryID int, index uint) (*models.Image, error) { table := qb.table() - fileTable := fileTableMgr.table - folderTable := folderTableMgr.table q := qb.selectDataset(). InnerJoin( @@ -640,7 +643,7 @@ func (qb *ImageStore) FindByGalleryIDIndex(ctx context.Context, galleryID int, i ). Where(galleriesImagesJoinTable.Col(galleryIDColumn).Eq(galleryID)). Prepared(true). - Order(folderTable.Col("path").Asc(), fileTable.Col("basename").Asc()). + Order(defaultGalleryOrder...). Limit(1).Offset(index) ret, err := qb.getMany(ctx, q) From 5721ea2b704b0daf91a7e0e1474011b3b92f61e8 Mon Sep 17 00:00:00 2001 From: yoshnopa <40072150+yoshnopa@users.noreply.github.com> Date: Thu, 5 Sep 2024 05:06:43 +0200 Subject: [PATCH 11/91] Gallery scrubber wall view (#5191) Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- .../components/Galleries/GalleryWallCard.tsx | 91 +++++++++++++------ ui/v2.5/src/components/Galleries/styles.scss | 22 ++++- .../src/components/Shared/HoverScrubber.tsx | 1 + 3 files changed, 83 insertions(+), 31 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx index f38103d53c4..c794ddc1481 100644 --- a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import { useIntl } from "react-intl"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; @@ -7,32 +7,50 @@ import TextUtils from "src/utils/text"; import { useGalleryLightbox } from "src/hooks/Lightbox/hooks"; import { galleryTitle } from "src/core/galleries"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; +import { GalleryPreviewScrubber } from "./GalleryPreviewScrubber"; +import cx from "classnames"; const CLASSNAME = "GalleryWallCard"; const CLASSNAME_FOOTER = `${CLASSNAME}-footer`; const CLASSNAME_IMG = `${CLASSNAME}-img`; const CLASSNAME_TITLE = `${CLASSNAME}-title`; +const CLASSNAME_IMG_CONTAIN = `${CLASSNAME}-img-contain`; interface IProps { gallery: GQL.SlimGalleryDataFragment; } +type Orientation = "landscape" | "portrait"; + +function getOrientation(width: number, height: number): Orientation { + return width > height ? "landscape" : "portrait"; +} + const GalleryWallCard: React.FC = ({ gallery }) => { const intl = useIntl(); - const [orientation, setOrientation] = React.useState< - "landscape" | "portrait" - >("landscape"); + const [coverOrientation, setCoverOrientation] = + React.useState("landscape"); + const [imageOrientation, setImageOrientation] = + React.useState("landscape"); const showLightbox = useGalleryLightbox(gallery.id, gallery.chapters); const cover = gallery?.paths.cover; - function onImageLoad(e: React.SyntheticEvent) { + function onCoverLoad(e: React.SyntheticEvent) { + const target = e.target as HTMLImageElement; + setCoverOrientation( + getOrientation(target.naturalWidth, target.naturalHeight) + ); + } + + function onNonCoverLoad(e: React.SyntheticEvent) { const target = e.target as HTMLImageElement; - setOrientation( - target.naturalWidth > target.naturalHeight ? "landscape" : "portrait" + setImageOrientation( + getOrientation(target.naturalWidth, target.naturalHeight) ); } + const [imgSrc, setImgSrc] = useState(cover ?? undefined); const title = galleryTitle(gallery); const performerNames = gallery.performers.map((p) => p.name); const performers = @@ -48,10 +66,13 @@ const GalleryWallCard: React.FC = ({ gallery }) => { showLightbox(0); } + const imgClassname = + imageOrientation !== coverOrientation ? CLASSNAME_IMG_CONTAIN : ""; + return ( <>
= ({ gallery }) => { -
- e.stopPropagation()} - > - {title && ( - - )} - -
- {gallery.date && TextUtils.formatDate(intl, gallery.date)} -
- -
+
+
+ e.stopPropagation()} + > + {title && ( + + )} + +
+ {gallery.date && TextUtils.formatDate(intl, gallery.date)} +
+ +
+ { + showLightbox(i); + }} + onPathChanged={setImgSrc} + /> +
); diff --git a/ui/v2.5/src/components/Galleries/styles.scss b/ui/v2.5/src/components/Galleries/styles.scss index d2314955281..12439a94d5f 100644 --- a/ui/v2.5/src/components/Galleries/styles.scss +++ b/ui/v2.5/src/components/Galleries/styles.scss @@ -237,6 +237,18 @@ $galleryTabWidth: 450px; width: 96vw; } + .lineargradient { + background-image: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.3)); + bottom: 100px; + height: 100px; + position: relative; + } + + .preview-scrubber { + top: 0; + z-index: 1; + } + @mixin galleryWidth($width) { height: math.div($width, 3) * 2; @@ -264,6 +276,11 @@ $galleryTabWidth: 450px; object-fit: cover; object-position: center 20%; width: 100%; + + &.GalleryWallCard-img-contain { + object-fit: contain; + object-position: initial; + } } &-title { @@ -271,13 +288,13 @@ $galleryTabWidth: 450px; } &-footer { - background-image: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.3)); - bottom: 0; + bottom: 20px; padding: 1rem; position: absolute; text-shadow: 1px 1px 3px black; transition: 0s opacity; width: 100%; + z-index: 2; @media (min-width: 768px) { opacity: 0; @@ -310,6 +327,7 @@ $galleryTabWidth: 450px; right: 1rem; text-shadow: 1px 1px 3px black; top: 1rem; + z-index: 2; } .rating-stars { diff --git a/ui/v2.5/src/components/Shared/HoverScrubber.tsx b/ui/v2.5/src/components/Shared/HoverScrubber.tsx index d42f06ae754..e9f3f8209e1 100644 --- a/ui/v2.5/src/components/Shared/HoverScrubber.tsx +++ b/ui/v2.5/src/components/Shared/HoverScrubber.tsx @@ -76,6 +76,7 @@ export const HoverScrubber: React.FC = ({ return; e.preventDefault(); + e.stopPropagation(); onClick(); } From 7f1ad30db18d8fcf0c5b9504cd30cd2e4628a891 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 5 Sep 2024 16:34:56 +1000 Subject: [PATCH 12/91] Show option for sub-content only if there are child objects (#5223) --- .../components/Studios/StudioDetails/Studio.tsx | 17 +++++++++++------ ui/v2.5/src/components/Tags/TagDetails/Tag.tsx | 17 +++++++++++------ 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index 522dfe64ad7..01b2d7524d6 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -79,7 +79,9 @@ const StudioTabs: React.FC<{ abbreviateCounter: boolean; showAllCounts?: boolean; }> = ({ tabKey, studio, abbreviateCounter, showAllCounts = false }) => { - const [showAllDetails, setShowAllDetails] = useState(showAllCounts); + const [showAllDetails, setShowAllDetails] = useState( + showAllCounts && studio.child_studios.length > 0 + ); const sceneCount = (showAllDetails ? studio.scene_count_all : studio.scene_count) ?? 0; @@ -125,8 +127,12 @@ const StudioTabs: React.FC<{ baseURL: `/studios/${studio.id}`, }); - const contentSwitch = useMemo( - () => ( + const contentSwitch = useMemo(() => { + if (!studio.child_studios.length) { + return null; + } + + return (
} />
- ), - [showAllDetails] - ); + ); + }, [showAllDetails, studio.child_studios.length]); return ( = ({ tabKey, tag, abbreviateCounter, showAllCounts = false }) => { - const [showAllDetails, setShowAllDetails] = useState(showAllCounts); + const [showAllDetails, setShowAllDetails] = useState( + showAllCounts && tag.children.length > 0 + ); const sceneCount = (showAllDetails ? tag.scene_count_all : tag.scene_count) ?? 0; @@ -135,8 +137,12 @@ const TagTabs: React.FC<{ baseURL: `/tags/${tag.id}`, }); - const contentSwitch = useMemo( - () => ( + const contentSwitch = useMemo(() => { + if (tag.children.length === 0) { + return null; + } + + return (
} />
- ), - [showAllDetails] - ); + ); + }, [showAllDetails, tag.children.length]); return ( Date: Thu, 5 Sep 2024 16:35:14 +1000 Subject: [PATCH 13/91] Add changelog for 0.27 (#5224) --- .../src/components/Changelog/Changelog.tsx | 14 +++-- ui/v2.5/src/docs/en/Changelog/v0270.md | 53 +++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 ui/v2.5/src/docs/en/Changelog/v0270.md diff --git a/ui/v2.5/src/components/Changelog/Changelog.tsx b/ui/v2.5/src/components/Changelog/Changelog.tsx index bd6e899455b..5a7e842c1ed 100644 --- a/ui/v2.5/src/components/Changelog/Changelog.tsx +++ b/ui/v2.5/src/components/Changelog/Changelog.tsx @@ -31,6 +31,7 @@ import V0230 from "src/docs/en/Changelog/v0230.md"; import V0240 from "src/docs/en/Changelog/v0240.md"; import V0250 from "src/docs/en/Changelog/v0250.md"; import V0260 from "src/docs/en/Changelog/v0260.md"; +import V0270 from "src/docs/en/Changelog/v0270.md"; import { MarkdownPage } from "../Shared/MarkdownPage"; const Changelog: React.FC = () => { @@ -66,9 +67,9 @@ const Changelog: React.FC = () => { // after new release: // add entry to releases, using the current* fields // then update the current fields. - const currentVersion = stashVersion || "v0.26.0"; + const currentVersion = stashVersion || "v0.27.0"; const currentDate = buildDate; - const currentPage = V0260; + const currentPage = V0270; const releases: IStashRelease[] = [ { @@ -77,14 +78,19 @@ const Changelog: React.FC = () => { page: currentPage, defaultOpen: true, }, + { + version: "v0.26.2", + date: "2024-06-27", + page: V0260, + }, { version: "v0.25.1", - date: "2023-03-13", + date: "2024-03-13", page: V0250, }, { version: "v0.24.3", - date: "2023-01-15", + date: "2024-01-15", page: V0240, }, { diff --git a/ui/v2.5/src/docs/en/Changelog/v0270.md b/ui/v2.5/src/docs/en/Changelog/v0270.md new file mode 100644 index 00000000000..6ac27fc2887 --- /dev/null +++ b/ui/v2.5/src/docs/en/Changelog/v0270.md @@ -0,0 +1,53 @@ +##### 💥 Note: The Movie concept has been renamed to Group. +##### 💥 Note: Tagger settings have been reset, but are now persisted between browser sessions. `Show male performers` and `Set Tags` are now defaulted to true. Please verify your settings before using the Tagger. + +### ✨ New Features +* Movies have been renamed to `Groups` and now may contain orderable sub-groups with descriptions. ([#5105](https://github.com/stashapp/stash/pull/5105)) +* Added support for multiple URLs for Performers. ([#4958](https://github.com/stashapp/stash/pull/4958)) +* Added ability to set tags on Studios. ([#4858](https://github.com/stashapp/stash/pull/4858)) +* Added support for multiple URLs for Groups. ([#4900](https://github.com/stashapp/stash/pull/4900)) +* Added ability to set tags on Groups. ([#4969](https://github.com/stashapp/stash/pull/4969)) +* Added ability to set a specific image as a Gallery Cover. ([#5182](https://github.com/stashapp/stash/pull/5182)) +* Added support for setting default filter for all views. ([#4962](https://github.com/stashapp/stash/pull/4962)) +* Added preview scrubber to Gallery cards. ([#5133](https://github.com/stashapp/stash/pull/5133)) +* Added support for bulk-editing Tags. ([#4925](https://github.com/stashapp/stash/pull/4925)) +* Added filter to Scrapers menu. ([#5041](https://github.com/stashapp/stash/pull/5041)) +* Added ability to set the location of ssl certificate files. ([#4910](https://github.com/stashapp/stash/pull/4910)) + +### 🎨 Improvements +* Added button to view sub-studio/sub-tag content on Studio/Tag details pages. ([#5080](https://github.com/stashapp/stash/pull/5080)) +* Made tagger settings persistent. ([#5165](https://github.com/stashapp/stash/pull/5165)) +* Added birthdate and age to Performer select. ([#5076](https://github.com/stashapp/stash/pull/5076)) +* Made pagination control more compact. ([#4882](https://github.com/stashapp/stash/pull/4882)) +* Added filter and count badge to Scraper lists in the `Metadata Providers` page, and improved presentation. ([#5040](https://github.com/stashapp/stash/pull/5040)) +* Clicking `Rescan` on the details pages will now properly recalculate file details. ([#5043](https://github.com/stashapp/stash/pull/5043)) +* Added performer sorting options for `career length`, `measurements` and `weight`. ([#5129](https://github.com/stashapp/stash/pull/5129)) +* Added `path` column option to scene and gallery list tables. ([#5005](https://github.com/stashapp/stash/pull/5005)) +* Moved `Reload scrapers` option to top of Scrapers menus. ([#5142](https://github.com/stashapp/stash/pull/5142)) +* Added `scene` filter criterion for Scene Marker queries. ([#5097](https://github.com/stashapp/stash/pull/5097)) +* Scene Player now allows interacting with the controls before playing video, and errors no longer prevent interacting with the Scene Player. ([#5145](https://github.com/stashapp/stash/pull/5145)) + +### 🐛 Bug fixes +* Fixed videos and images having incorrect dimensions when the orientation flag is set to a non-default value during scan. ([#5188](https://github.com/stashapp/stash/pull/5188), [#5189](https://github.com/stashapp/stash/pull/5189)) +* Fixed mp4 videos being incorrectly transcoded when the file has opus audio codec. ([#5030](https://github.com/stashapp/stash/pull/5030)) +* Fixed o-history being imported as view-history when importing from JSON. ([#5127](https://github.com/stashapp/stash/pull/5127)) +* Deleting a zip-based or folder-based Gallery and deleting the file/folder now removes files from the existing image if the image has multiple files, instead of removing the image. ([#5213](https://github.com/stashapp/stash/pull/5213)) +* Fixed Intel Quicksync hardware encoding support. ([#5069](https://github.com/stashapp/stash/pull/5069)) +* Fixed hardware transcoding not working correctly on macOS devices. ([#4945](https://github.com/stashapp/stash/pull/4945)) +* Fixed ffmpeg version detection for `n`- prefixed version numbers. ([#5102](https://github.com/stashapp/stash/pull/5102)) +* Anonymise now truncates o- and view history data. ([#5166](https://github.com/stashapp/stash/pull/5166)) +* Fixed issue where using mouse wheel on numeric input fields would scroll the window in addition to changing the value. ([#5199](https://github.com/stashapp/stash/pull/5199)) +* Fixed issue where some o-dates could not be deleted. ([#4971](https://github.com/stashapp/stash/pull/4971)) +* Added API key to DASH and HLS manifests. ([#5061](https://github.com/stashapp/stash/pull/5061)) +* Query field no longer focused when selecting items in the filter list on touch devices. ([#5204](https://github.com/stashapp/stash/pull/5204)) +* Fixed weird scrolling behaviour on Gallery detail page on smaller viewports ([#5205](https://github.com/stashapp/stash/pull/5205)) +* Performer popover links now correctly link to the applicable scenes/image/gallery query page instead of always going to scenes. ([#5195](https://github.com/stashapp/stash/pull/5195)) +* Fixed red/green/blue slider values in the Scene Filter panel. ([#5221](https://github.com/stashapp/stash/pull/5221)) +* Play button no longer appears on file-less Scenes. ([#5141](https://github.com/stashapp/stash/pull/5141)) +* Fixed transgender icon colouring. ([#5090](https://github.com/stashapp/stash/pull/5090)) +* Refreshed built in freeones scraper. ([#5171](https://github.com/stashapp/stash/pull/5171)) + +### Plugin API changes +* `PluginAPI.patch.instead` now allows for multiple plugins to hook into a single function. ([#5125](https://github.com/stashapp/stash/pull/5125)) + + From ca970b97062f5cebfadf0545f79b593d29612c8b Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 5 Sep 2024 16:45:15 +1000 Subject: [PATCH 14/91] Use gallery updated at for cover mod time (#5225) --- internal/api/routes_gallery.go | 4 +-- internal/api/routes_image.go | 11 +++++-- pkg/utils/http.go | 54 +++++++++++++++++++++++++++++----- 3 files changed, 56 insertions(+), 13 deletions(-) diff --git a/internal/api/routes_gallery.go b/internal/api/routes_gallery.go index e08663a708c..585d46d4a2f 100644 --- a/internal/api/routes_gallery.go +++ b/internal/api/routes_gallery.go @@ -78,7 +78,7 @@ func (rs galleryRoutes) Cover(w http.ResponseWriter, r *http.Request) { return } - rs.imageRoutes.serveThumbnail(w, r, i) + rs.imageRoutes.serveThumbnail(w, r, i, &g.UpdatedAt) } func (rs galleryRoutes) Preview(w http.ResponseWriter, r *http.Request) { @@ -116,7 +116,7 @@ func (rs galleryRoutes) Preview(w http.ResponseWriter, r *http.Request) { return } - rs.imageRoutes.serveThumbnail(w, r, i) + rs.imageRoutes.serveThumbnail(w, r, i, nil) } func (rs galleryRoutes) GalleryCtx(next http.Handler) http.Handler { diff --git a/internal/api/routes_image.go b/internal/api/routes_image.go index 89e6d2db4d0..598a6fe26c0 100644 --- a/internal/api/routes_image.go +++ b/internal/api/routes_image.go @@ -7,6 +7,7 @@ import ( "net/http" "os/exec" "strconv" + "time" "github.com/go-chi/chi/v5" @@ -47,17 +48,21 @@ func (rs imageRoutes) Routes() chi.Router { func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) { img := r.Context().Value(imageKey).(*models.Image) - rs.serveThumbnail(w, r, img) + rs.serveThumbnail(w, r, img, nil) } -func (rs imageRoutes) serveThumbnail(w http.ResponseWriter, r *http.Request, img *models.Image) { +func (rs imageRoutes) serveThumbnail(w http.ResponseWriter, r *http.Request, img *models.Image, modTime *time.Time) { mgr := manager.GetInstance() filepath := mgr.Paths.Generated.GetThumbnailPath(img.Checksum, models.DefaultGthumbWidth) // if the thumbnail doesn't exist, encode on the fly exists, _ := fsutil.FileExists(filepath) if exists { - utils.ServeStaticFile(w, r, filepath) + if modTime == nil { + utils.ServeStaticFile(w, r, filepath) + } else { + utils.ServeStaticFileModTime(w, r, filepath, *modTime) + } } else { const useDefault = true diff --git a/pkg/utils/http.go b/pkg/utils/http.go index d2b40af9978..a893a93f39e 100644 --- a/pkg/utils/http.go +++ b/pkg/utils/http.go @@ -2,7 +2,10 @@ package utils import ( "bytes" + "errors" + "io/fs" "net/http" + "path/filepath" "time" "github.com/stashapp/stash/pkg/hash/md5" @@ -15,14 +18,18 @@ func GenerateETag(data []byte) string { return `"` + hash + `"` } -// Serves static content, adding Cache-Control: no-cache and a generated ETag header. -// Responds to conditional requests using the ETag. -func ServeStaticContent(w http.ResponseWriter, r *http.Request, data []byte) { +func setStaticContentCacheControl(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Has("t") { w.Header().Set("Cache-Control", "private, max-age=31536000, immutable") } else { w.Header().Set("Cache-Control", "no-cache") } +} + +// Serves static content, adding Cache-Control: no-cache and a generated ETag header. +// Responds to conditional requests using the ETag. +func ServeStaticContent(w http.ResponseWriter, r *http.Request, data []byte) { + setStaticContentCacheControl(w, r) w.Header().Set("ETag", GenerateETag(data)) http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(data)) @@ -31,11 +38,42 @@ func ServeStaticContent(w http.ResponseWriter, r *http.Request, data []byte) { // Serves static content at filepath, adding Cache-Control: no-cache. // Responds to conditional requests using the file modtime. func ServeStaticFile(w http.ResponseWriter, r *http.Request, filepath string) { - if r.URL.Query().Has("t") { - w.Header().Set("Cache-Control", "private, max-age=31536000, immutable") - } else { - w.Header().Set("Cache-Control", "no-cache") - } + setStaticContentCacheControl(w, r) http.ServeFile(w, r, filepath) } + +func toHTTPError(err error) (msg string, httpStatus int) { + if errors.Is(err, fs.ErrNotExist) { + return "404 page not found", http.StatusNotFound + } + if errors.Is(err, fs.ErrPermission) { + return "403 Forbidden", http.StatusForbidden + } + return "500 Internal Server Error", http.StatusInternalServerError +} + +// ServeStaticFileModTime serves a static file at the given path using the given modTime instead of the file modTime. +func ServeStaticFileModTime(w http.ResponseWriter, r *http.Request, path string, modTime time.Time) { + setStaticContentCacheControl(w, r) + + dir, file := filepath.Split(path) + fs := http.Dir(dir) + + f, err := fs.Open(file) + if err != nil { + msg, code := toHTTPError(err) + http.Error(w, msg, code) + return + } + defer f.Close() + + d, err := f.Stat() + if err != nil { + msg, code := toHTTPError(err) + http.Error(w, msg, code) + return + } + + http.ServeContent(w, r, d.Name(), modTime, f) +} From 0a982966428d9dde81de29a984e9d5b929571413 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 6 Sep 2024 13:53:23 +1000 Subject: [PATCH 15/91] Fix scroll to top behaviour (#5228) --- ui/v2.5/src/components/List/ItemList.tsx | 6 +++--- ui/v2.5/src/components/List/util.ts | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/ui/v2.5/src/components/List/ItemList.tsx b/ui/v2.5/src/components/List/ItemList.tsx index 8dd2d93b27b..0efe3b03da9 100644 --- a/ui/v2.5/src/components/List/ItemList.tsx +++ b/ui/v2.5/src/components/List/ItemList.tsx @@ -92,6 +92,9 @@ export const ItemList = ( onSelectNone, } = useListContext(); + // scroll to the top of the page when the page changes + useScrollToTopOnPageChange(filter.currentPage, result.loading); + const { modal, showModal, closeModal } = useModal(); const metadataByline = useMemo(() => { @@ -320,9 +323,6 @@ export const ItemListContext = ( view ); - // scroll to the top of the page when the page changes - useScrollToTopOnPageChange(filter.currentPage); - if (defaultFilterLoading) return null; return ( diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index 3b85b666d86..280a65e75f5 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -338,9 +338,19 @@ export function useCachedQueryResult( return cachedResult; } -export function useScrollToTopOnPageChange(currentPage: number) { +export function useScrollToTopOnPageChange( + currentPage: number, + loading: boolean +) { + const prevPage = usePrevious(currentPage); + // scroll to the top of the page when the page changes + // only scroll to top if the page has changed and is not loading useEffect(() => { + if (loading || currentPage === prevPage || prevPage === undefined) { + return; + } + // if the current page has a detail-header, then // scroll up relative to that rather than 0, 0 const detailHeader = document.querySelector(".detail-header"); @@ -349,7 +359,7 @@ export function useScrollToTopOnPageChange(currentPage: number) { } else { window.scrollTo(0, 0); } - }, [currentPage]); + }, [prevPage, currentPage, loading]); } // handle case where page is more than there are pages From ba83da1983e2f9543b59174a2c7aacfcaa34c0eb Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Sun, 8 Sep 2024 01:20:44 +0300 Subject: [PATCH 16/91] Add note about saved filter sorting (#5234) --- ui/v2.5/src/docs/en/Manual/Browsing.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/v2.5/src/docs/en/Manual/Browsing.md b/ui/v2.5/src/docs/en/Manual/Browsing.md index ad323a9c2d8..a0c2896fa8c 100644 --- a/ui/v2.5/src/docs/en/Manual/Browsing.md +++ b/ui/v2.5/src/docs/en/Manual/Browsing.md @@ -45,6 +45,8 @@ The current sorting field is shown next to the query text field, indicating the Saved filters can be accessed with the bookmark button on the left of the query text field. The current filter can be saved by entering a filter name and clicking on the save button. Existing saved filters may be overwritten with the current filter by clicking on the save button next to the filter name. Saved filters may also be deleted by pressing the delete button next to the filter name. +Saved filters are sorted alphabetically by title with capitalized titles sorted first. + ### Default filter The default filter for the top-level pages may be set to the current filter by clicking the `Set as default` button in the saved filter menu. From a44993bbf4f1a1070749ec61d09c32c6a09621d4 Mon Sep 17 00:00:00 2001 From: Gykes Date: Mon, 9 Sep 2024 21:43:09 -0700 Subject: [PATCH 17/91] Fix source-selector being blocked on mobile (#5229) Small CSS change to allow the `source-selector` to be brought to the front of the controls to allow people to select which source they would like. --- ui/v2.5/src/components/ScenePlayer/styles.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui/v2.5/src/components/ScenePlayer/styles.scss b/ui/v2.5/src/components/ScenePlayer/styles.scss index 1b4fc6b25fc..b3ed8445eca 100644 --- a/ui/v2.5/src/components/ScenePlayer/styles.scss +++ b/ui/v2.5/src/components/ScenePlayer/styles.scss @@ -360,6 +360,10 @@ $sceneTabWidth: 450px; height: 1.4em; width: 1.4em; } + + .vjs-source-selector .vjs-menu { + z-index: 9999; + } } .vjs-menu-button-popup .vjs-menu { From a2153ced5258443eaa79086cdabe0677f4516db2 Mon Sep 17 00:00:00 2001 From: Gykes Date: Mon, 9 Sep 2024 21:52:12 -0700 Subject: [PATCH 18/91] Bottom Page-Count button causes scenes page to reset to top (#5241) I think was was happening is the browser was trying to do too much at once (Rendering the popup and focusing the input simultaneously). I believe it was triggering a reflow and setting the site back to default aka: back to the top. I set the timeout to 0 which moves the execution to the next loop event. It gives the browser time to do one and then the other, not both at the same time. I moved `onKeyPress` to `onKeyDown` due to the former being depreciated. --- ui/v2.5/src/components/List/Pagination.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/ui/v2.5/src/components/List/Pagination.tsx b/ui/v2.5/src/components/List/Pagination.tsx index 3db7d2f82aa..ab151188261 100644 --- a/ui/v2.5/src/components/List/Pagination.tsx +++ b/ui/v2.5/src/components/List/Pagination.tsx @@ -19,16 +19,16 @@ const PageCount: React.FC<{ onChangePage: (page: number) => void; }> = ({ totalPages, currentPage, onChangePage }) => { const intl = useIntl(); - const currentPageCtrl = useRef(null); - const [pageInput, pageFocus] = useFocus(); - const [showSelectPage, setShowSelectPage] = useState(false); useEffect(() => { if (showSelectPage) { - pageFocus(); + // delaying the focus to the next execution loop so that rendering takes place first and stops the page from resetting. + setTimeout(() => { + pageFocus(); + }, 0); } }, [showSelectPage, pageFocus]); @@ -105,7 +105,7 @@ const PageCount: React.FC<{ className="text-input" ref={pageInput} defaultValue={currentPage} - onKeyPress={(e: React.KeyboardEvent) => { + onKeyDown={(e: React.KeyboardEvent) => { if (e.key === "Enter") { onCustomChangePage(); e.preventDefault(); @@ -152,7 +152,6 @@ export const Pagination: React.FC = ({ onChangePage, }) => { const intl = useIntl(); - const totalPages = useMemo( () => Math.ceil(totalItems / itemsPerPage), [totalItems, itemsPerPage] From 653cd16eb2d78a051bcd52493f8ae7c76b4c8232 Mon Sep 17 00:00:00 2001 From: Gykes Date: Mon, 9 Sep 2024 21:54:15 -0700 Subject: [PATCH 19/91] Updating Reload Scrapers formatting (#5235) Per convo with people on Discord. I have updated the Reload Scrapers UI. It now adds a button if the filter box appears and then the button extends and takes up the whole space if the filter box does not exist. --------- Co-authored-by: CJ Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- ui/v2.5/src/components/Shared/ScraperMenu.tsx | 38 +++++++++---------- ui/v2.5/src/components/Shared/styles.scss | 30 +++++++++++++++ 2 files changed, 48 insertions(+), 20 deletions(-) diff --git a/ui/v2.5/src/components/Shared/ScraperMenu.tsx b/ui/v2.5/src/components/Shared/ScraperMenu.tsx index f73103e57b7..4cc38b6f858 100644 --- a/ui/v2.5/src/components/Shared/ScraperMenu.tsx +++ b/ui/v2.5/src/components/Shared/ScraperMenu.tsx @@ -1,14 +1,12 @@ import React, { useMemo, useState } from "react"; -import { Dropdown } from "react-bootstrap"; -import { FormattedMessage, useIntl } from "react-intl"; +import { Dropdown, Button } from "react-bootstrap"; +import { useIntl } from "react-intl"; import { Icon } from "./Icon"; 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"; -const minFilteredScrapers = 5; - export const ScraperMenu: React.FC<{ toggle: React.ReactNode; variant?: string; @@ -54,22 +52,22 @@ export const ScraperMenu: React.FC<{ {toggle} - onReloadScrapers()}> - - - - - - - - - {(stashBoxes?.length ?? 0) + scrapers.length > minFilteredScrapers && ( - - )} +
+
+ + +
+
{filteredStashboxes.map((s, index) => ( Date: Wed, 11 Sep 2024 11:46:41 +1000 Subject: [PATCH 20/91] Fix performer disambiguation styling in select (#5246) --- ui/v2.5/src/components/Performers/styles.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index b1e59653e8e..c1f891f6b3e 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -199,6 +199,10 @@ } } +.performer-select-value .performer-disambiguation { + color: initial; +} + .performer-select-option { .performer-select-row { align-items: center; From 5407596e0d36c9f5a50d02a8c9ebdf8a4fb13a96 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 11 Sep 2024 11:50:27 +1000 Subject: [PATCH 21/91] Anonymise missing fields (#5244) * Anonymise missing fields: - galleries.photographer - performers.disambiguation - gallery_urls * Anonymise captions and saved filters --- pkg/sqlite/anonymise.go | 103 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 95 insertions(+), 8 deletions(-) diff --git a/pkg/sqlite/anonymise.go b/pkg/sqlite/anonymise.go index 78c5f4ab1a6..f9396034cd5 100644 --- a/pkg/sqlite/anonymise.go +++ b/pkg/sqlite/anonymise.go @@ -51,6 +51,7 @@ func (db *Anonymiser) Anonymise(ctx context.Context) error { func() error { return db.clearWatchHistory() }, func() error { return db.anonymiseFolders(ctx) }, func() error { return db.anonymiseFiles(ctx) }, + func() error { return db.anonymiseCaptions(ctx) }, func() error { return db.anonymiseFingerprints(ctx) }, func() error { return db.anonymiseScenes(ctx) }, func() error { return db.anonymiseMarkers(ctx) }, @@ -60,6 +61,7 @@ func (db *Anonymiser) Anonymise(ctx context.Context) error { func() error { return db.anonymiseStudios(ctx) }, func() error { return db.anonymiseTags(ctx) }, func() error { return db.anonymiseGroups(ctx) }, + func() error { return db.anonymiseSavedFilters(ctx) }, func() error { return db.Optimise(ctx) }, }) }(); err != nil { @@ -173,6 +175,20 @@ func (db *Anonymiser) anonymiseFiles(ctx context.Context) error { }) } +func (db *Anonymiser) anonymiseCaptions(ctx context.Context) error { + logger.Infof("Anonymising captions") + return txn.WithTxn(ctx, db, func(ctx context.Context) error { + table := goqu.T(videoCaptionsTable) + stmt := dialect.Update(table).Set(goqu.Record{"filename": goqu.Cast(table.Col("file_id"), "VARCHAR")}) + + if _, err := exec(ctx, stmt); err != nil { + return fmt.Errorf("anonymising %s: %w", table.GetTable(), err) + } + + return nil + }) +} + func (db *Anonymiser) anonymiseFingerprints(ctx context.Context) error { logger.Infof("Anonymising fingerprints") table := fingerprintTableMgr.table @@ -446,6 +462,7 @@ func (db *Anonymiser) anonymiseGalleries(ctx context.Context) error { table.Col(idColumn), table.Col("title"), table.Col("details"), + table.Col("photographer"), ).Where(table.Col(idColumn).Gt(lastID)).Limit(1000) gotSome = false @@ -453,15 +470,17 @@ func (db *Anonymiser) anonymiseGalleries(ctx context.Context) error { const single = false return queryFunc(ctx, query, single, func(rows *sqlx.Rows) error { var ( - id int - title sql.NullString - details sql.NullString + id int + title sql.NullString + details sql.NullString + photographer sql.NullString ) if err := rows.Scan( &id, &title, &details, + &photographer, ); err != nil { return err } @@ -469,6 +488,7 @@ func (db *Anonymiser) anonymiseGalleries(ctx context.Context) error { set := goqu.Record{} db.obfuscateNullString(set, "title", title) db.obfuscateNullString(set, "details", details) + db.obfuscateNullString(set, "photographer", photographer) if len(set) > 0 { stmt := dialect.Update(table).Set(set).Where(table.Col(idColumn).Eq(id)) @@ -493,6 +513,10 @@ func (db *Anonymiser) anonymiseGalleries(ctx context.Context) error { } } + if err := db.anonymiseURLs(ctx, goqu.T(galleriesURLsTable), "gallery_id"); err != nil { + return err + } + return nil } @@ -508,6 +532,7 @@ func (db *Anonymiser) anonymisePerformers(ctx context.Context) error { query := dialect.From(table).Select( table.Col(idColumn), table.Col("name"), + table.Col("disambiguation"), table.Col("details"), table.Col("tattoos"), table.Col("piercings"), @@ -518,16 +543,18 @@ func (db *Anonymiser) anonymisePerformers(ctx context.Context) error { const single = false return queryFunc(ctx, query, single, func(rows *sqlx.Rows) error { var ( - id int - name sql.NullString - details sql.NullString - tattoos sql.NullString - piercings sql.NullString + id int + name sql.NullString + disambiguation sql.NullString + details sql.NullString + tattoos sql.NullString + piercings sql.NullString ) if err := rows.Scan( &id, &name, + &disambiguation, &details, &tattoos, &piercings, @@ -537,6 +564,7 @@ func (db *Anonymiser) anonymisePerformers(ctx context.Context) error { set := goqu.Record{} db.obfuscateNullString(set, "name", name) + db.obfuscateNullString(set, "disambiguation", disambiguation) db.obfuscateNullString(set, "details", details) db.obfuscateNullString(set, "tattoos", tattoos) db.obfuscateNullString(set, "piercings", piercings) @@ -914,6 +942,65 @@ func (db *Anonymiser) anonymiseGroups(ctx context.Context) error { return nil } +func (db *Anonymiser) anonymiseSavedFilters(ctx context.Context) error { + logger.Infof("Anonymising saved filters") + table := savedFilterTableMgr.table + lastID := 0 + total := 0 + const logEvery = 10000 + + for gotSome := true; gotSome; { + if err := txn.WithTxn(ctx, db, func(ctx context.Context) error { + query := dialect.From(table).Select( + table.Col(idColumn), + table.Col("name"), + ).Where(table.Col(idColumn).Gt(lastID)).Limit(1000) + + gotSome = false + + const single = false + return queryFunc(ctx, query, single, func(rows *sqlx.Rows) error { + var ( + id int + name sql.NullString + ) + + if err := rows.Scan( + &id, + &name, + ); err != nil { + return err + } + + set := goqu.Record{} + db.obfuscateNullString(set, "name", name) + + if len(set) > 0 { + stmt := dialect.Update(table).Set(set).Where(table.Col(idColumn).Eq(id)) + + if _, err := exec(ctx, stmt); err != nil { + return fmt.Errorf("anonymising %s: %w", table.GetTable(), err) + } + } + + lastID = id + gotSome = true + total++ + + if total%logEvery == 0 { + logger.Infof("Anonymised %d saved filters", total) + } + + return nil + }) + }); err != nil { + return err + } + } + + return nil +} + func (db *Anonymiser) anonymiseText(ctx context.Context, table exp.IdentifierExpression, column string, value string) error { set := goqu.Record{} set[column] = db.obfuscateString(value, letters) From b897de3e5ecb2ac91e15c3bc5b7f7b563d78ed9d Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 11 Sep 2024 11:50:40 +1000 Subject: [PATCH 22/91] Fix hover scrubber error in Firefox (#5243) --- ui/v2.5/src/components/Shared/HoverScrubber.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/Shared/HoverScrubber.tsx b/ui/v2.5/src/components/Shared/HoverScrubber.tsx index e9f3f8209e1..8fd97e1a790 100644 --- a/ui/v2.5/src/components/Shared/HoverScrubber.tsx +++ b/ui/v2.5/src/components/Shared/HoverScrubber.tsx @@ -1,6 +1,9 @@ import React, { useMemo } from "react"; import cx from "classnames"; +// #5231: TouchEvent is not defined on all browsers +const touchEventDefined = window.TouchEvent !== undefined; + interface IHoverScrubber { totalSprites: number; activeIndex: number | undefined; @@ -24,7 +27,7 @@ export const HoverScrubber: React.FC = ({ let x = 0; if (e.nativeEvent instanceof MouseEvent) { x = e.nativeEvent.offsetX; - } else if (e.nativeEvent instanceof TouchEvent) { + } else if (touchEventDefined && e.nativeEvent instanceof TouchEvent) { x = e.nativeEvent.touches[0].clientX - e.currentTarget.getBoundingClientRect().x; @@ -47,7 +50,8 @@ export const HoverScrubber: React.FC = ({ if ( (e instanceof MouseEvent && relatedTarget !== e.target) || - (e instanceof TouchEvent && + (touchEventDefined && + e instanceof TouchEvent && document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY)) ) return; @@ -70,7 +74,8 @@ export const HoverScrubber: React.FC = ({ if ( (e instanceof MouseEvent && relatedTarget !== e.target) || - (e instanceof TouchEvent && + (touchEventDefined && + e instanceof TouchEvent && document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY)) ) return; From a3838734c56ffb3d1e28bdde892346bda6f3b85e Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 11 Sep 2024 11:55:06 +1000 Subject: [PATCH 23/91] Set max-height for all modals (#5242) --- ui/v2.5/src/components/Help/styles.scss | 5 +++++ ui/v2.5/src/components/List/styles.scss | 3 +-- ui/v2.5/src/index.scss | 6 ++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/components/Help/styles.scss b/ui/v2.5/src/components/Help/styles.scss index 311876ea273..cf53f48f644 100644 --- a/ui/v2.5/src/components/Help/styles.scss +++ b/ui/v2.5/src/components/Help/styles.scss @@ -21,6 +21,11 @@ padding-left: 2rem; } + .modal-body { + // reset max-height so that we don't end up with two scroll bars + max-height: initial; + } + .manual-content, .manual-toc { max-height: calc(100vh - 10rem); diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 632ac9533fc..9f9519b351f 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -154,6 +154,7 @@ input[type="range"].zoom-slider { } .modal-body { + max-height: min(550px, calc(100vh - 12rem)); padding-left: 0; padding-right: 0; } @@ -166,8 +167,6 @@ input[type="range"].zoom-slider { .criterion-list { flex-direction: column; flex-wrap: nowrap; - max-height: 550px; - overflow-y: auto; .pinned-criterion-divider { padding-bottom: 2.5rem; diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index a2dce6acb92..8ad3ee1782a 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -1385,3 +1385,9 @@ select { .table-list .rating-number { width: 6rem; } + +.modal-body { + max-height: calc(100vh - 12rem); + overflow-y: auto; + padding-right: 1.5rem; +} From 129dd0ffcc4747b2d6fed6b73c761792ad253fc0 Mon Sep 17 00:00:00 2001 From: GlitchGal Date: Wed, 11 Sep 2024 03:56:17 +0200 Subject: [PATCH 24/91] ImageDetailPanel Patch Component (#5245) --- .../Images/ImageDetails/ImageDetailPanel.tsx | 232 +++++++++--------- ui/v2.5/src/docs/en/Manual/UIPluginApi.md | 1 + 2 files changed, 119 insertions(+), 114 deletions(-) diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx index 65cce87de64..a2044fcffe4 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx @@ -6,131 +6,135 @@ import { PerformerCard } from "src/components/Performers/PerformerCard"; import { sortPerformers } from "src/core/performers"; import { FormattedMessage, useIntl } from "react-intl"; import { PhotographerLink } from "src/components/Shared/Link"; +import { PatchComponent } from "../../../patch"; interface IImageDetailProps { image: GQL.ImageDataFragment; } -export const ImageDetailPanel: React.FC = (props) => { - const intl = useIntl(); +export const ImageDetailPanel: React.FC = PatchComponent( + "ImageDetailPanel", + (props) => { + const intl = useIntl(); - function renderDetails() { - if (!props.image.details) return; - return ( - <> -
- :{" "} -
-

{props.image.details}

- - ); - } + function renderDetails() { + if (!props.image.details) return; + return ( + <> +
+ :{" "} +
+

{props.image.details}

+ + ); + } - function renderTags() { - if (props.image.tags.length === 0) return; - const tags = props.image.tags.map((tag) => ( - - )); - return ( - <> -
- -
- {tags} - - ); - } + function renderTags() { + if (props.image.tags.length === 0) return; + const tags = props.image.tags.map((tag) => ( + + )); + return ( + <> +
+ +
+ {tags} + + ); + } - function renderPerformers() { - if (props.image.performers.length === 0) return; - const performers = sortPerformers(props.image.performers); - const cards = performers.map((performer) => ( - - )); + function renderPerformers() { + if (props.image.performers.length === 0) return; + const performers = sortPerformers(props.image.performers); + const cards = performers.map((performer) => ( + + )); - return ( - <> -
- -
-
- {cards} -
- - ); - } + return ( + <> +
+ +
+
+ {cards} +
+ + ); + } + + function renderGalleries() { + if (props.image.galleries.length === 0) return; + const galleries = props.image.galleries.map((gallery) => ( + + )); + return ( + <> +
+ +
+ {galleries} + + ); + } + + // filename should use entire row if there is no studio + const imageDetailsWidth = props.image.studio ? "col-9" : "col-12"; - function renderGalleries() { - if (props.image.galleries.length === 0) return; - const galleries = props.image.galleries.map((gallery) => ( - - )); return ( <> -
- -
- {galleries} +
+
+ {renderGalleries()} + { +
+ {" "} + :{" "} + {TextUtils.formatDateTime(intl, props.image.created_at)}{" "} +
+ } + { +
+ :{" "} + {TextUtils.formatDateTime(intl, props.image.updated_at)}{" "} +
+ } + {props.image.code && ( +
+ : {props.image.code}{" "} +
+ )} + {props.image.photographer && ( +
+ :{" "} + +
+ )} +
+
+
+
+ {renderDetails()} + {renderTags()} + {renderPerformers()} +
+
); } - - // filename should use entire row if there is no studio - const imageDetailsWidth = props.image.studio ? "col-9" : "col-12"; - - return ( - <> -
-
- {renderGalleries()} - { -
- {" "} - :{" "} - {TextUtils.formatDateTime(intl, props.image.created_at)}{" "} -
- } - { -
- :{" "} - {TextUtils.formatDateTime(intl, props.image.updated_at)}{" "} -
- } - {props.image.code && ( -
- : {props.image.code}{" "} -
- )} - {props.image.photographer && ( -
- :{" "} - -
- )} -
-
-
-
- {renderDetails()} - {renderTags()} - {renderPerformers()} -
-
- - ); -}; +); diff --git a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md index 1357e86cc28..7d267d1b1e9 100644 --- a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md +++ b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md @@ -151,6 +151,7 @@ Returns `void`. - `GallerySelect` - `GallerySelect.sort` - `Icon` +- `ImageDetailPanel` - `ModalSetting` - `MovieIDSelect` - `MovieSelect` From d1c207e40b9d330a05b5737687225554edd947cc Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 11 Sep 2024 13:39:46 +1000 Subject: [PATCH 25/91] Rename movies to groups in menu items in 65 post-migration (#5247) * Only backup config file if needed in 58 migration * Change movies to groups in menu items in 65 post-migration --- pkg/sqlite/migrations/58_postmigrate.go | 7 +- pkg/sqlite/migrations/65_postmigrate.go | 88 +++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 pkg/sqlite/migrations/65_postmigrate.go diff --git a/pkg/sqlite/migrations/58_postmigrate.go b/pkg/sqlite/migrations/58_postmigrate.go index 1afdd78e798..dfe85f4d006 100644 --- a/pkg/sqlite/migrations/58_postmigrate.go +++ b/pkg/sqlite/migrations/58_postmigrate.go @@ -129,6 +129,12 @@ func (m *schema58Migrator) migrateConfig() error { return nil } + ui := c.GetUIConfiguration() + if len(ui) == 0 { + // no UI config to migrate + return nil + } + // save a backup of the original config file backupPath := fmt.Sprintf("%s.57.%s", orgPath, time.Now().Format("20060102_150405")) @@ -143,7 +149,6 @@ func (m *schema58Migrator) migrateConfig() error { } // migrate the plugin and UI configs from snake_case to camelCase - ui := c.GetUIConfiguration() if ui != nil { ui = m.fromSnakeCaseMap(ui) diff --git a/pkg/sqlite/migrations/65_postmigrate.go b/pkg/sqlite/migrations/65_postmigrate.go new file mode 100644 index 00000000000..6738b6670e2 --- /dev/null +++ b/pkg/sqlite/migrations/65_postmigrate.go @@ -0,0 +1,88 @@ +package migrations + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/jmoiron/sqlx" + "github.com/stashapp/stash/internal/manager/config" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/sqlite" +) + +type schema65Migrator struct { + migrator +} + +func post65(ctx context.Context, db *sqlx.DB) error { + logger.Info("Running post-migration for schema version 65") + + m := schema65Migrator{ + migrator: migrator{ + db: db, + }, + } + + return m.migrate() +} + +func (m *schema65Migrator) migrate() error { + if err := m.migrateConfig(); err != nil { + return fmt.Errorf("failed to migrate config: %w", err) + } + + return nil +} + +func (m *schema65Migrator) migrateConfig() error { + c := config.GetInstance() + + orgPath := c.GetConfigFile() + + if orgPath == "" { + // no config file to migrate (usually in a test) + return nil + } + + items := c.GetMenuItems() + replaced := false + + // replace "movies" with "groups" in the menu items + for i, item := range items { + if item == "movies" { + items[i] = "groups" + replaced = true + } + } + + if !replaced { + return nil + } + + // save a backup of the original config file + backupPath := fmt.Sprintf("%s.64.%s", orgPath, time.Now().Format("20060102_150405")) + + data, err := c.Marshal() + if err != nil { + return fmt.Errorf("failed to marshal backup config: %w", err) + } + + logger.Infof("Backing up config to %s", backupPath) + if err := os.WriteFile(backupPath, data, 0644); err != nil { + return fmt.Errorf("failed to write backup config: %w", err) + } + + c.SetInterface(config.MenuItems, items) + + if err := c.Write(); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + + return nil +} + +func init() { + sqlite.RegisterPostMigration(65, post65) +} From a17199ba216082d1763cac9fd0b24a464dbe0297 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 11 Sep 2024 13:58:02 +1000 Subject: [PATCH 26/91] Handle symlink zip files (#5249) --- pkg/file/fs.go | 9 ++------- pkg/file/scan.go | 4 ++-- pkg/file/zip.go | 8 +++----- pkg/models/fs.go | 2 +- pkg/models/model_file.go | 4 ++-- pkg/sqlite/file.go | 4 ++++ pkg/sqlite/folder.go | 4 ++++ 7 files changed, 18 insertions(+), 17 deletions(-) diff --git a/pkg/file/fs.go b/pkg/file/fs.go index 80148cfa126..0311ed5abd6 100644 --- a/pkg/file/fs.go +++ b/pkg/file/fs.go @@ -58,13 +58,8 @@ func (f *OsFS) Open(name string) (fs.ReadDirFile, error) { return os.Open(name) } -func (f *OsFS) OpenZip(name string) (models.ZipFS, error) { - info, err := f.Lstat(name) - if err != nil { - return nil, err - } - - return newZipFS(f, name, info) +func (f *OsFS) OpenZip(name string, size int64) (models.ZipFS, error) { + return newZipFS(f, name, size) } func (f *OsFS) IsPathCaseSensitive(path string) (bool, error) { diff --git a/pkg/file/scan.go b/pkg/file/scan.go index fac49308099..3cfc4c26b22 100644 --- a/pkg/file/scan.go +++ b/pkg/file/scan.go @@ -341,7 +341,7 @@ func (s *scanJob) acceptEntry(ctx context.Context, path string, info fs.FileInfo } func (s *scanJob) scanZipFile(ctx context.Context, f scanFile) error { - zipFS, err := f.fs.OpenZip(f.Path) + zipFS, err := f.fs.OpenZip(f.Path, f.Size) if err != nil { if errors.Is(err, errNotReaderAt) { // can't walk the zip file @@ -838,7 +838,7 @@ func (s *scanJob) getFileFS(f *models.BaseFile) (models.FS, error) { } zipPath := f.ZipFile.Base().Path - return fs.OpenZip(zipPath) + return fs.OpenZip(zipPath, f.Size) } func (s *scanJob) handleRename(ctx context.Context, f models.File, fp []models.Fingerprint) (models.File, error) { diff --git a/pkg/file/zip.go b/pkg/file/zip.go index a17b596852f..4df2453dc1e 100644 --- a/pkg/file/zip.go +++ b/pkg/file/zip.go @@ -26,11 +26,10 @@ var ( type zipFS struct { *zip.Reader zipFileCloser io.Closer - zipInfo fs.FileInfo zipPath string } -func newZipFS(fs models.FS, path string, info fs.FileInfo) (*zipFS, error) { +func newZipFS(fs models.FS, path string, size int64) (*zipFS, error) { reader, err := fs.Open(path) if err != nil { return nil, err @@ -42,7 +41,7 @@ func newZipFS(fs models.FS, path string, info fs.FileInfo) (*zipFS, error) { return nil, errNotReaderAt } - zipReader, err := zip.NewReader(asReaderAt, info.Size()) + zipReader, err := zip.NewReader(asReaderAt, size) if err != nil { reader.Close() return nil, err @@ -89,7 +88,6 @@ func newZipFS(fs models.FS, path string, info fs.FileInfo) (*zipFS, error) { return &zipFS{ Reader: zipReader, zipFileCloser: reader, - zipInfo: info, zipPath: path, }, nil } @@ -125,7 +123,7 @@ func (f *zipFS) Lstat(name string) (fs.FileInfo, error) { return f.Stat(name) } -func (f *zipFS) OpenZip(name string) (models.ZipFS, error) { +func (f *zipFS) OpenZip(name string, size int64) (models.ZipFS, error) { return nil, errZipFSOpenZip } diff --git a/pkg/models/fs.go b/pkg/models/fs.go index bdbf603498d..771c7c21177 100644 --- a/pkg/models/fs.go +++ b/pkg/models/fs.go @@ -15,7 +15,7 @@ type FS interface { Stat(name string) (fs.FileInfo, error) Lstat(name string) (fs.FileInfo, error) Open(name string) (fs.ReadDirFile, error) - OpenZip(name string) (ZipFS, error) + OpenZip(name string, size int64) (ZipFS, error) IsPathCaseSensitive(path string) (bool, error) } diff --git a/pkg/models/model_file.go b/pkg/models/model_file.go index 45e75547404..e9df57990e5 100644 --- a/pkg/models/model_file.go +++ b/pkg/models/model_file.go @@ -93,7 +93,7 @@ type DirEntry struct { func (e *DirEntry) info(fs FS, path string) (fs.FileInfo, error) { if e.ZipFile != nil { zipPath := e.ZipFile.Base().Path - zfs, err := fs.OpenZip(zipPath) + zfs, err := fs.OpenZip(zipPath, e.ZipFile.Base().Size) if err != nil { return nil, err } @@ -163,7 +163,7 @@ func (f *BaseFile) Base() *BaseFile { func (f *BaseFile) Open(fs FS) (io.ReadCloser, error) { if f.ZipFile != nil { zipPath := f.ZipFile.Base().Path - zfs, err := fs.OpenZip(zipPath) + zfs, err := fs.OpenZip(zipPath, f.ZipFile.Base().Size) if err != nil { return nil, err } diff --git a/pkg/sqlite/file.go b/pkg/sqlite/file.go index 6cd74eb34cd..6bf6e32b51f 100644 --- a/pkg/sqlite/file.go +++ b/pkg/sqlite/file.go @@ -178,6 +178,7 @@ type fileQueryRow struct { ZipBasename null.String `db:"zip_basename"` ZipFolderPath null.String `db:"zip_folder_path"` + ZipSize null.Int `db:"zip_size"` FolderPath null.String `db:"parent_folder_path"` fingerprintQueryRow @@ -205,6 +206,7 @@ func (r *fileQueryRow) resolve() models.File { ID: *basic.ZipFileID, Path: filepath.Join(r.ZipFolderPath.String, r.ZipBasename.String), Basename: r.ZipBasename.String, + Size: r.ZipSize.Int64, } } @@ -461,6 +463,8 @@ func (qb *FileStore) selectDataset() *goqu.SelectDataset { fingerprintTable.Col("fingerprint"), zipFileTable.Col("basename").As("zip_basename"), zipFolderTable.Col("path").As("zip_folder_path"), + // size is needed to open containing zip files + zipFileTable.Col("size").As("zip_size"), } cols = append(cols, videoFileQueryColumns()...) diff --git a/pkg/sqlite/folder.go b/pkg/sqlite/folder.go index 26cbf896252..4cf632d49e6 100644 --- a/pkg/sqlite/folder.go +++ b/pkg/sqlite/folder.go @@ -41,6 +41,7 @@ type folderQueryRow struct { ZipBasename null.String `db:"zip_basename"` ZipFolderPath null.String `db:"zip_folder_path"` + ZipSize null.Int `db:"zip_size"` } func (r *folderQueryRow) resolve() *models.Folder { @@ -61,6 +62,7 @@ func (r *folderQueryRow) resolve() *models.Folder { ID: *ret.ZipFileID, Path: filepath.Join(r.ZipFolderPath.String, r.ZipBasename.String), Basename: r.ZipBasename.String, + Size: r.ZipSize.Int64, } } @@ -148,6 +150,8 @@ func (qb *FolderStore) selectDataset() *goqu.SelectDataset { table.Col("updated_at"), zipFileTable.Col("basename").As("zip_basename"), zipFolderTable.Col("path").As("zip_folder_path"), + // size is needed to open containing zip files + zipFileTable.Col("size").As("zip_size"), } ret := dialect.From(table).Select(cols...) From 71e39e5cb8e776c64028e7be22426772429bf685 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 11 Sep 2024 14:02:00 +1000 Subject: [PATCH 27/91] Default database backup to same directory as database (#5250) --- internal/manager/config/config.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 3cba5e1f261..6a568c1da0b 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -702,7 +702,8 @@ func (i *Config) GetBackupDirectoryPath() string { func (i *Config) GetBackupDirectoryPathOrDefault() string { ret := i.GetBackupDirectoryPath() if ret == "" { - return i.GetConfigPath() + // #4915 - default to the same directory as the database + return filepath.Dir(i.GetDatabasePath()) } return ret From 17be7e97d312c7b07f75bfe72228ed8328923eda Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 11 Sep 2024 14:29:16 +1000 Subject: [PATCH 28/91] Emit error in SafeMove if remove from source fails (#5251) --- pkg/fsutil/file.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pkg/fsutil/file.go b/pkg/fsutil/file.go index 19cf5ced65f..db5a1315506 100644 --- a/pkg/fsutil/file.go +++ b/pkg/fsutil/file.go @@ -8,8 +8,6 @@ import ( "regexp" "runtime" "strings" - - "github.com/stashapp/stash/pkg/logger" ) // CopyFile copies the contents of the file at srcpath to a regular file at dstpath. @@ -56,6 +54,7 @@ func CopyFile(srcpath, dstpath string) (err error) { } // SafeMove attempts to move the file with path src to dest using os.Rename. If this fails, then it copies src to dest, then deletes src. +// If the copy fails, or the delete fails, the function will return an error. func SafeMove(src, dst string) error { err := os.Rename(src, dst) @@ -65,9 +64,11 @@ func SafeMove(src, dst string) error { return fmt.Errorf("copying file during SaveMove failed with: '%w'; renaming file failed previously with: '%v'", copyErr, err) } - err = os.Remove(src) - if err != nil { - logger.Errorf("error removing old file %s during SafeMove: %v", src, err) + removeErr := os.Remove(src) + if removeErr != nil { + // if we can't remove the old file, remove the new one and fail + _ = os.Remove(dst) + return fmt.Errorf("removing old file during SafeMove failed with: '%w'; renaming file failed previously with: '%v'", removeErr, err) } } From 5c4bf4ecdf0dd99f00f9700eb0806919bf9eeb0f Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 11 Sep 2024 16:12:18 +1000 Subject: [PATCH 29/91] Add portals for selects in dialogs (#5253) --- ui/v2.5/src/components/Dialogs/IdentifyDialog/Options.tsx | 2 ++ ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx | 2 ++ ui/v2.5/src/components/Groups/ContainingGroupsMultiSet.tsx | 3 +++ ui/v2.5/src/components/Groups/EditGroupsDialog.tsx | 3 +++ .../src/components/Groups/GroupDetails/RelatedGroupTable.tsx | 3 +++ ui/v2.5/src/components/Images/EditImagesDialog.tsx | 4 ++++ ui/v2.5/src/components/Performers/EditPerformersDialog.tsx | 1 + ui/v2.5/src/components/Scenes/EditScenesDialog.tsx | 2 ++ ui/v2.5/src/components/Shared/MultiSet.tsx | 3 +++ ui/v2.5/src/components/Tags/EditTagsDialog.tsx | 1 + 10 files changed, 24 insertions(+) diff --git a/ui/v2.5/src/components/Dialogs/IdentifyDialog/Options.tsx b/ui/v2.5/src/components/Dialogs/IdentifyDialog/Options.tsx index 0bc31e6ae47..1362df02a96 100644 --- a/ui/v2.5/src/components/Dialogs/IdentifyDialog/Options.tsx +++ b/ui/v2.5/src/components/Dialogs/IdentifyDialog/Options.tsx @@ -63,6 +63,7 @@ export const OptionsEditor: React.FC = ({ options.skipMultipleMatchTag ? [options.skipMultipleMatchTag] : [] } noSelectionString="Select/create tag..." + menuPortalTarget={document.body} /> @@ -98,6 +99,7 @@ export const OptionsEditor: React.FC = ({ : [] } noSelectionString="Select/create tag..." + menuPortalTarget={document.body} /> diff --git a/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx b/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx index 533541302e1..9ff7e00f210 100644 --- a/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx +++ b/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx @@ -211,6 +211,7 @@ export const EditGalleriesDialog: React.FC = ( existingIds={existingIds ?? []} ids={ids ?? []} mode={mode} + menuPortalTarget={document.body} /> ); } @@ -273,6 +274,7 @@ export const EditGalleriesDialog: React.FC = ( } ids={studioId ? [studioId] : []} isDisabled={isUpdating} + menuPortalTarget={document.body} /> diff --git a/ui/v2.5/src/components/Groups/ContainingGroupsMultiSet.tsx b/ui/v2.5/src/components/Groups/ContainingGroupsMultiSet.tsx index 25ad0be4a3e..6e61e0ccf99 100644 --- a/ui/v2.5/src/components/Groups/ContainingGroupsMultiSet.tsx +++ b/ui/v2.5/src/components/Groups/ContainingGroupsMultiSet.tsx @@ -14,6 +14,7 @@ export const ContainingGroupsMultiSet: React.FC<{ disabled?: boolean; onUpdate: (value: IRelatedGroupEntry[]) => void; onSetMode: (mode: GQL.BulkUpdateIdMode) => void; + menuPortalTarget?: HTMLElement | null; }> = (props) => { const { mode, onUpdate, existingValue } = props; @@ -48,12 +49,14 @@ export const ContainingGroupsMultiSet: React.FC<{ value={props.value} onUpdate={props.onUpdate} disabled={props.disabled} + menuPortalTarget={props.menuPortalTarget} /> ) : ( onRemoveSet(items)} values={[]} isDisabled={props.disabled} + menuPortalTarget={props.menuPortalTarget} /> )}
diff --git a/ui/v2.5/src/components/Groups/EditGroupsDialog.tsx b/ui/v2.5/src/components/Groups/EditGroupsDialog.tsx index d404ccf9c33..efd14e757a7 100644 --- a/ui/v2.5/src/components/Groups/EditGroupsDialog.tsx +++ b/ui/v2.5/src/components/Groups/EditGroupsDialog.tsx @@ -227,6 +227,7 @@ export const EditGroupsDialog: React.FC = ( } ids={studioId ? [studioId] : []} isDisabled={isUpdating} + menuPortalTarget={document.body} /> @@ -241,6 +242,7 @@ export const EditGroupsDialog: React.FC = ( existingValue={existingContainingGroups ?? []} value={containingGroups ?? []} mode={containingGroupsMode} + menuPortalTarget={document.body} /> @@ -267,6 +269,7 @@ export const EditGroupsDialog: React.FC = ( existingIds={existingTagIds ?? []} ids={tagIds ?? []} mode={tagMode} + menuPortalTarget={document.body} /> diff --git a/ui/v2.5/src/components/Groups/GroupDetails/RelatedGroupTable.tsx b/ui/v2.5/src/components/Groups/GroupDetails/RelatedGroupTable.tsx index feed49ad09b..0b8ecb0c6b7 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/RelatedGroupTable.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/RelatedGroupTable.tsx @@ -19,6 +19,7 @@ export const RelatedGroupTable: React.FC<{ excludeIDs?: string[]; filterHook?: (f: ListFilterModel) => ListFilterModel; disabled?: boolean; + menuPortalTarget?: HTMLElement | null; }> = (props) => { const { value, onUpdate } = props; @@ -101,6 +102,7 @@ export const RelatedGroupTable: React.FC<{ excludeIds={excludeIDs} filterHook={props.filterHook} isDisabled={props.disabled} + menuPortalTarget={props.menuPortalTarget} /> @@ -129,6 +131,7 @@ export const RelatedGroupTable: React.FC<{ excludeIds={excludeIDs} filterHook={props.filterHook} isDisabled={props.disabled} + menuPortalTarget={props.menuPortalTarget} /> diff --git a/ui/v2.5/src/components/Images/EditImagesDialog.tsx b/ui/v2.5/src/components/Images/EditImagesDialog.tsx index bba8fc4c1ad..275ff155680 100644 --- a/ui/v2.5/src/components/Images/EditImagesDialog.tsx +++ b/ui/v2.5/src/components/Images/EditImagesDialog.tsx @@ -237,6 +237,7 @@ export const EditImagesDialog: React.FC = ( } ids={studioId ? [studioId] : []} isDisabled={isUpdating} + menuPortalTarget={document.body} /> @@ -253,6 +254,7 @@ export const EditImagesDialog: React.FC = ( existingIds={existingPerformerIds ?? []} ids={performerIds ?? []} mode={performerMode} + menuPortalTarget={document.body} /> @@ -268,6 +270,7 @@ export const EditImagesDialog: React.FC = ( existingIds={existingTagIds ?? []} ids={tagIds ?? []} mode={tagMode} + menuPortalTarget={document.body} /> @@ -283,6 +286,7 @@ export const EditImagesDialog: React.FC = ( existingIds={existingGalleryIds ?? []} ids={galleryIds ?? []} mode={galleryMode} + menuPortalTarget={document.body} /> diff --git a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx index bd3f6acd1f9..71fcbedd9e4 100644 --- a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx +++ b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx @@ -369,6 +369,7 @@ export const EditPerformersDialog: React.FC = ( existingIds={existingTagIds ?? []} ids={tagIds.ids ?? []} 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 06f3549a3cd..7b69cf65582 100644 --- a/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx +++ b/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx @@ -226,6 +226,7 @@ export const EditScenesDialog: React.FC = ( ids={ids ?? []} existingIds={existingIds ?? []} mode={mode} + menuPortalTarget={document.body} /> ); } @@ -288,6 +289,7 @@ export const EditScenesDialog: React.FC = ( } ids={studioId ? [studioId] : []} isDisabled={isUpdating} + menuPortalTarget={document.body} /> diff --git a/ui/v2.5/src/components/Shared/MultiSet.tsx b/ui/v2.5/src/components/Shared/MultiSet.tsx index 4ed7a99ffe4..b18c4087ab1 100644 --- a/ui/v2.5/src/components/Shared/MultiSet.tsx +++ b/ui/v2.5/src/components/Shared/MultiSet.tsx @@ -17,6 +17,7 @@ interface IMultiSetProps { disabled?: boolean; onUpdate: (ids: string[]) => void; onSetMode: (mode: GQL.BulkUpdateIdMode) => void; + menuPortalTarget?: HTMLElement | null; } const Select: React.FC = (props) => { @@ -36,6 +37,7 @@ const Select: React.FC = (props) => { ids={props.ids ?? []} // exclude file-based galleries when setting galleries extraCriteria={excludeFileBasedGalleries} + menuPortalTarget={props.menuPortalTarget} /> ); } @@ -48,6 +50,7 @@ const Select: React.FC = (props) => { isClearable={false} onSelect={onUpdate} ids={props.ids ?? []} + menuPortalTarget={props.menuPortalTarget} /> ); }; diff --git a/ui/v2.5/src/components/Tags/EditTagsDialog.tsx b/ui/v2.5/src/components/Tags/EditTagsDialog.tsx index d771ea1c94c..6780a8ce86e 100644 --- a/ui/v2.5/src/components/Tags/EditTagsDialog.tsx +++ b/ui/v2.5/src/components/Tags/EditTagsDialog.tsx @@ -48,6 +48,7 @@ function Tags(props: { existingIds={existingTagIds ?? []} ids={tagIDs.ids ?? []} mode={tagIDs.mode} + menuPortalTarget={document.body} /> ); From c9f76a01c5c24376cf91757334600c34276d6a6c Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 11 Sep 2024 17:29:27 +1000 Subject: [PATCH 30/91] Add UI option for rescan scan option (#5254) --- ui/v2.5/src/components/Settings/Tasks/ScanOptions.tsx | 8 ++++++++ ui/v2.5/src/docs/en/Manual/Tasks.md | 1 + ui/v2.5/src/locales/en-GB.json | 2 ++ 3 files changed, 11 insertions(+) diff --git a/ui/v2.5/src/components/Settings/Tasks/ScanOptions.tsx b/ui/v2.5/src/components/Settings/Tasks/ScanOptions.tsx index c23916d2eef..5b5c7809eda 100644 --- a/ui/v2.5/src/components/Settings/Tasks/ScanOptions.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/ScanOptions.tsx @@ -19,6 +19,7 @@ export const ScanOptions: React.FC = ({ scanGeneratePhashes, scanGenerateThumbnails, scanGenerateClipPreviews, + rescan, } = options; function setOptions(input: Partial) { @@ -77,6 +78,13 @@ export const ScanOptions: React.FC = ({ headingID="config.tasks.generate_clip_previews_during_scan" onChange={(v) => setOptions({ scanGenerateClipPreviews: v })} /> + setOptions({ rescan: v })} + /> ); }; diff --git a/ui/v2.5/src/docs/en/Manual/Tasks.md b/ui/v2.5/src/docs/en/Manual/Tasks.md index 9387c0d92b3..d3cd1f6a742 100644 --- a/ui/v2.5/src/docs/en/Manual/Tasks.md +++ b/ui/v2.5/src/docs/en/Manual/Tasks.md @@ -21,6 +21,7 @@ The scan task accepts the following options: | Generate perceptual hashes | Generates perceptual hashes for scene deduplication and identification. | | Generate thumbnails for images | Generates thumbnails for image files. | | Generate previews for image clips | Generates a gif/looping video as thumbnail for image clips/gifs. | +| Rescan | By default, Stash will only rescan existing files if the file's modified date has been updated since its previous scan. Stash will rescan files in the path when this option is enabled, regardless of the file modification time. Only required Stash needs to recalculate video/image metadata, or to rescan gallery zips. | ## Auto Tagging See the [Auto Tagging](/help/AutoTagging.md) page. diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index f2c5c5d1fb9..74073d1ccfb 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -545,6 +545,8 @@ "optimise_database": "Attempt to improve performance by analysing and then rebuilding the entire database file.", "optimise_database_warning": "Warning: while this task is running, any operations that modify the database will fail, and depending on your database size, it could take several minutes to complete. It also requires at the very minimum as much free disk space as your database is large, but 1.5x is recommended.", "plugin_tasks": "Plugin Tasks", + "rescan": "Rescan files", + "rescan_tooltip": "Rescan every file in the path. Used to force update file metadata and rescan zip files.", "scan": { "scanning_all_paths": "Scanning all paths", "scanning_paths": "Scanning the following paths" From f54304634913b9b7c8bd2d82d07a261df499ad9f Mon Sep 17 00:00:00 2001 From: Ian McKenzie <13459320+ikmckenz@users.noreply.github.com> Date: Sun, 15 Sep 2024 17:12:09 -0700 Subject: [PATCH 31/91] Update upload-artifact action to pass build (#5260) --- .github/workflows/build.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 02a5d973e5a..f424ef5d518 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -113,7 +113,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@v2 + uses: actions/upload-artifact@v4 with: name: stash-win.exe path: dist/stash-win.exe @@ -121,7 +121,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@v2 + uses: actions/upload-artifact@v4 with: name: stash-macos path: dist/stash-macos @@ -129,7 +129,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@v2 + uses: actions/upload-artifact@v4 with: name: stash-linux path: dist/stash-linux @@ -137,7 +137,7 @@ 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@v2 + uses: actions/upload-artifact@v4 with: name: stash-ui.zip path: dist/stash-ui.zip From e4ef14e8306a3a750f0c1b34132a33717d7a5114 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 16 Sep 2024 16:30:16 +1000 Subject: [PATCH 32/91] Fix preview scrubber touch issues (#5267) --- .../components/Galleries/GalleryPreviewScrubber.tsx | 8 ++++---- ui/v2.5/src/components/Scenes/PreviewScrubber.tsx | 13 ++++++++----- ui/v2.5/src/components/Shared/HoverScrubber.tsx | 9 ++++++--- ui/v2.5/src/hooks/sprite.ts | 7 +++++-- 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx b/ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx index 6bc10274ada..ef47782bff7 100644 --- a/ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx @@ -21,12 +21,12 @@ export const GalleryPreviewScrubber: React.FC<{ const [activeIndex, setActiveIndex] = useState(); const debounceSetActiveIndex = useThrottle(setActiveIndex, 50); - function onScrubberClick() { - if (activeIndex === undefined || !onClick) { + function onScrubberClick(index: number) { + if (!onClick) { return; } - onClick(activeIndex); + onClick(index); } useEffect(() => { @@ -47,7 +47,7 @@ export const GalleryPreviewScrubber: React.FC<{ totalSprites={imageCount} activeIndex={activeIndex} setActiveIndex={(i) => debounceSetActiveIndex(i)} - onClick={() => onScrubberClick()} + onClick={onScrubberClick} />
); diff --git a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx index cdfc3a6e9bd..eb8f2c10425 100644 --- a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx +++ b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx @@ -27,6 +27,8 @@ function scaleToFit(dimensions: { w: number; h: number }, bounds: DOMRect) { return Math.min(rw, rh); } +const defaultSprites = 81; // 9x9 grid by default + export const PreviewScrubber: React.FC = ({ vttPath, onClick, @@ -83,15 +85,16 @@ export const PreviewScrubber: React.FC = ({ return start; }, [sprite]); - function onScrubberClick() { - if (!sprite || !onClick) { + function onScrubberClick(index: number) { + if (!onClick || !spriteInfo) { return; } - onClick(sprite.start); + const s = spriteInfo[index]; + onClick(s.start); } - if (!spriteInfo && hasLoaded) return null; + if (spriteInfo === null) return null; return (
@@ -104,7 +107,7 @@ export const PreviewScrubber: React.FC = ({
)} debounceSetActiveIndex(i)} onClick={onScrubberClick} diff --git a/ui/v2.5/src/components/Shared/HoverScrubber.tsx b/ui/v2.5/src/components/Shared/HoverScrubber.tsx index 8fd97e1a790..7c07e8adc30 100644 --- a/ui/v2.5/src/components/Shared/HoverScrubber.tsx +++ b/ui/v2.5/src/components/Shared/HoverScrubber.tsx @@ -8,7 +8,7 @@ interface IHoverScrubber { totalSprites: number; activeIndex: number | undefined; setActiveIndex: (index: number | undefined) => void; - onClick?: () => void; + onClick?: (index: number) => void; } export const HoverScrubber: React.FC = ({ @@ -82,7 +82,10 @@ export const HoverScrubber: React.FC = ({ e.preventDefault(); e.stopPropagation(); - onClick(); + + const i = getActiveIndex(e); + if (i === undefined) return; + onClick(i); } const indicatorStyle = useMemo(() => { @@ -107,8 +110,8 @@ export const HoverScrubber: React.FC = ({ onTouchMove={onMove} onMouseLeave={onLeave} onTouchEnd={onLeave} + onTouchCancel={onLeave} onClick={onScrubberClick} - onTouchStart={onScrubberClick} />
{activeIndex !== undefined && ( diff --git a/ui/v2.5/src/hooks/sprite.ts b/ui/v2.5/src/hooks/sprite.ts index 8d66c2fa568..27a25c24804 100644 --- a/ui/v2.5/src/hooks/sprite.ts +++ b/ui/v2.5/src/hooks/sprite.ts @@ -35,9 +35,12 @@ function getSpriteInfo(vttPath: string, response: string) { return sprites; } +// useSpriteInfo is a hook that fetches a VTT file and parses it for sprite information. +// If the vttPath is undefined, the hook will return undefined. +// If the response is not ok, the hook will return null. This usually indicates missing sprite. export function useSpriteInfo(vttPath: string | undefined) { const [spriteInfo, setSpriteInfo] = useState< - ISceneSpriteInfo[] | undefined + ISceneSpriteInfo[] | undefined | null >(); useEffect(() => { @@ -48,7 +51,7 @@ export function useSpriteInfo(vttPath: string | undefined) { fetch(vttPath).then((response) => { if (!response.ok) { - setSpriteInfo(undefined); + setSpriteInfo(null); return; } From 7152be6086dd380902ece65d3c2ea0ccd3d35839 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 18 Sep 2024 14:38:56 +1000 Subject: [PATCH 33/91] Weblate translation update (#5271) * Translated using Weblate (French) Currently translated at 100.0% (1157 of 1157 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1157 of 1157 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/ * Translated using Weblate (Swedish) Currently translated at 100.0% (1157 of 1157 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/sv/ * Translated using Weblate (German) Currently translated at 87.8% (1017 of 1157 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/de/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1157 of 1157 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/ * Translated using Weblate (Chinese (Traditional)) Currently translated at 89.3% (1034 of 1157 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hant/ * Translated using Weblate (Chinese (Traditional)) Currently translated at 89.3% (1034 of 1157 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hant/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1157 of 1157 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/ * Translated using Weblate (Chinese (Traditional)) Currently translated at 95.5% (1106 of 1157 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hant/ * Translated using Weblate (Chinese (Traditional)) Currently translated at 100.0% (1157 of 1157 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hant/ * Translated using Weblate (French) Currently translated at 100.0% (1157 of 1157 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1161 of 1161 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/ * Translated using Weblate (Chinese (Traditional)) Currently translated at 100.0% (1161 of 1161 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hant/ * Translated using Weblate (French) Currently translated at 100.0% (1172 of 1172 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/ * Translated using Weblate (German) Currently translated at 87.2% (1023 of 1172 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/de/ * Translated using Weblate (Swedish) Currently translated at 100.0% (1172 of 1172 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/sv/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1172 of 1172 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/ * Translated using Weblate (Chinese (Traditional)) Currently translated at 100.0% (1172 of 1172 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hant/ * Translated using Weblate (French) Currently translated at 100.0% (1174 of 1174 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1174 of 1174 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/ * Translated using Weblate (Chinese (Traditional)) Currently translated at 100.0% (1174 of 1174 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hant/ * Translated using Weblate (German) Currently translated at 87.2% (1024 of 1174 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/de/ * Translated using Weblate (Spanish) Currently translated at 98.5% (1157 of 1174 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/es/ * Translated using Weblate (Japanese) Currently translated at 84.3% (990 of 1174 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ja/ * Translated using Weblate (Swedish) Currently translated at 100.0% (1174 of 1174 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/sv/ * Translated using Weblate (Ukrainian) Currently translated at 16.6% (195 of 1174 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/uk/ --------- Co-authored-by: Larsluph Co-authored-by: wql219 Co-authored-by: AlpacaSerious Co-authored-by: mrtuxa Co-authored-by: Lytel Co-authored-by: doodoo Co-authored-by: Lucqqq Co-authored-by: wql219 Co-authored-by: lurch Co-authored-by: miamoreau Co-authored-by: ikayaki Co-authored-by: jc_back --- ui/v2.5/src/locales/de-DE.json | 12 +- ui/v2.5/src/locales/es-ES.json | 4 +- ui/v2.5/src/locales/fr-FR.json | 27 ++- ui/v2.5/src/locales/ja-JP.json | 10 +- ui/v2.5/src/locales/sv-SE.json | 25 ++- ui/v2.5/src/locales/uk-UA.json | 11 +- ui/v2.5/src/locales/zh-CN.json | 37 +++- ui/v2.5/src/locales/zh-TW.json | 302 +++++++++++++++++++++++++++++---- 8 files changed, 371 insertions(+), 57 deletions(-) diff --git a/ui/v2.5/src/locales/de-DE.json b/ui/v2.5/src/locales/de-DE.json index 7e80f5b5c48..4170de48642 100644 --- a/ui/v2.5/src/locales/de-DE.json +++ b/ui/v2.5/src/locales/de-DE.json @@ -135,7 +135,13 @@ "add_o": "O hinzufügen", "add_play": "abspielen hinzufügen", "clear_date_data": "Datum-Eintrag löschen", - "assign_stashid_to_parent_studio": "Stash ID existierendem Elternstudio hinzufügen und Metadaten aktualisieren" + "assign_stashid_to_parent_studio": "Stash ID existierendem Elternstudio hinzufügen und Metadaten aktualisieren", + "set_cover": "Als Titelbild setzen", + "reset_cover": "Titelbild zurücksetzen", + "remove_from_containing_group": "Von Gruppe entfernen", + "reset_play_duration": "Spieldauer zurücksetzten", + "reset_resume_time": "Forschritt zurücksetzten", + "add_sub_groups": "Untergruppen hinzufügen" }, "actions_name": "Aktionen", "age": "Alter", @@ -501,7 +507,9 @@ "skip_multiple_matches_tooltip": "Wenn dies nicht aktiviert ist und mehr als ein Ergebnis zurückgegeben wird, wird ein passendes zufällig ausgewählt", "skip_single_name_performers": "Darsteller mit nur einem - nicht eindeutigem - Namen überspringen", "skip_single_name_performers_tooltip": "Wenn dies nicht aktiviert ist, werden Darsteller, die oft generisch sind, wie Samantha oder Olga, abgeglichen", - "tag_skipped_matches": "Übersprungenes Tag passt zu" + "tag_skipped_matches": "Übersprungenes Tag passt zu", + "tag_skipped_matches_tooltip": "Erstellen Sie ein Tag wie 'Identifizieren: Mehrere Übereinstimmungen“, nach denen Sie in der Scene Tagger-Ansicht filtern und die richtige Übereinstimmung von Hand auswählen können", + "tag_skipped_performers": "Setze folgenden Tag bei übersprungenen Darstellern" }, "import_from_exported_json": "Import aus exportiertem JSON im Metadatenverzeichnis. Löscht die vorhandene Datenbank.", "incremental_import": "Inkrementeller Import aus einer Export-ZIP-Datei.", diff --git a/ui/v2.5/src/locales/es-ES.json b/ui/v2.5/src/locales/es-ES.json index e30ac04f14d..3221602c0be 100644 --- a/ui/v2.5/src/locales/es-ES.json +++ b/ui/v2.5/src/locales/es-ES.json @@ -135,7 +135,9 @@ "create_parent_studio": "Crear estudio matriz", "enable": "Activar", "remove_date": "Eliminar fecha", - "view_history": "Ver historial" + "view_history": "Ver historial", + "add_sub_groups": "Añadir Subgrupos", + "remove_from_containing_group": "Eliminar del grupo" }, "actions_name": "Acciones", "age": "Edad", diff --git a/ui/v2.5/src/locales/fr-FR.json b/ui/v2.5/src/locales/fr-FR.json index c9f0eb2cd2d..5d80067522a 100644 --- a/ui/v2.5/src/locales/fr-FR.json +++ b/ui/v2.5/src/locales/fr-FR.json @@ -135,7 +135,13 @@ "reload": "Actualiser", "remove_date": "Supprimer la date", "clean_generated": "Nettoyer les fichiers générés", - "view_history": "Voir l'historique" + "view_history": "Voir l'historique", + "reset_cover": "Rétablir la vignette par défaut", + "reset_play_duration": "Réinitialiser la durée de lecture", + "reset_resume_time": "Réinitialiser le temps de reprise", + "set_cover": "Définir comme vignette", + "remove_from_containing_group": "Supprimer du groupe", + "add_sub_groups": "Ajouter des groupes affiliés" }, "actions_name": "Actions", "age": "Âge", @@ -543,7 +549,9 @@ "sprites": "Sprites de scène", "transcodes": "Transcodes de scènes" }, - "generate_sprites_during_scan_tooltip": "L'ensemble des images affichées en dessous du lecteur vidéo pour une navigation aisée." + "generate_sprites_during_scan_tooltip": "L'ensemble des images affichées en dessous du lecteur vidéo pour une navigation aisée.", + "rescan": "Réanalyse des fichiers", + "rescan_tooltip": "Réanalyse chaque fichier contenu dans le chemin d'accès. Utilisé pour forcer la mise à jour des métadonnées des fichiers et réanalyser les archives zip." }, "tools": { "scene_duplicate_checker": "Vérificateur de doublons de scènes", @@ -1391,7 +1399,7 @@ "saved_entity": "{entity} sauvegardé·e", "started_auto_tagging": "Début de l'étiquetage automatique", "started_generating": "Début de la génération", - "started_importing": "Début de l'importation", + "started_importing": "Début d'importation", "updated_entity": "{entity} mis·e à jour" }, "total": "Total", @@ -1471,5 +1479,16 @@ "groups": "Groupes", "group": "Groupe", "studio_count": "Nombre de studios", - "studio_tags": "Étiquettes du studio" + "studio_tags": "Étiquettes du studio", + "include_sub_studio_content": "Inclure le contenu des studios affiliés", + "include_sub_tag_content": "Inclure le contenu des étiquettes affiliées", + "containing_group": "Groupe contenant", + "containing_group_count": "Nombre de groupes contenant", + "containing_groups": "Groupes contenant", + "include_sub_group_content": "Inclure le contenu des groupes affiliés", + "sub_group_order": "Ordre de groupe affilié", + "sub_groups": "Groupes affiliés", + "sub_group_of": "Groupe affilié de {parent}", + "sub_group": "Groupe affilié", + "sub_group_count": "Nombre de groupes affiliés" } diff --git a/ui/v2.5/src/locales/ja-JP.json b/ui/v2.5/src/locales/ja-JP.json index 73593a3ba5d..4dbba51c465 100644 --- a/ui/v2.5/src/locales/ja-JP.json +++ b/ui/v2.5/src/locales/ja-JP.json @@ -134,7 +134,12 @@ "enable": "有効", "remove_date": "日付を削除", "create_parent_studio": "親スタジオを作成", - "optimise_database": "データベースを最適化" + "optimise_database": "データベースを最適化", + "reset_play_duration": "プレイ時間をリセット", + "add_sub_groups": "サブグループを追加", + "remove_from_containing_group": "グループから削除", + "set_cover": "カバーをセット", + "view_history": "履歴を表示する" }, "actions_name": "操作", "age": "年齢", @@ -246,7 +251,8 @@ "successfully_cancelled_temporary_behaviour": "一時的な挙動のキャンセルに成功しました", "until_restart": "再起動まで", "video_sort_order": "デフォルトの動画ソート順序", - "video_sort_order_desc": "動画をデフォルト順でソートします。" + "video_sort_order_desc": "動画をデフォルト順でソートします。", + "server_port": "サーバーポート番号" }, "general": { "auth": { diff --git a/ui/v2.5/src/locales/sv-SE.json b/ui/v2.5/src/locales/sv-SE.json index 1a280843146..a077f07aaf5 100644 --- a/ui/v2.5/src/locales/sv-SE.json +++ b/ui/v2.5/src/locales/sv-SE.json @@ -135,7 +135,13 @@ "clear_date_data": "Rensa datumdata", "clean_generated": "Rensa genererade filer", "remove_date": "Ta bort datum", - "view_history": "Visningshistorik" + "view_history": "Visningshistorik", + "reset_cover": "Återställ Standardomslag", + "reset_play_duration": "Återställ uppspelad tid", + "add_sub_groups": "Lägg Till Undergrupper", + "remove_from_containing_group": "Ta bort från Grupp", + "reset_resume_time": "Återställ återupptagningstid", + "set_cover": "Välj som Omslag" }, "actions_name": "Handlingar", "age": "Ålder", @@ -543,7 +549,9 @@ "previews_desc": "Scenförhandsvisningar och omslagsbilder", "sprites": "Scensprites", "transcodes": "Scenomkodningar" - } + }, + "rescan": "Skanna om filer", + "rescan_tooltip": "Skanna om alla filer i sökvägen. Används för att tvångsuppdatera filmetadata och att skanna om zip-filer." }, "tools": { "scene_duplicate_checker": "Scen Duplikat Kontroll", @@ -1471,5 +1479,16 @@ "group_scene_number": "Scennummer", "groups": "Grupper", "studio_count": "Antal Studior", - "studio_tags": "Studiotaggar" + "studio_tags": "Studiotaggar", + "include_sub_studio_content": "Inkludera innehåll från underordnade studior", + "include_sub_tag_content": "Inkludera innehåll från underordnade taggar", + "containing_group": "Innehållande grupp", + "containing_group_count": "Antal Innehållande Grupper", + "containing_groups": "Innehållande Grupper", + "include_sub_group_content": "Inkludera innehåll från undergrupper", + "sub_group_count": "Antal Undergrupper", + "sub_group_of": "Undergrupp av {parent}", + "sub_group_order": "Ordning av Undergrupper", + "sub_groups": "Undergrupper", + "sub_group": "Undergrupp" } diff --git a/ui/v2.5/src/locales/uk-UA.json b/ui/v2.5/src/locales/uk-UA.json index da0582bcecc..cc3487d4cd0 100644 --- a/ui/v2.5/src/locales/uk-UA.json +++ b/ui/v2.5/src/locales/uk-UA.json @@ -1,8 +1,8 @@ { "actions": { - "add": "Добавити", - "add_directory": "Добавити Папку", - "add_entity": "Добавити {entityType}", + "add": "Додати", + "add_directory": "Додати Папку", + "add_entity": "Додати {entityType}", "add_to_entity": "Добавити до {entityType}", "allow": "Дозволити", "allow_temporarily": "Дозволити Тимчасово", @@ -214,5 +214,8 @@ "parallel_scan_head": "Паралельне сканування/генерація", "preview_generation": "Генерація прев'ю" } - } + }, + "video_codec": "Відео кодек", + "videos": "Відео", + "weight": "Вага" } diff --git a/ui/v2.5/src/locales/zh-CN.json b/ui/v2.5/src/locales/zh-CN.json index fb221a3c6c8..ce3aa6f178d 100644 --- a/ui/v2.5/src/locales/zh-CN.json +++ b/ui/v2.5/src/locales/zh-CN.json @@ -135,7 +135,13 @@ "remove_date": "去除日期", "add_o": "添加高潮记录", "clean_generated": "清除已生成的文件", - "view_history": "观看历史" + "view_history": "观看历史", + "reset_cover": "恢复默认封面", + "set_cover": "设置为封面", + "reset_play_duration": "重置播放时长", + "reset_resume_time": "重置恢复时间", + "add_sub_groups": "添加子群组", + "remove_from_containing_group": "从群组中移除" }, "actions_name": "操作", "age": "年龄", @@ -500,8 +506,8 @@ "skip_multiple_matches": "跳过有多个结果的匹配", "skip_single_name_performers": "跳过无消歧义字段且名字为单个词的表演者", "skip_multiple_matches_tooltip": "如果未启用此选项,且返回的结果不止一个,则将随机选择一个结果与之匹配", - "tag_skipped_performers": "跳过以下匹配的演员", - "tag_skipped_matches": "跳过以下匹配的标签", + "tag_skipped_performers": "标记跳过的演员", + "tag_skipped_matches": "标记跳过的匹配项", "skip_single_name_performers_tooltip": "如果未启用此选项,则将匹配 Samantha 或 Olga 等经常被通用的表演者", "tag_skipped_performer_tooltip": "创建类似“识别:单名表演者”的标签,您可以在短片标签视图中进行筛选,并选择如何处理这些表演者", "tag_skipped_matches_tooltip": "创建类似“识别:多个匹配项”的标签,您可以在短片标签视图中进行筛选,并手动选择正确的匹配项" @@ -542,7 +548,9 @@ "previews_desc": "短片预览和缩略图", "sprites": "短片精灵", "transcodes": "短片转码" - } + }, + "rescan": "重新扫描文件", + "rescan_tooltip": "重新扫描路径中的每个文件。用于强制更新文件元数据和重新扫描zip文件。" }, "tools": { "scene_duplicate_checker": "短片重复性检查工具", @@ -825,7 +833,7 @@ "not_matches_regex": "不符合正则表达式", "not_null": "不为空", "format_string_depth": "{criterion}{modifierString}{valueString}(+{depth, plural, =-1 {all} other {{depth}}})", - "format_string_excludes": "{criterion} {modifierString} {valueString} (包括 {excludedString})", + "format_string_excludes": "{criterion} {modifierString} {valueString} (不包括 {excludedString})", "format_string_excludes_depth": "{criterion} {modifierString} {valueString} (包括 {excludedString}) (+{depth, plural, =-1 {全部} other {{depth}}})" }, "custom": "自定义", @@ -934,7 +942,7 @@ "transcodes_tooltip": "将为所有内容提前生成MP4转码文件;对慢速CPU有用,但需要更多磁盘空间", "video_previews": "预览", "video_previews_tooltip": "鼠标悬停在短片上时播放的预览短片", - "phash_tooltip": "针对去重和场景鉴定", + "phash_tooltip": "针对去重和短片识别", "image_thumbnails": "图像缩略图", "clip_previews": "图像片段预览" }, @@ -1248,10 +1256,10 @@ "set_up_your_paths": "设立你的路径", "stash_alert": "没有选择任何影像库的路径。Stash将不会扫描到任何媒体文件。你确认吗?", "where_can_stash_store_blobs": "Stash 在哪里可以存储数据库二进制数据?", - "where_can_stash_store_blobs_description": "Stash 可以在数据库或文件系统中存储二进制数据,如短片封面、表演者、工作室和标签图像。默认情况下,它会将这些数据存储在子目录 blobs 中的文件系统中。如果要更改此路径,请输入绝对或相对(相对于当前工作目录)路径。如果该目录不存在,Stash将创建该目录。", + "where_can_stash_store_blobs_description": "Stash可以将场景封面、表演者、工作室和标记图像等二进制数据存储在数据库或文件系统中。默认情况下,它会将这些数据存储在包含您的配置文件的目录内的子目录blob中的文件系统中。如果您想更改此设置,请输入绝对或相对(到当前工作目录)路径。如果Stash不存在,它将创建此目录。", "where_can_stash_store_blobs_description_addendum": "或者,如果要将此数据存储在数据库中,可以将此字段留空注意:这将增加数据库文件的大小,并增加数据库迁移时间。", "where_can_stash_store_cache_files": "Stash可以在哪里存储缓存文件?", - "where_can_stash_store_cache_files_description": "为了使 HLS/DASH 实时转码等功能发挥作用,Stash 需要一个临时文件的缓存目录。默认情况下,Stash将在包含配置文件的目录中创建一个缓存目录。如果要更改此路径,请输入绝对或相对(相对于当前工作目录)路径。如果该目录不存在,Stash 将创建该目录。", + "where_can_stash_store_cache_files_description": "为了使HLS/DASH实时转码等功能正常运行,Stash需要一个临时文件的缓存目录。默认情况下,Stash将在包含您的配置文件的目录中创建一个cache目录。如果您想更改此设置,请输入绝对或相对(与当前工作目录)路径。如果Stash不存在,它将创建此目录。", "where_can_stash_store_its_database": "在哪里可以储存Stash的数据库?", "where_can_stash_store_its_database_description": "Stash 使用 sqlite 数据库来存放你的收藏的元数据。默认情况下,会建立stash-go.sqlite在包含有你配置文件的目录里。如果你想改动,请输入一个绝对,或者相对(对于当前目录)的文件名。", "where_can_stash_store_its_database_warning": "警告:不支持将数据库存储在运行 Stash 的不同系统上(例如,在另一台计算机上运行 Stash 服务器时将数据库存储到 NAS 上)!SQLite 不适合在网络上使用,尝试这样做很容易导致整个数据库损坏。", @@ -1471,5 +1479,16 @@ "group": "群组", "group_count": "群组总计", "group_scene_number": "短片序号", - "groups": "群组" + "groups": "群组", + "include_sub_studio_content": "包括子工作室内容", + "include_sub_tag_content": "包括子标签内容", + "include_sub_group_content": "包括子群组内容", + "containing_group": "包含群组", + "containing_group_count": "包含群组计数", + "containing_groups": "包含群组", + "sub_group": "子群组", + "sub_group_count": "子群组计数", + "sub_group_of": "{parent}的子群组", + "sub_group_order": "子群组排序", + "sub_groups": "子群组" } diff --git a/ui/v2.5/src/locales/zh-TW.json b/ui/v2.5/src/locales/zh-TW.json index 67318061daa..e9041f571bc 100644 --- a/ui/v2.5/src/locales/zh-TW.json +++ b/ui/v2.5/src/locales/zh-TW.json @@ -134,7 +134,14 @@ "create_parent_studio": "新增母工作室", "optimise_database": "最佳化資料庫", "choose_date": "選擇日期", - "clean_generated": "清除已生成的文件" + "clean_generated": "清除已生成的文件", + "view_history": "查看歷史", + "reset_cover": "恢復預設封面", + "reset_play_duration": "重置播放時長", + "reset_resume_time": "重置恢復時間", + "set_cover": "設為封面", + "remove_from_containing_group": "從群組中刪除", + "add_sub_groups": "新增子組" }, "actions_name": "動作", "age": "年齡", @@ -251,7 +258,9 @@ "successfully_cancelled_temporary_behaviour": "已關閉暫時啟用伺服器的功能", "until_restart": "直到重啟", "video_sort_order": "預設的影片排序", - "video_sort_order_desc": "依照預設方式排序影片。" + "video_sort_order_desc": "依照預設方式排序影片。", + "server_port": "伺服器埠", + "server_port_desc": "運行 DLNA 伺服器的埠。更改後需要重新啟動 DLNA。" }, "general": { "auth": { @@ -331,6 +340,18 @@ "heading": "FFMpeg 即時串流輸出選項", "desc": "進階:產生影片時在 \"output\" 欄位之前傳遞給 ffmpeg 的附加參數。" } + }, + "download_ffmpeg": { + "description": "將 FFmpeg 下載到配置目錄中,並從配置目錄中清除要解析的 ffmpeg 和 ffprobe 路徑。", + "heading": "下載 FFmpeg" + }, + "ffmpeg_path": { + "description": "ffmpeg 可執行文件的路徑(而不僅僅是資料夾)。如果為空,則ffmpeg將通過 $PATH、配置目錄或 $HOME/.stash 從環境中解析", + "heading": "FFmpeg 可執行文件路徑" + }, + "ffprobe_path": { + "description": "ffprobe 可執行檔(而不僅僅是資料夾)的路徑。如果為空,則ffprobe將通過 $PATH、配置目錄或 $HOME/.stash 從環境中解析", + "heading": "FFprobe 可執行文件路徑" } }, "gallery_ext_desc": "以逗號分隔的副檔名名稱,這類檔案將視為圖庫。", @@ -391,7 +412,7 @@ "log_level": "日誌檔級別" }, "plugins": { - "hooks": "Hooks", + "hooks": "鉤子", "triggers_on": "觸發於", "available_plugins": "可使用的插件", "installed_plugins": "已安裝的插件" @@ -477,7 +498,11 @@ "skip_multiple_matches": "跳過具有多個結果的匹配結果", "skip_single_name_performers": "跳過單名且沒有歧義的演員", "skip_multiple_matches_tooltip": "如果未啟用此功能且搜尋回傳多個結果,則會隨機選擇一個其中一個進行匹配", - "skip_single_name_performers_tooltip": "如果未啟用此功能,則會自動匹配 Samantha 或者 Olga 這種常見名稱的演員" + "skip_single_name_performers_tooltip": "如果未啟用此功能,則會自動匹配 Samantha 或者 Olga 這種常見名稱的演員", + "tag_skipped_matches": "標記跳過的匹配項", + "tag_skipped_matches_tooltip": "創建一個標籤,例如「識別:多個匹配項」,您可以在短片標籤器視圖中過濾該標籤,並手動選擇正確的匹配項", + "tag_skipped_performer_tooltip": "創建一個標籤,例如“識別:單名表演者”,您可以在短片標籤器視圖中篩選該標籤,並選擇您希望如何處理這些表演者", + "tag_skipped_performers": "標記跳過的表演者" }, "import_from_exported_json": "匯入先前從 Metadata 資料夾中匯出的 JSON 檔。此動作將清除現有資料庫中的內容。", "incremental_import": "從匯出 ZIP 檔進行增量匯入。", @@ -497,7 +522,9 @@ "generate_sprites_during_scan_tooltip": "便於瀏覽顯示在影片播放器下方的一組預覽圖。", "generate_video_covers_during_scan": "產生短片封面", "migrate_scene_screenshots": { - "delete_files": "刪除截圖檔案" + "delete_files": "刪除截圖檔案", + "description": "將場景螢幕截圖遷移到新的 blob 儲存系統中。應在將現有系統遷移到 0.20 後運行此遷移。可以選擇在遷移後刪除舊的屏幕截圖。", + "overwrite_existing": "使用螢幕截圖資料覆蓋現有 blob" }, "clean_generated": { "blob_files": "Blob 檔案", @@ -511,8 +538,13 @@ "transcodes": "短片轉碼" }, "migrate_blobs": { - "description": "將 Blob 遷移到當前的 Blob 儲存系統。 此遷移應在更改 Blob 儲存系統後執行。 遷移後可以選擇性的刪除舊資料。" - } + "description": "將 Blob 遷移到當前的 Blob 儲存系統。 此遷移應在更改 Blob 儲存系統後執行。 遷移後可以選擇性的刪除舊資料。", + "delete_old": "刪除舊資料" + }, + "optimise_database": "嘗試通過分析然後重建整個資料庫檔來提高性能。", + "optimise_database_warning": "警告:當此任務運行時,任何修改資料庫的操作都將失敗,並且根據資料庫大小,可能需要幾分鐘才能完成。它還至少需要與資料庫大小一樣多的可用磁碟空間,但建議使用1.5倍。", + "rescan": "重新掃描檔案", + "rescan_tooltip": "重新掃描路徑中的每個檔案。 用於強制更新檔案中繼資料和重新掃描zip檔案。" }, "tools": { "scene_duplicate_checker": "短片相近性檢查工具", @@ -592,6 +624,9 @@ "stars": "星級" } } + }, + "max_options_shown": { + "label": "在選擇下拉式選單中顯示的最大項目數量" } }, "funscript_offset": { @@ -621,6 +656,10 @@ "write_image_thumbnails": { "description": "建立縮圖時,將檔案寫至磁碟中", "heading": "建立圖片縮圖" + }, + "create_image_clips_from_videos": { + "description": "當資料庫停用影片時,視訊檔(以視訊副檔名結尾的檔案)會被掃描為圖像剪輯。", + "heading": "將視訊擴充檔掃描為圖片剪輯" } } }, @@ -679,9 +718,12 @@ "show_scrubber": "顯示預覽軸", "track_activity": "追蹤使用活動", "vr_tag": { - "heading": "VR 標籤" + "heading": "VR 標籤", + "description": "VR 按鈕只會顯示在有此標籤的場景中。" }, - "enable_chromecast": "啟用 Chromecast" + "enable_chromecast": "啟用 Chromecast", + "show_ab_loop_controls": "顯示AB循環插件控件", + "disable_mobile_media_auto_rotate": "停用行動裝置上全螢幕媒體的自動旋轉功能" } }, "scene_wall": { @@ -724,14 +766,27 @@ "title": "使用者介面", "image_wall": { "heading": "圖片預覽牆", - "direction": "方向" + "direction": "方向", + "margin": "邊距(像素)" }, "detail": { "heading": "細節頁面", "show_all_details": { "heading": "顯示所有細節", "description": "啟用後,預設將顯示所有內容的細節內容,並且每個詳細資訊項目將以單列呈現" + }, + "compact_expanded_details": { + "heading": "緊湊型擴充詳細資料", + "description": "啟用時,此選項會呈現擴充的詳細資料,同時保持簡潔的呈現方式" + }, + "enable_background_image": { + "description": "在詳細頁面上顯示背景圖片。", + "heading": "啟用背景影像" } + }, + "use_stash_hosted_funscript": { + "description": "啟用後,funscript 將直接從 Stash 傳送至您的 Handy 裝置,而無需使用第三方 Handy 伺服器。要求可從您的 Handy 裝置存取 Stash,且如果 stash 已設定憑證,則會產生 API 金鑰。", + "heading": "直接為 funscript 服務" } }, "advanced_mode": "進階模式" @@ -745,7 +800,8 @@ "performers": "演員", "scenes": "短片", "studios": "工作室", - "tags": "標籤" + "tags": "標籤", + "groups": "{count, plural, one {群組} other {群組}}" }, "country": "國家", "cover_image": "封面圖片", @@ -769,7 +825,10 @@ "not_between": "不與 ... 之間", "not_equals": "不是", "not_matches_regex": "不符合正規表示式", - "not_null": "不為空" + "not_null": "不為空", + "format_string_excludes": "{criterion} {modifierString} {valueString} (排除{excludedString})", + "format_string_depth": "{criterion}{modifierString}{valueString}(+{depth, plural, =-1 {所有} other {{深度}}})", + "format_string_excludes_depth": "{criterion} {modifierString} {valueString} (排除 {excludedString}) (+{depth, plural, =-1 {所有} other {{深度}}})" }, "custom": "自訂", "date": "日期", @@ -815,7 +874,8 @@ "label": "滑動模式", "pan_y": "Y 軸滑動", "zoom": "放大" - } + }, + "page_header": "第 {page} 頁,共計{total}頁" }, "merge": { "destination": "目標", @@ -860,11 +920,15 @@ "preview_seg_duration_desc": "每個預覽片段的長度,以秒為單位。", "preview_seg_duration_head": "預覽片段長度", "sprites": "時間軸預覽", - "sprites_tooltip": "時間軸預覽(用於短片中時間軸的預覽圖)", + "sprites_tooltip": "時間軸預覽(用於短片中時間軸的預覽圖)。", "transcodes": "轉檔", "transcodes_tooltip": "將不支援的影片格式轉換成 MP4", "video_previews": "影片預覽", - "video_previews_tooltip": "此預覽將於滑鼠移至影片上時自動播放" + "video_previews_tooltip": "此預覽將於滑鼠移至影片上時自動播放", + "clip_previews": "影象剪輯預覽", + "covers": "短片封面", + "image_thumbnails": "圖片縮圖", + "phash_tooltip": "可用於辨認短片或偵測重複的短片" }, "scenes_found": "已找到 {count} 個短片", "scrape_entity_query": "{entity_type}爬蟲搜尋", @@ -875,11 +939,15 @@ "unsaved_changes": "尚有未儲存的更改。你確定要離開嗎?", "imagewall": { "direction": { - "row": "行" - } + "row": "行", + "column": "縱列", + "description": "以列或以行為基礎的佈局。" + }, + "margin_desc": "每個完整影像周圍的邊界像素數目。" }, "clear_o_history_confirm": "您確定要清除尻尻紀錄嗎?", - "clear_play_history_confirm": "您確定要清除播放紀錄嗎?" + "clear_play_history_confirm": "您確定要清除播放紀錄嗎?", + "performers_found": "找到{count} 個演員" }, "dimensions": "解析度", "director": "導演", @@ -902,7 +970,19 @@ "medium": "中" }, "search_accuracy_label": "搜尋準確度", - "title": "相近的短片" + "title": "相近的短片", + "duration_diff": "最長時間差", + "duration_options": { + "any": "任何", + "equal": "相等" + }, + "only_select_matching_codecs": "僅在重複群組中的所有編解碼器都符合時才選擇", + "select_all_but_largest_file": "選取每個重複群組中的每個檔案,最大的檔案除外", + "select_none": "清除選擇", + "select_oldest": "選取重複群組中最舊的檔案", + "select_options": "選擇選項…", + "select_youngest": "選擇重複組中最新的檔案", + "select_all_but_largest_resolution": "選取每個重复的群組中的每個檔案,解析度最高的檔案除外" }, "duplicated_phash": "重複的檔案 (PHash)", "duration": "長度", @@ -1012,7 +1092,8 @@ "play_count": "播放次數", "play_duration": "播放長度", "stream": "串流連結", - "video_codec": "影片編碼" + "video_codec": "影片編碼", + "o_count": "O 計數" }, "megabits_per_second": "{value} megabits/秒", "metadata": "Metadata", @@ -1026,7 +1107,8 @@ "first": "最前一頁", "last": "最後一頁", "next": "下一頁", - "previous": "上一頁" + "previous": "上一頁", + "current_total": "第{current}頁, 共 {total}頁" }, "parent_of": "{children} 的母物件", "parent_studios": "母工作室", @@ -1103,7 +1185,8 @@ "search_filter": { "name": "篩選", "saved_filters": "已儲存的過濾條件", - "update_filter": "更新篩選" + "update_filter": "更新篩選", + "edit_filter": "編輯篩選器" }, "seconds": "秒", "settings": "設定", @@ -1114,7 +1197,10 @@ "database_file_path": "資料庫檔案路徑", "generated_directory": "生成媒體路徑", "nearly_there": "快好了!", - "stash_library_directories": "Stash 多媒體檔案路徑" + "stash_library_directories": "Stash 多媒體檔案路徑", + "cache_directory": "快取目錄", + "blobs_directory": "二進位資料目錄", + "blobs_use_database": "<使用資料庫中>" }, "creating": { "creating_your_system": "建立系統中" @@ -1153,7 +1239,16 @@ "where_can_stash_store_its_generated_content": "Stash 可以在哪裡儲存其生成內容?", "where_can_stash_store_its_generated_content_description": "為提供縮圖、預覽和其他預覽資料,Stash 將自動生成圖片和影片資訊。這包括不支援的檔案格式之轉檔。預設情況下,Stash 將在包含您設定檔案的資料夾中建立一個新的 generated 資料夾。如果要更改此生成媒體的儲存位置,請在此輸入絕對或相對路徑(相對於目前工作目錄)。如果該資料夾不存在,Stash 將自動建立此目錄。", "where_is_your_porn_located": "你的片片都藏哪?", - "where_is_your_porn_located_description": "在此選擇你A片及圖片的資料夾,Stash 將在掃描影片及圖片時使用這些路徑。" + "where_is_your_porn_located_description": "在此選擇你A片及圖片的資料夾,Stash 將在掃描影片及圖片時使用這些路徑。", + "path_to_blobs_directory_empty_for_default": "blobs 目錄的路徑 (預設為空)", + "path_to_cache_directory_empty_for_default": "快取目錄的路徑 (預設為空)", + "store_blobs_in_database": "將 blobs 儲存到資料庫", + "where_can_stash_store_blobs": "Stash 可以在哪裡儲存資料庫的二進位資料?", + "where_can_stash_store_blobs_description": "Stash可以在資料庫或檔案系統中儲存二進位制資料,如場景封面、表演者、工作室和標籤影象。預設情況下,它會將這些資料儲存在檔案系統中包含配置檔案的目錄中的子目錄 blobs 中。如果要更改此項,請輸入絕對或相對(到當前工作目錄)路徑。如果Stash不存在此目錄,它將建立此目錄。", + "where_can_stash_store_cache_files": "Stash可以在哪裡儲存快取檔案?", + "where_can_stash_store_blobs_description_addendum": "或者,您可以將此資料儲存在資料庫中。注意:這將增加資料庫檔案的大小,並增加資料庫遷移時間。", + "where_can_stash_store_cache_files_description": "為了使HLS/DASH實時轉碼等功能正常執行,Stash需要一個臨時檔案的快取目錄。預設情況下,Stash將在包含您的配置檔案的目錄中建立一個cache目錄。如果您想更改此設定,請輸入絕對或相對(與當前工作目錄)路徑。如果Stash不存在,它將建立此目錄。", + "where_can_stash_store_its_database_warning": "警告:將資料庫儲存在與執行Stash的系統不同的系統上(例如,將資料庫儲存在NAS上,同時在另一台計算機上執行Stash伺服器)是不受支援的!SQLite不適用於跨網路使用,嘗試這樣做很容易導致整個資料庫損壞。" }, "stash_setup_wizard": "Stash 安裝精靈", "success": { @@ -1166,16 +1261,20 @@ "support_us": "支持我們", "thanks_for_trying_stash": "感謝您使用 Stash!", "welcome_contrib": "我們也歡迎以程式碼(錯誤修復、改進和新功能實作)、測試、錯誤報告、改進和功能請求以及使用者支援的形式做出貢獻。詳情請見程式內說明的 Contribution(貢獻)頁面。", - "your_system_has_been_created": "成功!您的系統已安裝完成!" + "your_system_has_been_created": "成功!您的系統已安裝完成!", + "missing_ffmpeg": "您缺少所需的ffmpeg二進位制檔案。您可以通過選中下面的框自動將其下載到您的配置目錄中。或者,您可以在系統設定中提供ffmpegffprobe二進位制檔案的路徑。Stash必須存在這些二進位制檔案才能執行。", + "download_ffmpeg": "下載 ffmpeg" }, "welcome": { "config_path_logic_explained": "Stash 於執行時,會先在執行目錄中找尋其設定檔案 (config.yml),當找不到時,它將會再試著使用 $HOME/.stash/config.yml(於 Windows 中,此路徑為 %USERPROFILE%\\.stash\\config.yml)。您也可在執行 Stash 時透過 -c '<設定檔路徑>' 提供設定路徑,或者 --config ''。", - "in_current_stash_directory": "於 $HOME/.stash 資料夾中", - "in_the_current_working_directory": "於目前的工作路徑", + "in_current_stash_directory": "於 {path} 資料夾中:", + "in_the_current_working_directory": "於目前的工作路徑 {path}:", "next_step": "如果您已準備好繼續安裝本程式,請選擇您想要儲存設定檔案的位置,然後點擊「下一步」。", "store_stash_config": "您希望在哪裡儲存您的 Stash 設定呢?", "unable_to_locate_config": "如果看到此畫面的話,則代表 Stash 無法找到先前的設定檔案。本安裝畫面將帶您建立新的設定檔案。", - "unexpected_explained": "如果您覺得此畫面不應該出現的話,請試著在正確的 Stash 資料夾中重新啟動本程式,或者將設定路徑設為 -c。" + "unexpected_explained": "如果您覺得此畫面不應該出現的話,請試著在正確的 Stash 資料夾中重新啟動本程式,或者將設定路徑設為 -c。", + "in_the_current_working_directory_disabled": "在{path}中,工作目錄:", + "in_the_current_working_directory_disabled_macos": "當正在執行 Stash.app時不支援,

在工作目錄中執行 stash-macos來設定" }, "welcome_specific_config": { "config_path": "Stash 將使用下列設定檔案路徑:{path}", @@ -1192,13 +1291,18 @@ "selected_stash_box": "已選擇的 Stash-Box 端點", "submission_failed": "提交失敗", "submission_successful": "提交成功", - "submit_update": "已存在於 {endpoint_name}" + "submit_update": "已存在於 {endpoint_name}", + "source": "Stash-Box源" }, "statistics": "統計資訊", "stats": { "image_size": "圖片大小", "scenes_duration": "短片長度", - "scenes_size": "短片大小" + "scenes_size": "短片大小", + "scenes_played": "已播放的短片", + "total_o_count": "總O計數", + "total_play_count": "總播放數", + "total_play_duration": "總播放時長" }, "status": "狀態:{statusText}", "studio": "工作室", @@ -1230,7 +1334,8 @@ "started_auto_tagging": "自動套用標籤中", "started_generating": "生成檔案中", "started_importing": "匯入中", - "updated_entity": "已更新{entity}" + "updated_entity": "已更新{entity}", + "image_index_too_large": "錯誤:圖片索引大於圖庫中的圖片數量" }, "total": "總計", "true": "是", @@ -1252,5 +1357,138 @@ "appears_with": "共同演出", "audio_codec": "音訊編碼", "circumcised": "已切除包皮", - "date_format": "YYYY-MM-DD" + "date_format": "YYYY-MM-DD", + "connection_monitor": { + "websocket_connection_failed": "無法建立 websocket 連線:詳細資訊請參閱瀏覽器主控台", + "websocket_connection_reestablished": "Websocket 連線已重新建立" + }, + "distance": "距離", + "errors": { + "header": "錯誤", + "image_index_greater_than_zero": "影象索引必須大於0", + "invalid_javascript_string": "無效的 javascript 程式碼: {error}", + "invalid_json_string": "無效的 JSON 字串: {error}", + "something_went_wrong": "出错了。", + "loading_type": "載入{type}時出錯", + "lazy_component_error_help": "如果您最近升級了 Stash,請重新載入頁面或清除瀏覽器cache。" + }, + "group": "群組", + "group_count": "群組計數", + "group_scene_number": "短片編號", + "groups": "群組", + "hasChapters": "擁有章節", + "history": "歷史紀錄", + "image_index": "圖片#", + "index_of_total": "第{index}個,共 {total}個", + "last_o_at": "最後 O 在", + "package_manager": { + "source": { + "name": "名稱", + "url": "源 URL", + "local_path": { + "heading": "本地路徑", + "description": "儲存此源的軟體包的相對路徑。請注意,變更此路徑需要手動移動軟體包。" + } + }, + "uninstall": "解除安裝", + "unknown": "<未知>", + "update": "更新", + "version": "版本", + "add_source": "新增源", + "check_for_updates": "檢查更新", + "confirm_delete_source": "您確定要刪除源{name} ({url})嗎?", + "confirm_uninstall": "您確定要解除安裝{number}軟體包嗎?", + "description": "描述", + "edit_source": "編輯源", + "hide_unselected": "隱藏未選取的", + "install": "安装", + "installed_version": "已安裝版本", + "latest_version": "最新版本", + "no_packages": "未找到軟體包", + "no_sources": "未配置源", + "no_upgradable": "未找到可升級軟體包", + "package": "軟體包", + "required_by": "為 {packages} 所需", + "selected_only": "僅選定", + "show_all": "顯示全部" + }, + "photographer": "攝影師", + "play_history": "播放歷史", + "playdate_recorded_no": "無播放日期記錄", + "second": "秒", + "studio_count": "工作室計數", + "studio_and_parent": "工作室與其上級", + "studio_tagger": { + "add_new_studios": "新增工作室", + "batch_update_studios": "批量更新工作室", + "config": { + "active_stash-box_instance": "目前使用的 Stash-box:", + "create_parent_desc": "建立缺失的母工作室,或使用確切的名稱匹配標記和更新現有母工作室的資料/影象", + "create_parent_label": "建立家長工作室", + "edit_excluded_fields": "編輯排除的欄位", + "excluded_fields": "已排除欄位:", + "no_instances_found": "尚未設定端點", + "these_fields_will_not_be_changed_when_updating_studios": "更新工作室時不會更改這些欄位。", + "no_fields_are_excluded": "尚無排除任何欄位" + }, + "any_names_entered_will_be_queried": "如果輸入的名稱有在設定的 Stash-Box 端點上找到的話,則相對應的結果將會被自動新增。只有完全符合的結果才會被視為匹配。", + "batch_add_studios": "批量添加工作室", + "studio_already_tagged": "已經設定標籤的工作室", + "refreshing_will_update_the_data": "重新整理後將更新所有已在此stash-box例項被標記的工作室的资料。", + "status_tagging_job_queued": "狀態:標籤設定任務排隊中", + "status_tagging_studios": "狀態:給工作室設定標籤中", + "untagged_studios": "未設定標籤的工作室", + "update_studio": "更新工作室", + "update_studios": "更新工作室", + "updating_untagged_studios_description": "更新未標記的工作室將嘗試匹配任何缺乏stashid的工作室並更新metadata。", + "studio_names_separated_by_comma": "以逗號分隔的工作室名稱", + "studio_selection": "工作室選擇", + "studio_successfully_tagged": "工作室已成功設定標籤", + "tag_status": "標籤狀態", + "to_use_the_studio_tagger": "使用工作室標籤器需要配置一個stash-box例項。", + "create_or_tag_parent_studios": "建立缺失的母工作室或標記已存在的母工作室", + "current_page": "目前頁面", + "failed_to_save_studio": "儲存工作室“{studio}”失敗", + "name_already_exists": "名稱已存在", + "network_error": "網路錯誤", + "number_of_studios_will_be_processed": "{studio_count}個工作室將被處理", + "query_all_studios_in_the_database": "此資料庫中的所有工作室", + "refresh_tagged_studios": "重新整理已設定標籤的工作室", + "no_results_found": "沒有找到結果。" + }, + "include_sub_studio_content": "包括子工作室內容", + "include_sub_tag_content": "包含子標籤內容", + "o_count": "O 計數", + "o_history": "o 歷史記錄", + "odate_recorded_no": "無O記錄日期", + "orientation": "方向", + "parent_studio": "母工作室", + "penis": "陰莖", + "penis_length": "陰莖長度", + "penis_length_cm": "陰莖長度(公分)", + "plays": "{value} 次播放", + "primary_tag": "主要標籤", + "studio_tags": "工作室標籤", + "subsidiary_studio_count": "子工作室數量", + "tag_parent_tooltip": "有父母標籤", + "validation": { + "required": "${path}是必填欄位", + "date_invalid_form": "${path}必須是YYYY-MM-DD形式", + "unique": "${path}必須是唯一的", + "blank": "${path}不能為空" + }, + "video_codec": "影片編碼", + "tag_sub_tag_tooltip": "有子標籤", + "time": "時間", + "unknown_date": "未知日期", + "urls": "支援網址", + "containing_group_count": "包含群組計數", + "containing_groups": "包含群組", + "include_sub_group_content": "包括子組內容", + "sub_group_count": "子組計數", + "sub_group_of": "{parent}的子組", + "sub_group_order": "子組順序", + "sub_groups": "子組", + "containing_group": "包含群組", + "sub_group": "子組" } From 476688c84d699936d23a06bd02204d5b7559f360 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 20 Sep 2024 12:56:26 +1000 Subject: [PATCH 34/91] Database connection pool refactor (#5274) * Move optimise out of RunAllMigrations * Separate read and write database connections * Enforce readonly connection constraint * Fix migrations not using tx * #5155 - allow setting cache size from environment * Document new environment variable --- pkg/sqlite/anonymise.go | 6 +- pkg/sqlite/database.go | 124 ++++++++++++------ pkg/sqlite/migrate.go | 56 +++------ pkg/sqlite/migrations/45_postmigrate.go | 2 +- pkg/sqlite/migrations/48_premigrate.go | 8 +- pkg/sqlite/migrations/60_postmigrate.go | 4 +- pkg/sqlite/transaction.go | 24 ++-- pkg/sqlite/transaction_test.go | 131 ++++++++++---------- pkg/txn/transaction.go | 26 ++-- ui/v2.5/src/docs/en/Manual/Configuration.md | 6 + 10 files changed, 208 insertions(+), 179 deletions(-) diff --git a/pkg/sqlite/anonymise.go b/pkg/sqlite/anonymise.go index f9396034cd5..519489abfc6 100644 --- a/pkg/sqlite/anonymise.go +++ b/pkg/sqlite/anonymise.go @@ -28,7 +28,7 @@ type Anonymiser struct { } func NewAnonymiser(db *Database, outPath string) (*Anonymiser, error) { - if _, err := db.db.Exec(fmt.Sprintf(`VACUUM INTO "%s"`, outPath)); err != nil { + if _, err := db.writeDB.Exec(fmt.Sprintf(`VACUUM INTO "%s"`, outPath)); err != nil { return nil, fmt.Errorf("vacuuming into %s: %w", outPath, err) } @@ -75,12 +75,12 @@ func (db *Anonymiser) Anonymise(ctx context.Context) error { } func (db *Anonymiser) truncateColumn(tableName string, column string) error { - _, err := db.db.Exec("UPDATE " + tableName + " SET " + column + " = NULL") + _, err := db.writeDB.Exec("UPDATE " + tableName + " SET " + column + " = NULL") return err } func (db *Anonymiser) truncateTable(tableName string) error { - _, err := db.db.Exec("DELETE FROM " + tableName) + _, err := db.writeDB.Exec("DELETE FROM " + tableName) return err } diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 7dd4771d33f..eed335f0973 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -17,17 +17,21 @@ import ( ) const ( - // Number of database connections to use + maxWriteConnections = 1 + // Number of database read connections to use // The same value is used for both the maximum and idle limit, // to prevent opening connections on the fly which has a notieable performance penalty. // Fewer connections use less memory, more connections increase performance, // but have diminishing returns. // 10 was found to be a good tradeoff. - dbConns = 10 + maxReadConnections = 10 // Idle connection timeout, in seconds // Closes a connection after a period of inactivity, which saves on memory and // causes the sqlite -wal and -shm files to be automatically deleted. - dbConnTimeout = 30 + dbConnTimeout = 30 * time.Second + + // environment variable to set the cache size + cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) var appSchemaVersion uint = 67 @@ -80,8 +84,9 @@ type storeRepository struct { type Database struct { *storeRepository - db *sqlx.DB - dbPath string + readDB *sqlx.DB + writeDB *sqlx.DB + dbPath string schemaVersion uint @@ -128,7 +133,7 @@ func (db *Database) SetBlobStoreOptions(options BlobStoreOptions) { // Ready returns an error if the database is not ready to begin transactions. func (db *Database) Ready() error { - if db.db == nil { + if db.readDB == nil || db.writeDB == nil { return ErrDatabaseNotInitialized } @@ -140,7 +145,7 @@ func (db *Database) Ready() error { // necessary migrations must be run separately using RunMigrations. // Returns true if the database is new. func (db *Database) Open(dbPath string) error { - db.lockNoCtx() + db.lock() defer db.unlock() db.dbPath = dbPath @@ -152,7 +157,9 @@ func (db *Database) Open(dbPath string) error { db.schemaVersion = databaseSchemaVersion - if databaseSchemaVersion == 0 { + isNew := databaseSchemaVersion == 0 + + if isNew { // new database, just run the migrations if err := db.RunAllMigrations(); err != nil { return fmt.Errorf("error running initial schema migrations: %w", err) @@ -174,31 +181,23 @@ func (db *Database) Open(dbPath string) error { } } - // RunMigrations may have opened a connection already - if db.db == nil { - const disableForeignKeys = false - db.db, err = db.open(disableForeignKeys) + if err := db.initialise(); err != nil { + return err + } + + if isNew { + // optimize database after migration + err = db.Optimise(context.Background()) if err != nil { - return err + logger.Warnf("error while performing post-migration optimisation: %v", err) } } return nil } -// lock locks the database for writing. -// This method will block until the lock is acquired of the context is cancelled. -func (db *Database) lock(ctx context.Context) error { - select { - case <-ctx.Done(): - return ctx.Err() - case db.lockChan <- struct{}{}: - return nil - } -} - // lock locks the database for writing. This method will block until the lock is acquired. -func (db *Database) lockNoCtx() { +func (db *Database) lock() { db.lockChan <- struct{}{} } @@ -214,31 +213,47 @@ func (db *Database) unlock() { } func (db *Database) Close() error { - db.lockNoCtx() + db.lock() defer db.unlock() - if db.db != nil { - if err := db.db.Close(); err != nil { + if db.readDB != nil { + if err := db.readDB.Close(); err != nil { + return err + } + + db.readDB = nil + } + if db.writeDB != nil { + if err := db.writeDB.Close(); err != nil { return err } - db.db = nil + db.writeDB = nil } return nil } -func (db *Database) open(disableForeignKeys bool) (*sqlx.DB, error) { +func (db *Database) open(disableForeignKeys bool, writable bool) (*sqlx.DB, error) { // https://github.com/mattn/go-sqlite3 url := "file:" + db.dbPath + "?_journal=WAL&_sync=NORMAL&_busy_timeout=50" if !disableForeignKeys { url += "&_fk=true" } + if writable { + url += "&_txlock=immediate" + } else { + url += "&mode=ro" + } + + // #5155 - set the cache size if the environment variable is set + // default is -2000 which is 2MB + if cacheSize := os.Getenv(cacheSizeEnv); cacheSize != "" { + url += "&_cache_size=" + cacheSize + } + conn, err := sqlx.Open(sqlite3Driver, url) - conn.SetMaxOpenConns(dbConns) - conn.SetMaxIdleConns(dbConns) - conn.SetConnMaxIdleTime(dbConnTimeout * time.Second) if err != nil { return nil, fmt.Errorf("db.Open(): %w", err) } @@ -246,6 +261,43 @@ func (db *Database) open(disableForeignKeys bool) (*sqlx.DB, error) { return conn, nil } +func (db *Database) initialise() error { + if err := db.openReadDB(); err != nil { + return fmt.Errorf("opening read database: %w", err) + } + if err := db.openWriteDB(); err != nil { + return fmt.Errorf("opening write database: %w", err) + } + + return nil +} + +func (db *Database) openReadDB() error { + const ( + disableForeignKeys = false + writable = false + ) + var err error + db.readDB, err = db.open(disableForeignKeys, writable) + db.readDB.SetMaxOpenConns(maxReadConnections) + db.readDB.SetMaxIdleConns(maxReadConnections) + db.readDB.SetConnMaxIdleTime(dbConnTimeout) + return err +} + +func (db *Database) openWriteDB() error { + const ( + disableForeignKeys = false + writable = true + ) + var err error + db.writeDB, err = db.open(disableForeignKeys, writable) + db.writeDB.SetMaxOpenConns(maxWriteConnections) + db.writeDB.SetMaxIdleConns(maxWriteConnections) + db.writeDB.SetConnMaxIdleTime(dbConnTimeout) + return err +} + func (db *Database) Remove() error { databasePath := db.dbPath err := db.Close() @@ -289,7 +341,7 @@ func (db *Database) Reset() error { // Backup the database. If db is nil, then uses the existing database // connection. func (db *Database) Backup(backupPath string) (err error) { - thisDB := db.db + thisDB := db.writeDB if thisDB == nil { thisDB, err = sqlx.Connect(sqlite3Driver, "file:"+db.dbPath+"?_fk=true") if err != nil { @@ -372,13 +424,13 @@ func (db *Database) Optimise(ctx context.Context) error { // Vacuum runs a VACUUM on the database, rebuilding the database file into a minimal amount of disk space. func (db *Database) Vacuum(ctx context.Context) error { - _, err := db.db.ExecContext(ctx, "VACUUM") + _, err := db.writeDB.ExecContext(ctx, "VACUUM") return err } // Analyze runs an ANALYZE on the database to improve query performance. func (db *Database) Analyze(ctx context.Context) error { - _, err := db.db.ExecContext(ctx, "ANALYZE") + _, err := db.writeDB.ExecContext(ctx, "ANALYZE") return err } diff --git a/pkg/sqlite/migrate.go b/pkg/sqlite/migrate.go index 9fb36dba1d8..ba47544588d 100644 --- a/pkg/sqlite/migrate.go +++ b/pkg/sqlite/migrate.go @@ -7,6 +7,7 @@ import ( "github.com/golang-migrate/migrate/v4" sqlite3mig "github.com/golang-migrate/migrate/v4/database/sqlite3" "github.com/golang-migrate/migrate/v4/source/iofs" + "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/logger" ) @@ -15,8 +16,9 @@ func (db *Database) needsMigration() bool { } type Migrator struct { - db *Database - m *migrate.Migrate + db *Database + conn *sqlx.DB + m *migrate.Migrate } func NewMigrator(db *Database) (*Migrator, error) { @@ -24,7 +26,18 @@ func NewMigrator(db *Database) (*Migrator, error) { db: db, } + const disableForeignKeys = true + const writable = true var err error + m.conn, err = m.db.open(disableForeignKeys, writable) + if err != nil { + return nil, err + } + + m.conn.SetMaxOpenConns(maxReadConnections) + m.conn.SetMaxIdleConns(maxReadConnections) + m.conn.SetConnMaxIdleTime(dbConnTimeout) + m.m, err = m.getMigrate() return m, err } @@ -51,13 +64,7 @@ func (m *Migrator) getMigrate() (*migrate.Migrate, error) { return nil, err } - const disableForeignKeys = true - conn, err := m.db.open(disableForeignKeys) - if err != nil { - return nil, err - } - - driver, err := sqlite3mig.WithInstance(conn.DB, &sqlite3mig.Config{}) + driver, err := sqlite3mig.WithInstance(m.conn.DB, &sqlite3mig.Config{}) if err != nil { return nil, err } @@ -110,14 +117,7 @@ func (m *Migrator) runCustomMigrations(ctx context.Context, fns []customMigratio } func (m *Migrator) runCustomMigration(ctx context.Context, fn customMigrationFunc) error { - const disableForeignKeys = false - d, err := m.db.open(disableForeignKeys) - if err != nil { - return err - } - - defer d.Close() - if err := fn(ctx, d); err != nil { + if err := fn(ctx, m.conn); err != nil { return err } @@ -136,14 +136,7 @@ func (db *Database) getDatabaseSchemaVersion() (uint, error) { } func (db *Database) ReInitialise() error { - const disableForeignKeys = false - var err error - db.db, err = db.open(disableForeignKeys) - if err != nil { - return fmt.Errorf("re-initializing the database: %w", err) - } - - return nil + return db.initialise() } // RunAllMigrations runs all migrations to bring the database up to the current schema version @@ -171,18 +164,5 @@ func (db *Database) RunAllMigrations() error { } } - // re-initialise the database - const disableForeignKeys = false - db.db, err = db.open(disableForeignKeys) - if err != nil { - return fmt.Errorf("re-initializing the database: %w", err) - } - - // optimize database after migration - err = db.Optimise(ctx) - if err != nil { - logger.Warnf("error while performing post-migration optimisation: %v", err) - } - return nil } diff --git a/pkg/sqlite/migrations/45_postmigrate.go b/pkg/sqlite/migrations/45_postmigrate.go index 6cafc5e7f15..3a2ee67020e 100644 --- a/pkg/sqlite/migrations/45_postmigrate.go +++ b/pkg/sqlite/migrations/45_postmigrate.go @@ -247,7 +247,7 @@ func (m *schema45Migrator) insertImage(data []byte, id int, destTable string, de func (m *schema45Migrator) dropTable(ctx context.Context, table string) error { if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { logger.Debugf("Dropping %s", table) - _, err := m.db.Exec(fmt.Sprintf("DROP TABLE `%s`", table)) + _, err := tx.Exec(fmt.Sprintf("DROP TABLE `%s`", table)) return err }); err != nil { return err diff --git a/pkg/sqlite/migrations/48_premigrate.go b/pkg/sqlite/migrations/48_premigrate.go index f0e59620e04..05c2c352352 100644 --- a/pkg/sqlite/migrations/48_premigrate.go +++ b/pkg/sqlite/migrations/48_premigrate.go @@ -52,7 +52,7 @@ func (m *schema48PreMigrator) validateScrapedItems(ctx context.Context) error { func (m *schema48PreMigrator) fixStudioNames(ctx context.Context) error { // First remove NULL names if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { - _, err := m.db.Exec("UPDATE studios SET name = 'NULL' WHERE name IS NULL") + _, err := tx.Exec("UPDATE studios SET name = 'NULL' WHERE name IS NULL") return err }); err != nil { return err @@ -64,7 +64,7 @@ func (m *schema48PreMigrator) fixStudioNames(ctx context.Context) error { // collect names if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { - rows, err := m.db.Query("SELECT id, name FROM studios ORDER BY name, id") + rows, err := tx.Query("SELECT id, name FROM studios ORDER BY name, id") if err != nil { return err } @@ -114,7 +114,7 @@ func (m *schema48PreMigrator) fixStudioNames(ctx context.Context) error { var count int - row := m.db.QueryRowx("SELECT COUNT(*) FROM studios WHERE name = ?", newName) + row := tx.QueryRowx("SELECT COUNT(*) FROM studios WHERE name = ?", newName) err := row.Scan(&count) if err != nil { return err @@ -131,7 +131,7 @@ func (m *schema48PreMigrator) fixStudioNames(ctx context.Context) error { } logger.Infof("Renaming duplicate studio id %d to %s", id, newName) - _, err := m.db.Exec("UPDATE studios SET name = ? WHERE id = ?", newName, id) + _, err := tx.Exec("UPDATE studios SET name = ? WHERE id = ?", newName, id) if err != nil { return err } diff --git a/pkg/sqlite/migrations/60_postmigrate.go b/pkg/sqlite/migrations/60_postmigrate.go index dfed33f1886..53d4da5c9ec 100644 --- a/pkg/sqlite/migrations/60_postmigrate.go +++ b/pkg/sqlite/migrations/60_postmigrate.go @@ -48,7 +48,7 @@ func (m *schema60Migrator) migrate(ctx context.Context) error { if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { query := "SELECT id, mode, find_filter, object_filter, ui_options FROM `saved_filters` WHERE `name` = ''" - rows, err := m.db.Query(query) + rows, err := tx.Query(query) if err != nil { return err } @@ -98,7 +98,7 @@ func (m *schema60Migrator) migrate(ctx context.Context) error { // remove the default filters from the database query = "DELETE FROM `saved_filters` WHERE `name` = ''" - if _, err := m.db.Exec(query); err != nil { + if _, err := tx.Exec(query); err != nil { return fmt.Errorf("deleting default filters: %w", err) } diff --git a/pkg/sqlite/transaction.go b/pkg/sqlite/transaction.go index 705c61e0789..fb86723bdff 100644 --- a/pkg/sqlite/transaction.go +++ b/pkg/sqlite/transaction.go @@ -17,7 +17,7 @@ type key int const ( txnKey key = iota + 1 dbKey - exclusiveKey + writableKey ) func (db *Database) WithDatabase(ctx context.Context) (context.Context, error) { @@ -26,10 +26,10 @@ func (db *Database) WithDatabase(ctx context.Context) (context.Context, error) { return ctx, nil } - return context.WithValue(ctx, dbKey, db.db), nil + return context.WithValue(ctx, dbKey, db.readDB), nil } -func (db *Database) Begin(ctx context.Context, exclusive bool) (context.Context, error) { +func (db *Database) Begin(ctx context.Context, writable bool) (context.Context, error) { if tx, _ := getTx(ctx); tx != nil { // log the stack trace so we can see logger.Error(string(debug.Stack())) @@ -37,22 +37,17 @@ func (db *Database) Begin(ctx context.Context, exclusive bool) (context.Context, return nil, fmt.Errorf("already in transaction") } - if exclusive { - if err := db.lock(ctx); err != nil { - return nil, err - } + dbtx := db.readDB + if writable { + dbtx = db.writeDB } - tx, err := db.db.BeginTxx(ctx, nil) + tx, err := dbtx.BeginTxx(ctx, nil) if err != nil { - // begin failed, unlock - if exclusive { - db.unlock() - } return nil, fmt.Errorf("beginning transaction: %w", err) } - ctx = context.WithValue(ctx, exclusiveKey, exclusive) + ctx = context.WithValue(ctx, writableKey, writable) return context.WithValue(ctx, txnKey, tx), nil } @@ -88,9 +83,6 @@ func (db *Database) Rollback(ctx context.Context) error { } func (db *Database) txnComplete(ctx context.Context) { - if exclusive := ctx.Value(exclusiveKey).(bool); exclusive { - db.unlock() - } } func getTx(ctx context.Context) (*sqlx.Tx, error) { diff --git a/pkg/sqlite/transaction_test.go b/pkg/sqlite/transaction_test.go index 00aa9c2de56..513a60a2065 100644 --- a/pkg/sqlite/transaction_test.go +++ b/pkg/sqlite/transaction_test.go @@ -77,80 +77,83 @@ func waitForOtherThread(c chan struct{}) error { } } -func TestConcurrentReadTxn(t *testing.T) { - var wg sync.WaitGroup - ctx := context.Background() - c := make(chan struct{}) - - // first thread - wg.Add(2) - go func() { - defer wg.Done() - if err := txn.WithReadTxn(ctx, db, func(ctx context.Context) error { - scene := &models.Scene{ - Title: "test", - } +// this test is left commented as it's no longer possible to write to the database +// with a read-only transaction. - if err := db.Scene.Create(ctx, scene, nil); err != nil { - return err - } +// func TestConcurrentReadTxn(t *testing.T) { +// var wg sync.WaitGroup +// ctx := context.Background() +// c := make(chan struct{}) + +// // first thread +// wg.Add(2) +// go func() { +// defer wg.Done() +// if err := txn.WithReadTxn(ctx, db, func(ctx context.Context) error { +// scene := &models.Scene{ +// Title: "test", +// } - // wait for other thread to start - if err := signalOtherThread(c); err != nil { - return err - } - if err := waitForOtherThread(c); err != nil { - return err - } +// if err := db.Scene.Create(ctx, scene, nil); err != nil { +// return err +// } - if err := db.Scene.Destroy(ctx, scene.ID); err != nil { - return err - } +// // wait for other thread to start +// if err := signalOtherThread(c); err != nil { +// return err +// } +// if err := waitForOtherThread(c); err != nil { +// return err +// } - return nil - }); err != nil { - t.Errorf("unexpected error in first thread: %v", err) - } - }() +// if err := db.Scene.Destroy(ctx, scene.ID); err != nil { +// return err +// } - // second thread - go func() { - defer wg.Done() - _ = txn.WithReadTxn(ctx, db, func(ctx context.Context) error { - // wait for first thread - if err := waitForOtherThread(c); err != nil { - t.Errorf(err.Error()) - return err - } +// return nil +// }); err != nil { +// t.Errorf("unexpected error in first thread: %v", err) +// } +// }() + +// // second thread +// go func() { +// defer wg.Done() +// _ = txn.WithReadTxn(ctx, db, func(ctx context.Context) error { +// // wait for first thread +// if err := waitForOtherThread(c); err != nil { +// t.Errorf(err.Error()) +// return err +// } - defer func() { - if err := signalOtherThread(c); err != nil { - t.Errorf(err.Error()) - } - }() +// defer func() { +// if err := signalOtherThread(c); err != nil { +// t.Errorf(err.Error()) +// } +// }() - scene := &models.Scene{ - Title: "test", - } +// scene := &models.Scene{ +// Title: "test", +// } - // expect error when we try to do this, as the other thread has already - // modified this table - // this takes time to fail, so we need to wait for it - if err := db.Scene.Create(ctx, scene, nil); err != nil { - if !db.IsLocked(err) { - t.Errorf("unexpected error: %v", err) - } - return err - } else { - t.Errorf("expected locked error in second thread") - } +// // expect error when we try to do this, as the other thread has already +// // modified this table +// // this takes time to fail, so we need to wait for it +// if err := db.Scene.Create(ctx, scene, nil); err != nil { +// if !db.IsLocked(err) { +// t.Errorf("unexpected error: %v", err) +// } +// return err +// } else { +// t.Errorf("expected locked error in second thread") +// } - return nil - }) - }() +// return nil +// }) +// }() - wg.Wait() -} +// wg.Wait() +// } func TestConcurrentExclusiveAndReadTxn(t *testing.T) { var wg sync.WaitGroup diff --git a/pkg/txn/transaction.go b/pkg/txn/transaction.go index b8d0aa8300c..219482fbb1b 100644 --- a/pkg/txn/transaction.go +++ b/pkg/txn/transaction.go @@ -7,7 +7,7 @@ import ( ) type Manager interface { - Begin(ctx context.Context, exclusive bool) (context.Context, error) + Begin(ctx context.Context, writable bool) (context.Context, error) Commit(ctx context.Context) error Rollback(ctx context.Context) error @@ -28,34 +28,30 @@ type MustFunc func(ctx context.Context) // WithTxn executes fn in a transaction. If fn returns an error then // the transaction is rolled back. Otherwise it is committed. -// Transaction is exclusive. Only one thread may run a transaction -// using this function at a time. This function will wait until the -// lock is available before executing. +// This function will call m.Begin with writable = true. // This function should be used for making changes to the database. func WithTxn(ctx context.Context, m Manager, fn TxnFunc) error { const ( execComplete = true - exclusive = true + writable = true ) - return withTxn(ctx, m, fn, exclusive, execComplete) + return withTxn(ctx, m, fn, writable, execComplete) } // WithReadTxn executes fn in a transaction. If fn returns an error then // the transaction is rolled back. Otherwise it is committed. -// Transaction is not exclusive and does not enforce read-only restrictions. -// Multiple threads can run transactions using this function concurrently, -// but concurrent writes may result in locked database error. +// This function will call m.Begin with writable = false. func WithReadTxn(ctx context.Context, m Manager, fn TxnFunc) error { const ( execComplete = true - exclusive = false + writable = false ) - return withTxn(ctx, m, fn, exclusive, execComplete) + return withTxn(ctx, m, fn, writable, execComplete) } -func withTxn(ctx context.Context, m Manager, fn TxnFunc, exclusive bool, execCompleteOnLocked bool) error { +func withTxn(ctx context.Context, m Manager, fn TxnFunc, writable bool, execCompleteOnLocked bool) error { // post-hooks should be executed with the outside context - txnCtx, err := begin(ctx, m, exclusive) + txnCtx, err := begin(ctx, m, writable) if err != nil { return err } @@ -94,9 +90,9 @@ func withTxn(ctx context.Context, m Manager, fn TxnFunc, exclusive bool, execCom return err } -func begin(ctx context.Context, m Manager, exclusive bool) (context.Context, error) { +func begin(ctx context.Context, m Manager, writable bool) (context.Context, error) { var err error - ctx, err = m.Begin(ctx, exclusive) + ctx, err = m.Begin(ctx, writable) if err != nil { return nil, err } diff --git a/ui/v2.5/src/docs/en/Manual/Configuration.md b/ui/v2.5/src/docs/en/Manual/Configuration.md index 197ae541024..e3824f064ba 100644 --- a/ui/v2.5/src/docs/en/Manual/Configuration.md +++ b/ui/v2.5/src/docs/en/Manual/Configuration.md @@ -149,6 +149,12 @@ These options are typically not exposed in the UI and must be changed manually i | `no_proxy` | A list of domains for which the proxy must not be used. Default is all local LAN: localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12 | | `sequential_scanning` | Modifies behaviour of the scanning functionality to generate support files (previews/sprites/phash) at the same time as fingerprinting/screenshotting. Useful when scanning cached remote files. | +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. | + ### Custom served folders Custom served folders are served when the server handles a request with the `/custom` URL prefix. The following is an example configuration: From 3abdcbee6fe9f1e5d558533dcfe89cfec3836a5f Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Sun, 22 Sep 2024 06:18:12 +0300 Subject: [PATCH 35/91] Replace movie with group (#5280) --- ui/v2.5/src/docs/en/Manual/Browsing.md | 2 +- ui/v2.5/src/docs/en/Manual/JSONSpec.md | 4 ++-- .../src/docs/en/Manual/KeyboardShortcuts.md | 18 +++++++++--------- ui/v2.5/src/docs/en/Manual/Plugins.md | 4 ++-- ui/v2.5/src/docs/en/Manual/Scraping.md | 4 ++-- ui/v2.5/src/docs/en/Manual/UIPluginApi.md | 6 +++--- 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/ui/v2.5/src/docs/en/Manual/Browsing.md b/ui/v2.5/src/docs/en/Manual/Browsing.md index a0c2896fa8c..d41388232e2 100644 --- a/ui/v2.5/src/docs/en/Manual/Browsing.md +++ b/ui/v2.5/src/docs/en/Manual/Browsing.md @@ -10,7 +10,7 @@ The text field allows you to search using keywords. Keyword searching matches on |------|-----------------| | Scene | Title, Details, Path, OSHash, Checksum, Marker titles | | Image | Title, Path, Checksum | -| Movie | Title | +| Group | Title | | Marker | Title, Scene title | | Gallery | Title, Path, Checksum | | Performer | Name, Aliases | diff --git a/ui/v2.5/src/docs/en/Manual/JSONSpec.md b/ui/v2.5/src/docs/en/Manual/JSONSpec.md index e7e22bfe864..0a53d09f206 100644 --- a/ui/v2.5/src/docs/en/Manual/JSONSpec.md +++ b/ui/v2.5/src/docs/en/Manual/JSONSpec.md @@ -8,7 +8,7 @@ The metadata given to Stash can be exported into the JSON format. This structure * `performers` * `scenes` * `studios` -* `movies` +* `groups` ## File naming @@ -22,7 +22,7 @@ When exported, files are named with different formats depending on the object ty | Performers | `.json` | | Scenes | `.<hash>.json` | | Studios | `<name>.json` | -| Movies | `<name>.json` | +| Groups | `<name>.json` | Note that the file naming is not significant when importing. All json files will be read from the subdirectories. diff --git a/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md b/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md index 3ece6f90aaa..2c250cb6573 100644 --- a/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md +++ b/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md @@ -12,7 +12,7 @@ |-------------------|--------| | `g s` | Scenes | | `g i` | Images | -| `g v` | Movies | +| `g v` | Groups | | `g k` | Markers | | `g l` | Galleries | | `g p` | Performers | @@ -104,28 +104,28 @@ [//]: # "(| `l` | Focus Gallery selector |)" [//]: # "(| `u` | Focus Studio selector |)" [//]: # "(| `p` | Focus Performers selector |)" -[//]: # "(| `v` | Focus Movies selector |)" +[//]: # "(| `v` | Focus Groups selector |)" [//]: # "(| `t` | Focus Tags selector |)" -## Movies Page shortcuts +## Groups Page shortcuts | Keyboard sequence | Action | |-------------------|--------| -| `n` | New Movie | +| `n` | New Group | -## Movie Page shortcuts +## Group Page shortcuts | Keyboard sequence | Action | |-------------------|--------| -| `e` | Edit Movie | -| `s s` | Save Movie | -| `d d` | Delete Movie | +| `e` | Edit Group | +| `s s` | Save Group | +| `d d` | Delete Group | | `r {1-5}` | [Edit mode] Set rating (stars) | | `r 0` | [Edit mode] Unset rating (stars) | | `r {0-9} {0-9}` | [Edit mode] Set rating (decimal - `r 0 0` for `10.0`) | | ``r ` `` | [Edit mode] Unset rating (decimal) | | `,` | Expand/Collapse Details | -| `Ctrl + v` | Paste Movie image | +| `Ctrl + v` | Paste Group image | [//]: # "Commented until implementation is dealt with" [//]: # "(| `u` | Focus Studio selector (in edit mode) |)" diff --git a/ui/v2.5/src/docs/en/Manual/Plugins.md b/ui/v2.5/src/docs/en/Manual/Plugins.md index 90a2782d7ed..23ef0bc8188 100644 --- a/ui/v2.5/src/docs/en/Manual/Plugins.md +++ b/ui/v2.5/src/docs/en/Manual/Plugins.md @@ -248,7 +248,7 @@ The following object types are supported: * `SceneMarker` * `Image` * `Gallery` -* `Movie` +* `Group` * `Performer` * `Studio` * `Tag` @@ -296,7 +296,7 @@ For example, here is the `args` values for a Scene update operation: "studio_id":null, "gallery_ids":null, "performer_ids":null, - "movies":null, + "groups":null, "tag_ids":["21"], "cover_image":null, "stash_ids":null diff --git a/ui/v2.5/src/docs/en/Manual/Scraping.md b/ui/v2.5/src/docs/en/Manual/Scraping.md index 267f03b97d6..7f7b88a4931 100644 --- a/ui/v2.5/src/docs/en/Manual/Scraping.md +++ b/ui/v2.5/src/docs/en/Manual/Scraping.md @@ -15,7 +15,7 @@ Stash supports scraping of metadata from various external sources. | | Fragment | Search | URL | |---|:---:|:---:|:---:| | gallery | ✔️ | | ✔️ | -| movie | | | ✔️ | +| group | | | ✔️ | | performer | | ✔️ | ✔️ | | scene | ✔️ | ✔️ | ✔️ | @@ -94,7 +94,7 @@ When used in combination with stash-box, the user can optionally submit scene fi | | Has Tagger | Source Selection | |---|:---:|:---:| | gallery | | | -| movie | | | +| group | | | | performer | ✔️ | | | scene | ✔️ | ✔️ | diff --git a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md index 7d267d1b1e9..760dfef546a 100644 --- a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md +++ b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md @@ -153,9 +153,9 @@ Returns `void`. - `Icon` - `ImageDetailPanel` - `ModalSetting` -- `MovieIDSelect` -- `MovieSelect` -- `MovieSelect.sort` +- `GroupIDSelect` +- `GroupSelect` +- `GroupSelect.sort` - `NumberSetting` - `PerformerDetailsPanel` - `PerformerDetailsPanel.DetailGroup` From fd9e4b3ec2cf1a4da576a99c052fe2dd607a8433 Mon Sep 17 00:00:00 2001 From: dogwithakeyboard <128322708+dogwithakeyboard@users.noreply.github.com> Date: Sun, 22 Sep 2024 04:20:33 +0100 Subject: [PATCH 36/91] add table alias to group scene sort (#5279) --- pkg/sqlite/scene.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index c950be4d160..5df614b886f 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -1128,9 +1128,12 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF direction := findFilter.GetDirection() switch sort { - case "movie_scene_number", "group_scene_number": + case "movie_scene_number": query.join(groupsScenesTable, "", "scenes.id = groups_scenes.scene_id") query.sortAndPagination += getSort("scene_index", direction, groupsScenesTable) + case "group_scene_number": + query.join(groupsScenesTable, "scene_group", "scenes.id = scene_group.scene_id") + query.sortAndPagination += getSort("scene_index", direction, "scene_group") case "tag_count": query.sortAndPagination += getCountSort(sceneTable, scenesTagsTable, sceneIDColumn, direction) case "performer_count": From 7e8c764dc71dcd4b5a9c197d6c98ff90db00d1cd Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Sun, 22 Sep 2024 14:03:54 +1000 Subject: [PATCH 37/91] Fix migrations not using tx (#5282) --- pkg/sqlite/migrations/32_postmigrate.go | 32 +++++++++++++------------ pkg/sqlite/migrations/32_premigrate.go | 4 ++-- pkg/sqlite/migrations/34_postmigrate.go | 4 ++-- pkg/sqlite/migrations/42_postmigrate.go | 28 +++++++++++----------- pkg/sqlite/migrations/45_postmigrate.go | 12 +++++----- pkg/sqlite/migrations/49_postmigrate.go | 4 ++-- pkg/sqlite/migrations/52_postmigrate.go | 6 ++--- pkg/sqlite/migrations/55_postmigrate.go | 4 ++-- pkg/sqlite/migrations/64_postmigrate.go | 4 ++-- 9 files changed, 50 insertions(+), 48 deletions(-) diff --git a/pkg/sqlite/migrations/32_postmigrate.go b/pkg/sqlite/migrations/32_postmigrate.go index 4dbd65df874..6a4cf3d00b8 100644 --- a/pkg/sqlite/migrations/32_postmigrate.go +++ b/pkg/sqlite/migrations/32_postmigrate.go @@ -74,7 +74,7 @@ func (m *schema32Migrator) migrateFolders(ctx context.Context) error { query += fmt.Sprintf("ORDER BY `folders`.`id` LIMIT %d", limit) - rows, err := m.db.Query(query) + rows, err := tx.Query(query) if err != nil { return err } @@ -94,12 +94,12 @@ func (m *schema32Migrator) migrateFolders(ctx context.Context) error { count++ parent := filepath.Dir(p) - parentID, zipFileID, err := m.createFolderHierarchy(parent) + parentID, zipFileID, err := m.createFolderHierarchy(tx, parent) if err != nil { return err } - _, err = m.db.Exec("UPDATE `folders` SET `parent_folder_id` = ?, `zip_file_id` = ? WHERE `id` = ?", parentID, zipFileID, id) + _, err = tx.Exec("UPDATE `folders` SET `parent_folder_id` = ?, `zip_file_id` = ? WHERE `id` = ?", parentID, zipFileID, id) if err != nil { return err } @@ -153,7 +153,7 @@ func (m *schema32Migrator) migrateFiles(ctx context.Context) error { query += fmt.Sprintf("ORDER BY `id` LIMIT %d", limit) if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { - rows, err := m.db.Query(query) + rows, err := tx.Query(query) if err != nil { return err } @@ -178,12 +178,12 @@ func (m *schema32Migrator) migrateFiles(ctx context.Context) error { parent := filepath.Dir(p) basename := filepath.Base(p) if parent != "." { - parentID, zipFileID, err := m.createFolderHierarchy(parent) + parentID, zipFileID, err := m.createFolderHierarchy(tx, parent) if err != nil { return err } - _, err = m.db.Exec("UPDATE `files` SET `parent_folder_id` = ?, `zip_file_id` = ?, `basename` = ? WHERE `id` = ?", parentID, zipFileID, basename, id) + _, err = tx.Exec("UPDATE `files` SET `parent_folder_id` = ?, `zip_file_id` = ?, `basename` = ? WHERE `id` = ?", parentID, zipFileID, basename, id) if err != nil { return fmt.Errorf("migrating file %s: %w", p, err) } @@ -245,16 +245,18 @@ func (m *schema32Migrator) deletePlaceholderFolder(ctx context.Context) error { return fmt.Errorf("not deleting placeholder folder because it has %d folders", result.Count) } - _, err := m.db.Exec("DELETE FROM `folders` WHERE `id` = 1") - return err + return m.withTxn(ctx, func(tx *sqlx.Tx) error { + _, err := tx.Exec("DELETE FROM `folders` WHERE `id` = 1") + return err + }) } -func (m *schema32Migrator) createFolderHierarchy(p string) (*int, sql.NullInt64, error) { +func (m *schema32Migrator) createFolderHierarchy(tx *sqlx.Tx, p string) (*int, sql.NullInt64, error) { parent := filepath.Dir(p) if parent == p { // get or create this folder - return m.getOrCreateFolder(p, nil, sql.NullInt64{}) + return m.getOrCreateFolder(tx, p, nil, sql.NullInt64{}) } var ( @@ -269,23 +271,23 @@ func (m *schema32Migrator) createFolderHierarchy(p string) (*int, sql.NullInt64, parentID = &foundEntry.id zipFileID = foundEntry.zipID } else { - parentID, zipFileID, err = m.createFolderHierarchy(parent) + parentID, zipFileID, err = m.createFolderHierarchy(tx, parent) if err != nil { return nil, sql.NullInt64{}, err } } - return m.getOrCreateFolder(p, parentID, zipFileID) + return m.getOrCreateFolder(tx, p, parentID, zipFileID) } -func (m *schema32Migrator) getOrCreateFolder(path string, parentID *int, zipFileID sql.NullInt64) (*int, sql.NullInt64, error) { +func (m *schema32Migrator) getOrCreateFolder(tx *sqlx.Tx, path string, parentID *int, zipFileID sql.NullInt64) (*int, sql.NullInt64, error) { foundEntry, ok := m.folderCache[path] if ok { return &foundEntry.id, foundEntry.zipID, nil } const query = "SELECT `id`, `zip_file_id` FROM `folders` WHERE `path` = ?" - rows, err := m.db.Query(query, path) + rows, err := tx.Query(query, path) if err != nil { return nil, sql.NullInt64{}, err } @@ -314,7 +316,7 @@ func (m *schema32Migrator) getOrCreateFolder(path string, parentID *int, zipFile } now := time.Now() - result, err := m.db.Exec(insertSQL, path, parentFolderID, zipFileID, time.Time{}, now, now) + result, err := tx.Exec(insertSQL, path, parentFolderID, zipFileID, time.Time{}, now, now) if err != nil { return nil, sql.NullInt64{}, fmt.Errorf("creating folder %s: %w", path, err) } diff --git a/pkg/sqlite/migrations/32_premigrate.go b/pkg/sqlite/migrations/32_premigrate.go index 12906f7d553..caba639bcc6 100644 --- a/pkg/sqlite/migrations/32_premigrate.go +++ b/pkg/sqlite/migrations/32_premigrate.go @@ -65,7 +65,7 @@ func (m *schema32PreMigrator) migrate(ctx context.Context) error { query += fmt.Sprintf("ORDER BY `id` LIMIT %d", limit) - rows, err := m.db.Query(query) + rows, err := tx.Query(query) if err != nil { return err } @@ -100,7 +100,7 @@ func (m *schema32PreMigrator) migrate(ctx context.Context) error { logger.Infof("Correcting %q gallery to be zip-based.", p) - _, err = m.db.Exec("UPDATE `galleries` SET `zip` = '1' WHERE `id` = ?", id) + _, err = tx.Exec("UPDATE `galleries` SET `zip` = '1' WHERE `id` = ?", id) if err != nil { return err } diff --git a/pkg/sqlite/migrations/34_postmigrate.go b/pkg/sqlite/migrations/34_postmigrate.go index e167c9a975d..769655cb6e5 100644 --- a/pkg/sqlite/migrations/34_postmigrate.go +++ b/pkg/sqlite/migrations/34_postmigrate.go @@ -88,7 +88,7 @@ func (m *schema34Migrator) migrateObjects(ctx context.Context, table string, col query += fmt.Sprintf(" ORDER BY `id` LIMIT %d", limit) - rows, err := m.db.Query(query) + rows, err := tx.Query(query) if err != nil { return err } @@ -126,7 +126,7 @@ func (m *schema34Migrator) migrateObjects(ctx context.Context, table string, col updateSQL := fmt.Sprintf("UPDATE `%s` SET %s WHERE `id` = ?", table, updateList) - _, err = m.db.Exec(updateSQL, args...) + _, err = tx.Exec(updateSQL, args...) if err != nil { return err } diff --git a/pkg/sqlite/migrations/42_postmigrate.go b/pkg/sqlite/migrations/42_postmigrate.go index afb0db9e7aa..42180ff0f11 100644 --- a/pkg/sqlite/migrations/42_postmigrate.go +++ b/pkg/sqlite/migrations/42_postmigrate.go @@ -71,7 +71,7 @@ func (m *schema42Migrator) migrate(ctx context.Context) error { query += fmt.Sprintf(" ORDER BY `performer_id` LIMIT %d", limit) - rows, err := m.db.Query(query) + rows, err := tx.Query(query) if err != nil { return err } @@ -92,7 +92,7 @@ func (m *schema42Migrator) migrate(ctx context.Context) error { gotSome = true count++ - if err := m.migratePerformerAliases(id, aliases); err != nil { + if err := m.migratePerformerAliases(tx, id, aliases); err != nil { return err } } @@ -114,7 +114,7 @@ func (m *schema42Migrator) migrate(ctx context.Context) error { return nil } -func (m *schema42Migrator) migratePerformerAliases(id int, aliases string) error { +func (m *schema42Migrator) migratePerformerAliases(tx *sqlx.Tx, id int, aliases string) error { // split aliases by , or / aliasList := strings.FieldsFunc(aliases, func(r rune) bool { return strings.ContainsRune(",/", r) @@ -126,7 +126,7 @@ func (m *schema42Migrator) migratePerformerAliases(id int, aliases string) error } // delete the existing row - if _, err := m.db.Exec("DELETE FROM `performer_aliases` WHERE `performer_id` = ?", id); err != nil { + if _, err := tx.Exec("DELETE FROM `performer_aliases` WHERE `performer_id` = ?", id); err != nil { return err } @@ -140,7 +140,7 @@ func (m *schema42Migrator) migratePerformerAliases(id int, aliases string) error // insert aliases into table for _, alias := range aliasList { - _, err := m.db.Exec("INSERT INTO `performer_aliases` (`performer_id`, `alias`) VALUES (?, ?)", id, alias) + _, err := tx.Exec("INSERT INTO `performer_aliases` (`performer_id`, `alias`) VALUES (?, ?)", id, alias) if err != nil { return err } @@ -173,7 +173,7 @@ SELECT id, name FROM performers WHERE performers.name like '% (%)'` query += fmt.Sprintf(" ORDER BY `id` LIMIT %d", limit) - rows, err := m.db.Query(query) + rows, err := tx.Query(query) if err != nil { return err } @@ -194,7 +194,7 @@ SELECT id, name FROM performers WHERE performers.name like '% (%)'` lastID = id count++ - if err := m.massagePerformerName(id, name); err != nil { + if err := m.massagePerformerName(tx, id, name); err != nil { return err } } @@ -220,7 +220,7 @@ SELECT id, name FROM performers WHERE performers.name like '% (%)'` // the format "name (disambiguation)". var performerDisRE = regexp.MustCompile(`^((?:[^(\s]+\s)+)\(([^)]+)\)$`) -func (m *schema42Migrator) massagePerformerName(performerID int, name string) error { +func (m *schema42Migrator) massagePerformerName(tx *sqlx.Tx, performerID int, name string) error { r := performerDisRE.FindStringSubmatch(name) if len(r) != 3 { @@ -235,7 +235,7 @@ func (m *schema42Migrator) massagePerformerName(performerID int, name string) er logger.Infof("Separating %q into %q and disambiguation %q", name, newName, newDis) - _, err := m.db.Exec("UPDATE performers SET name = ?, disambiguation = ? WHERE id = ?", newName, newDis, performerID) + _, err := tx.Exec("UPDATE performers SET name = ?, disambiguation = ? WHERE id = ?", newName, newDis, performerID) if err != nil { return err } @@ -266,7 +266,7 @@ SELECT id, name FROM performers WHERE performers.disambiguation IS NULL AND EXIS query += fmt.Sprintf(" ORDER BY `id` LIMIT %d", limit) - rows, err := m.db.Query(query) + rows, err := tx.Query(query) if err != nil { return err } @@ -286,7 +286,7 @@ SELECT id, name FROM performers WHERE performers.disambiguation IS NULL AND EXIS gotSome = true count++ - if err := m.migrateDuplicatePerformer(id, name); err != nil { + if err := m.migrateDuplicatePerformer(tx, id, name); err != nil { return err } } @@ -308,13 +308,13 @@ SELECT id, name FROM performers WHERE performers.disambiguation IS NULL AND EXIS return nil } -func (m *schema42Migrator) migrateDuplicatePerformer(performerID int, name string) error { +func (m *schema42Migrator) migrateDuplicatePerformer(tx *sqlx.Tx, performerID int, name string) error { // get the highest value of disambiguation for this performer name query := ` SELECT disambiguation FROM performers WHERE name = ? ORDER BY disambiguation DESC LIMIT 1` var disambiguation sql.NullString - if err := m.db.Get(&disambiguation, query, name); err != nil { + if err := tx.Get(&disambiguation, query, name); err != nil { return err } @@ -333,7 +333,7 @@ SELECT disambiguation FROM performers WHERE name = ? ORDER BY disambiguation DES logger.Infof("Adding disambiguation '%d' for performer %q", newDisambiguation, name) - _, err := m.db.Exec("UPDATE performers SET disambiguation = ? WHERE id = ?", strconv.Itoa(newDisambiguation), performerID) + _, err := tx.Exec("UPDATE performers SET disambiguation = ? WHERE id = ?", strconv.Itoa(newDisambiguation), performerID) if err != nil { return err } diff --git a/pkg/sqlite/migrations/45_postmigrate.go b/pkg/sqlite/migrations/45_postmigrate.go index 3a2ee67020e..9e2bb1f8f14 100644 --- a/pkg/sqlite/migrations/45_postmigrate.go +++ b/pkg/sqlite/migrations/45_postmigrate.go @@ -161,7 +161,7 @@ func (m *schema45Migrator) migrateImagesTable(ctx context.Context, options migra query += fmt.Sprintf(" LIMIT %d", limit) - rows, err := m.db.Query(query) + rows, err := tx.Query(query) if err != nil { return err } @@ -191,7 +191,7 @@ func (m *schema45Migrator) migrateImagesTable(ctx context.Context, options migra image := result[i+1].(*[]byte) if len(*image) > 0 { - if err := m.insertImage(*image, id, options.destTable, col.destCol); err != nil { + if err := m.insertImage(tx, *image, id, options.destTable, col.destCol); err != nil { return err } } @@ -202,7 +202,7 @@ func (m *schema45Migrator) migrateImagesTable(ctx context.Context, options migra "joinTable": options.joinTable, "joinIDCol": options.joinIDCol, }) - if _, err := m.db.Exec(deleteSQL, id); err != nil { + if _, err := tx.Exec(deleteSQL, id); err != nil { return err } } @@ -224,11 +224,11 @@ func (m *schema45Migrator) migrateImagesTable(ctx context.Context, options migra return nil } -func (m *schema45Migrator) insertImage(data []byte, id int, destTable string, destCol string) error { +func (m *schema45Migrator) insertImage(tx *sqlx.Tx, data []byte, id int, destTable string, destCol string) error { // calculate checksum and insert into blobs table checksum := md5.FromBytes(data) - if _, err := m.db.Exec("INSERT INTO `blobs` (`checksum`, `blob`) VALUES (?, ?) ON CONFLICT DO NOTHING", checksum, data); err != nil { + if _, err := tx.Exec("INSERT INTO `blobs` (`checksum`, `blob`) VALUES (?, ?) ON CONFLICT DO NOTHING", checksum, data); err != nil { return err } @@ -237,7 +237,7 @@ func (m *schema45Migrator) insertImage(data []byte, id int, destTable string, de "destTable": destTable, "destCol": destCol, }) - if _, err := m.db.Exec(updateSQL, checksum, id); err != nil { + if _, err := tx.Exec(updateSQL, checksum, id); err != nil { return err } diff --git a/pkg/sqlite/migrations/49_postmigrate.go b/pkg/sqlite/migrations/49_postmigrate.go index 67e128f2c77..8ba900d16cb 100644 --- a/pkg/sqlite/migrations/49_postmigrate.go +++ b/pkg/sqlite/migrations/49_postmigrate.go @@ -112,7 +112,7 @@ type schema49Migrator struct { func (m *schema49Migrator) migrateSavedFilters(ctx context.Context) error { if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { - rows, err := m.db.Query("SELECT id, mode, find_filter FROM saved_filters ORDER BY id") + rows, err := tx.Query("SELECT id, mode, find_filter FROM saved_filters ORDER BY id") if err != nil { return err } @@ -147,7 +147,7 @@ func (m *schema49Migrator) migrateSavedFilters(ctx context.Context) error { return fmt.Errorf("failed to get display options for saved filter %s : %w", findFilter, err) } - _, err = m.db.Exec("UPDATE saved_filters SET find_filter = ?, object_filter = ?, ui_options = ? WHERE id = ?", newFindFilter, objectFilter, uiOptions, id) + _, err = tx.Exec("UPDATE saved_filters SET find_filter = ?, object_filter = ?, ui_options = ? WHERE id = ?", newFindFilter, objectFilter, uiOptions, id) if err != nil { return fmt.Errorf("failed to update saved filter %d: %w", id, err) } diff --git a/pkg/sqlite/migrations/52_postmigrate.go b/pkg/sqlite/migrations/52_postmigrate.go index f173d808704..8235b4cf028 100644 --- a/pkg/sqlite/migrations/52_postmigrate.go +++ b/pkg/sqlite/migrations/52_postmigrate.go @@ -34,7 +34,7 @@ func (m *schema52Migrator) migrate(ctx context.Context) error { query := "SELECT `folders`.`id`, `folders`.`path`, `parent_folder`.`path` FROM `folders` " + "INNER JOIN `folders` AS `parent_folder` ON `parent_folder`.`id` = `folders`.`parent_folder_id`" - rows, err := m.db.Query(query) + rows, err := tx.Query(query) if err != nil { return err } @@ -64,7 +64,7 @@ func (m *schema52Migrator) migrate(ctx context.Context) error { // ensure the correct path is unique var v int - isEmptyErr := m.db.Get(&v, "SELECT 1 FROM folders WHERE path = ?", correctPath) + isEmptyErr := tx.Get(&v, "SELECT 1 FROM folders WHERE path = ?", correctPath) if isEmptyErr != nil && !errors.Is(isEmptyErr, sql.ErrNoRows) { return fmt.Errorf("error checking if correct path %s is unique: %w", correctPath, isEmptyErr) } @@ -75,7 +75,7 @@ func (m *schema52Migrator) migrate(ctx context.Context) error { continue } - if _, err := m.db.Exec("UPDATE folders SET path = ? WHERE id = ?", correctPath, id); err != nil { + if _, err := tx.Exec("UPDATE folders SET path = ? WHERE id = ?", correctPath, id); err != nil { return fmt.Errorf("error updating folder path %s to %s: %w", folderPath, correctPath, err) } } diff --git a/pkg/sqlite/migrations/55_postmigrate.go b/pkg/sqlite/migrations/55_postmigrate.go index 1a3a5c5660a..5db79a531f4 100644 --- a/pkg/sqlite/migrations/55_postmigrate.go +++ b/pkg/sqlite/migrations/55_postmigrate.go @@ -31,7 +31,7 @@ func (m *schema55Migrator) migrate(ctx context.Context) error { if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { query := "SELECT DISTINCT `scene_id`, `view_date` FROM `scenes_view_dates`" - rows, err := m.db.Query(query) + rows, err := tx.Query(query) if err != nil { return err } @@ -53,7 +53,7 @@ func (m *schema55Migrator) migrate(ctx context.Context) error { } // convert the timestamp to the correct format - if _, err := m.db.Exec("UPDATE scenes_view_dates SET view_date = ? WHERE view_date = ?", utcTimestamp, viewDate.Timestamp); err != nil { + if _, err := tx.Exec("UPDATE scenes_view_dates SET view_date = ? WHERE view_date = ?", utcTimestamp, viewDate.Timestamp); err != nil { return fmt.Errorf("error correcting view date %s to %s: %w", viewDate.Timestamp, viewDate, err) } } diff --git a/pkg/sqlite/migrations/64_postmigrate.go b/pkg/sqlite/migrations/64_postmigrate.go index 5b0f31a256d..023f7db1d58 100644 --- a/pkg/sqlite/migrations/64_postmigrate.go +++ b/pkg/sqlite/migrations/64_postmigrate.go @@ -35,7 +35,7 @@ func (m *schema64Migrator) migrate(ctx context.Context) error { if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { query := "SELECT DISTINCT `scene_id`, `view_date` FROM `scenes_view_dates`" - rows, err := m.db.Query(query) + rows, err := tx.Query(query) if err != nil { return err } @@ -64,7 +64,7 @@ func (m *schema64Migrator) migrate(ctx context.Context) error { // convert the timestamp to the correct format logger.Debugf("correcting view date %q to UTC date %q for scene %d", viewDate.Timestamp, viewDate.Timestamp.UTC(), id) - r, err := m.db.Exec("UPDATE scenes_view_dates SET view_date = ? WHERE scene_id = ? AND (view_date = ? OR view_date = ?)", utcTimestamp, id, viewDate.Timestamp, viewDate) + r, err := tx.Exec("UPDATE scenes_view_dates SET view_date = ? WHERE scene_id = ? AND (view_date = ? OR view_date = ?)", utcTimestamp, id, viewDate.Timestamp, viewDate) if err != nil { return fmt.Errorf("error correcting view date %s to %s: %w", viewDate.Timestamp, viewDate, err) } From 4ad0241c53c6f8b60d7570c0e10e57270ae4b7bf Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Sun, 22 Sep 2024 14:04:26 +1000 Subject: [PATCH 38/91] Update changelog --- ui/v2.5/src/docs/en/Changelog/v0270.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui/v2.5/src/docs/en/Changelog/v0270.md b/ui/v2.5/src/docs/en/Changelog/v0270.md index 6ac27fc2887..b9e1c7b678e 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0270.md +++ b/ui/v2.5/src/docs/en/Changelog/v0270.md @@ -13,6 +13,7 @@ * Added support for bulk-editing Tags. ([#4925](https://github.com/stashapp/stash/pull/4925)) * Added filter to Scrapers menu. ([#5041](https://github.com/stashapp/stash/pull/5041)) * Added ability to set the location of ssl certificate files. ([#4910](https://github.com/stashapp/stash/pull/4910)) +* Added option to rescan all files in the Scan task. ([#5254](https://github.com/stashapp/stash/pull/5254)) ### 🎨 Improvements * Added button to view sub-studio/sub-tag content on Studio/Tag details pages. ([#5080](https://github.com/stashapp/stash/pull/5080)) @@ -38,10 +39,13 @@ * Anonymise now truncates o- and view history data. ([#5166](https://github.com/stashapp/stash/pull/5166)) * Fixed issue where using mouse wheel on numeric input fields would scroll the window in addition to changing the value. ([#5199](https://github.com/stashapp/stash/pull/5199)) * Fixed issue where some o-dates could not be deleted. ([#4971](https://github.com/stashapp/stash/pull/4971)) +* Fixed handling of symlink zip files. ([#5249](https://github.com/stashapp/stash/pull/5249)) +* Fixed default database backup directory being set to the config file directory instead of the database directory. ([#5250](https://github.com/stashapp/stash/pull/5250)) * Added API key to DASH and HLS manifests. ([#5061](https://github.com/stashapp/stash/pull/5061)) * Query field no longer focused when selecting items in the filter list on touch devices. ([#5204](https://github.com/stashapp/stash/pull/5204)) * Fixed weird scrolling behaviour on Gallery detail page on smaller viewports ([#5205](https://github.com/stashapp/stash/pull/5205)) * Performer popover links now correctly link to the applicable scenes/image/gallery query page instead of always going to scenes. ([#5195](https://github.com/stashapp/stash/pull/5195)) +* Fixed scene player source selector appearing behind the player controls. ([#5229](https://github.com/stashapp/stash/pull/5229)) * Fixed red/green/blue slider values in the Scene Filter panel. ([#5221](https://github.com/stashapp/stash/pull/5221)) * Play button no longer appears on file-less Scenes. ([#5141](https://github.com/stashapp/stash/pull/5141)) * Fixed transgender icon colouring. ([#5090](https://github.com/stashapp/stash/pull/5090)) From 33050f700ee12f3d4c580edde427aa47f734922f Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Sun, 22 Sep 2024 15:24:54 +1000 Subject: [PATCH 39/91] Prevent mouse wheel window scrolling on other number fields (#5283) --- .../Filters/HierarchicalLabelValueFilter.tsx | 4 ++-- .../components/List/Filters/NumberFilter.tsx | 10 ++++------ .../components/List/Filters/PhashFilter.tsx | 4 ++-- .../List/Filters/SelectableFilter.tsx | 4 ++-- ui/v2.5/src/components/List/ListFilter.tsx | 4 ++++ ui/v2.5/src/components/List/Pagination.tsx | 4 ++++ .../Scenes/SceneDetails/SceneGroupTable.tsx | 4 ++-- .../Settings/GeneratePreviewOptions.tsx | 7 +++---- ui/v2.5/src/components/Settings/Inputs.tsx | 4 ++-- .../components/Shared/Rating/RatingNumber.tsx | 2 ++ ui/v2.5/src/utils/form.tsx | 19 +++++++++++++++---- 11 files changed, 42 insertions(+), 24 deletions(-) diff --git a/ui/v2.5/src/components/List/Filters/HierarchicalLabelValueFilter.tsx b/ui/v2.5/src/components/List/Filters/HierarchicalLabelValueFilter.tsx index 71ff92e5e33..8d96a8a8d42 100644 --- a/ui/v2.5/src/components/List/Filters/HierarchicalLabelValueFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/HierarchicalLabelValueFilter.tsx @@ -4,6 +4,7 @@ import { defineMessages, MessageDescriptor, useIntl } from "react-intl"; import { FilterSelect, SelectObject } from "src/components/Shared/Select"; import { Criterion } from "src/models/list-filter/criteria/criterion"; import { IHierarchicalLabelValue } from "src/models/list-filter/types"; +import { NumberField } from "src/utils/form"; interface IHierarchicalLabelValueFilterProps { criterion: Criterion<IHierarchicalLabelValue>; @@ -104,9 +105,8 @@ export const HierarchicalLabelValueFilter: React.FC< {criterion.value.depth !== 0 && ( <Form.Group> - <Form.Control + <NumberField className="btn-secondary" - type="number" placeholder={intl.formatMessage(messages.studio_depth)} onChange={(e) => onDepthChanged(e.target.value ? parseInt(e.target.value, 10) : -1) diff --git a/ui/v2.5/src/components/List/Filters/NumberFilter.tsx b/ui/v2.5/src/components/List/Filters/NumberFilter.tsx index 7aa574a2e21..21120f799d6 100644 --- a/ui/v2.5/src/components/List/Filters/NumberFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/NumberFilter.tsx @@ -4,6 +4,7 @@ import { useIntl } from "react-intl"; import { CriterionModifier } from "../../../core/generated-graphql"; import { INumberValue } from "../../../models/list-filter/types"; import { NumberCriterion } from "../../../models/list-filter/criteria/criterion"; +import { NumberField } from "src/utils/form"; interface IDurationFilterProps { criterion: NumberCriterion; @@ -36,9 +37,8 @@ export const NumberFilter: React.FC<IDurationFilterProps> = ({ ) { equalsControl = ( <Form.Group> - <Form.Control + <NumberField className="btn-secondary" - type="number" onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChanged(e, "value") } @@ -57,9 +57,8 @@ export const NumberFilter: React.FC<IDurationFilterProps> = ({ ) { lowerControl = ( <Form.Group> - <Form.Control + <NumberField className="btn-secondary" - type="number" onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChanged(e, "value") } @@ -78,9 +77,8 @@ export const NumberFilter: React.FC<IDurationFilterProps> = ({ ) { upperControl = ( <Form.Group> - <Form.Control + <NumberField className="btn-secondary" - type="number" onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChanged( e, diff --git a/ui/v2.5/src/components/List/Filters/PhashFilter.tsx b/ui/v2.5/src/components/List/Filters/PhashFilter.tsx index 68eda800280..a42a612871c 100644 --- a/ui/v2.5/src/components/List/Filters/PhashFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/PhashFilter.tsx @@ -4,6 +4,7 @@ import { useIntl } from "react-intl"; import { IPhashDistanceValue } from "../../../models/list-filter/types"; import { Criterion } from "../../../models/list-filter/criteria/criterion"; import { CriterionModifier } from "src/core/generated-graphql"; +import { NumberField } from "src/utils/form"; interface IPhashFilterProps { criterion: Criterion<IPhashDistanceValue>; @@ -49,10 +50,9 @@ export const PhashFilter: React.FC<IPhashFilterProps> = ({ {criterion.modifier !== CriterionModifier.IsNull && criterion.modifier !== CriterionModifier.NotNull && ( <Form.Group> - <Form.Control + <NumberField className="btn-secondary" onChange={distanceChanged} - type="number" value={value ? value.distance : ""} placeholder={intl.formatMessage({ id: "distance" })} /> diff --git a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx index 19d2b70bbf1..e6f8f9fcf98 100644 --- a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx @@ -25,6 +25,7 @@ import { keyboardClickHandler } from "src/utils/keyboard"; import { useDebounce } from "src/hooks/debounce"; import useFocus from "src/utils/focus"; import ScreenUtils from "src/utils/screen"; +import { NumberField } from "src/utils/form"; interface ISelectedItem { item: ILabeledId; @@ -361,9 +362,8 @@ export const HierarchicalObjectsFilter = < {criterion.value.depth !== 0 && ( <Form.Group> - <Form.Control + <NumberField className="btn-secondary" - type="number" placeholder={intl.formatMessage(messages.studio_depth)} onChange={(e) => onDepthChanged(e.target.value ? parseInt(e.target.value, 10) : -1) diff --git a/ui/v2.5/src/components/List/ListFilter.tsx b/ui/v2.5/src/components/List/ListFilter.tsx index 24ea02af1d4..109ac127a85 100644 --- a/ui/v2.5/src/components/List/ListFilter.tsx +++ b/ui/v2.5/src/components/List/ListFilter.tsx @@ -35,6 +35,7 @@ import { FilterButton } from "./Filters/FilterButton"; import { useDebounce } from "src/hooks/debounce"; import { View } from "./views"; import { ClearableInput } from "../Shared/ClearableInput"; +import { useStopWheelScroll } from "src/utils/form"; export function useDebouncedSearchInput( filter: ListFilterModel, @@ -126,6 +127,8 @@ export const PageSizeSelector: React.FC<{ } }, [customPageSizeShowing, perPageFocus]); + useStopWheelScroll(perPageInput); + const pageSizeOptions = useMemo(() => { const ret = PAGE_SIZE_OPTIONS.map((o) => { return { @@ -190,6 +193,7 @@ export const PageSizeSelector: React.FC<{ <Popover id="custom_pagesize_popover"> <Form inline> <InputGroup> + {/* can't use NumberField because of the ref */} <Form.Control type="number" min={1} diff --git a/ui/v2.5/src/components/List/Pagination.tsx b/ui/v2.5/src/components/List/Pagination.tsx index ab151188261..6ee08680b45 100644 --- a/ui/v2.5/src/components/List/Pagination.tsx +++ b/ui/v2.5/src/components/List/Pagination.tsx @@ -12,6 +12,7 @@ import { FormattedMessage, FormattedNumber, useIntl } from "react-intl"; import useFocus from "src/utils/focus"; import { Icon } from "../Shared/Icon"; import { faCheck, faChevronDown } from "@fortawesome/free-solid-svg-icons"; +import { useStopWheelScroll } from "src/utils/form"; const PageCount: React.FC<{ totalPages: number; @@ -32,6 +33,8 @@ const PageCount: React.FC<{ } }, [showSelectPage, pageFocus]); + useStopWheelScroll(pageInput); + const pageOptions = useMemo(() => { const maxPagesToShow = 10; const min = Math.max(1, currentPage - maxPagesToShow / 2); @@ -98,6 +101,7 @@ const PageCount: React.FC<{ <Popover id="select_page_popover"> <Form inline> <InputGroup> + {/* can't use NumberField because of the ref */} <Form.Control type="number" min={1} diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneGroupTable.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneGroupTable.tsx index ed721ab4bcb..282ff6740ef 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneGroupTable.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneGroupTable.tsx @@ -4,6 +4,7 @@ import * as GQL from "src/core/generated-graphql"; import { Form, Row, Col } from "react-bootstrap"; import { Group, GroupSelect } from "src/components/Groups/GroupSelect"; import cx from "classnames"; +import { NumberField } from "src/utils/form"; export type GroupSceneIndexMap = Map<string, number | undefined>; @@ -92,9 +93,8 @@ export const SceneGroupTable: React.FC<IProps> = (props) => { /> </Col> <Col xs={3}> - <Form.Control + <NumberField className="text-input" - type="number" value={m.scene_index ?? ""} onChange={(e: React.ChangeEvent<HTMLInputElement>) => { updateFieldChanged( diff --git a/ui/v2.5/src/components/Settings/GeneratePreviewOptions.tsx b/ui/v2.5/src/components/Settings/GeneratePreviewOptions.tsx index bb4cba77d2a..c7987add6f7 100644 --- a/ui/v2.5/src/components/Settings/GeneratePreviewOptions.tsx +++ b/ui/v2.5/src/components/Settings/GeneratePreviewOptions.tsx @@ -2,6 +2,7 @@ import React from "react"; import { useIntl } from "react-intl"; import { Form } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; +import { NumberField } from "src/utils/form"; export type VideoPreviewSettingsInput = Pick< GQL.ConfigGeneralInput, @@ -44,9 +45,8 @@ export const VideoPreviewInput: React.FC<IVideoPreviewInput> = ({ id: "dialogs.scene_gen.preview_seg_count_head", })} </h6> - <Form.Control + <NumberField className="text-input" - type="number" value={previewSegments?.toString() ?? 1} min={1} onChange={(e: React.ChangeEvent<HTMLInputElement>) => @@ -71,9 +71,8 @@ export const VideoPreviewInput: React.FC<IVideoPreviewInput> = ({ id: "dialogs.scene_gen.preview_seg_duration_head", })} </h6> - <Form.Control + <NumberField className="text-input" - type="number" value={previewSegmentDuration?.toString() ?? 0} onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ diff --git a/ui/v2.5/src/components/Settings/Inputs.tsx b/ui/v2.5/src/components/Settings/Inputs.tsx index 073be4e11d9..b84232d1f63 100644 --- a/ui/v2.5/src/components/Settings/Inputs.tsx +++ b/ui/v2.5/src/components/Settings/Inputs.tsx @@ -6,6 +6,7 @@ import { Icon } from "../Shared/Icon"; import { StringListInput } from "../Shared/StringListInput"; import { PatchComponent } from "src/patch"; import { useSettings, useSettingsOptional } from "./context"; +import { NumberField } from "src/utils/form"; interface ISetting { id?: string; @@ -484,9 +485,8 @@ export const NumberSetting: React.FC<INumberSetting> = PatchComponent( <ModalSetting<number> {...props} renderField={(value, setValue) => ( - <Form.Control + <NumberField className="text-input" - type="number" value={value ?? 0} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setValue(Number.parseInt(e.currentTarget.value || "0", 10)) diff --git a/ui/v2.5/src/components/Shared/Rating/RatingNumber.tsx b/ui/v2.5/src/components/Shared/Rating/RatingNumber.tsx index 14cd701d13a..69195ff407e 100644 --- a/ui/v2.5/src/components/Shared/Rating/RatingNumber.tsx +++ b/ui/v2.5/src/components/Shared/Rating/RatingNumber.tsx @@ -3,6 +3,7 @@ import { Button } from "react-bootstrap"; import { Icon } from "../Icon"; import { faPencil, faStar } from "@fortawesome/free-solid-svg-icons"; import { useFocusOnce } from "src/utils/focus"; +import { useStopWheelScroll } from "src/utils/form"; export interface IRatingNumberProps { value: number | null; @@ -26,6 +27,7 @@ export const RatingNumber: React.FC<IRatingNumberProps> = ( const showTextField = !props.disabled && (editing || !props.clickToRate); const [ratingRef] = useFocusOnce(editing, true); + useStopWheelScroll(ratingRef); const effectiveValue = editing ? valueStage : props.value; diff --git a/ui/v2.5/src/utils/form.tsx b/ui/v2.5/src/utils/form.tsx index 40fedc7bf20..45b0aa86f41 100644 --- a/ui/v2.5/src/utils/form.tsx +++ b/ui/v2.5/src/utils/form.tsx @@ -48,6 +48,7 @@ export function renderLabel(options: { // the mouse wheel will change the field value _and_ scroll the window. // This hook prevents the propagation that causes the window to scroll. export function useStopWheelScroll(ref: React.RefObject<HTMLElement>) { + // removed the dependency array because the underlying ref value may change useEffect(() => { const { current } = ref; @@ -66,17 +67,18 @@ export function useStopWheelScroll(ref: React.RefObject<HTMLElement>) { current.removeEventListener("wheel", stopWheelScroll); } }; - }, [ref]); + }); } -const InputField: React.FC< +// NumberField is a wrapper around Form.Control that prevents wheel events from scrolling the window. +export const NumberField: React.FC< InputHTMLAttributes<HTMLInputElement> & FormControlProps > = (props) => { const inputRef = useRef<HTMLInputElement>(null); useStopWheelScroll(inputRef); - return <Form.Control {...props} ref={inputRef} />; + return <Form.Control {...props} type="number" ref={inputRef} />; }; type Formik<V extends FormikValues> = ReturnType<typeof useFormik<V>>; @@ -134,9 +136,18 @@ export function formikUtils<V extends FormikValues>( isInvalid={!!error} /> ); + } else if (type === "number") { + <NumberField + type={type} + className="text-input" + placeholder={placeholder} + {...formikProps} + value={value} + isInvalid={!!error} + />; } else { control = ( - <InputField + <Form.Control type={type} className="text-input" placeholder={placeholder} From 82f4a8f671f5523962003cf023c479e70646418b Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 23 Sep 2024 10:14:29 +1000 Subject: [PATCH 40/91] Fix number field render --- ui/v2.5/src/utils/form.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/ui/v2.5/src/utils/form.tsx b/ui/v2.5/src/utils/form.tsx index 45b0aa86f41..f518d570079 100644 --- a/ui/v2.5/src/utils/form.tsx +++ b/ui/v2.5/src/utils/form.tsx @@ -137,14 +137,16 @@ export function formikUtils<V extends FormikValues>( /> ); } else if (type === "number") { - <NumberField - type={type} - className="text-input" - placeholder={placeholder} - {...formikProps} - value={value} - isInvalid={!!error} - />; + control = ( + <NumberField + type={type} + className="text-input" + placeholder={placeholder} + {...formikProps} + value={value} + isInvalid={!!error} + /> + ); } else { control = ( <Form.Control From a20fbe33c014f17ded104702567a18ea1da2458b Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 23 Sep 2024 10:28:43 +1000 Subject: [PATCH 41/91] Fix tag select breaking layout --- ui/v2.5/src/components/Tags/TagSelect.tsx | 14 +++++++------- ui/v2.5/src/hooks/tagsEdit.tsx | 11 +++-------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/ui/v2.5/src/components/Tags/TagSelect.tsx b/ui/v2.5/src/components/Tags/TagSelect.tsx index 64a58d1217b..18f667cba88 100644 --- a/ui/v2.5/src/components/Tags/TagSelect.tsx +++ b/ui/v2.5/src/components/Tags/TagSelect.tsx @@ -54,13 +54,13 @@ function sortTagsByRelevance(input: string, tags: FindTagsResult) { const tagSelectSort = PatchFunction("TagSelect.sort", sortTagsByRelevance); -const _TagSelect: React.FC< - IFilterProps & - IFilterValueProps<Tag> & { - hoverPlacement?: Placement; - excludeIds?: string[]; - } -> = (props) => { +export type TagSelectProps = IFilterProps & + IFilterValueProps<Tag> & { + hoverPlacement?: Placement; + excludeIds?: string[]; + }; + +const _TagSelect: React.FC<TagSelectProps> = (props) => { const [createTag] = useTagCreate(); const { configuration } = React.useContext(ConfigurationContext); diff --git a/ui/v2.5/src/hooks/tagsEdit.tsx b/ui/v2.5/src/hooks/tagsEdit.tsx index e4458291180..73d6c951d95 100644 --- a/ui/v2.5/src/hooks/tagsEdit.tsx +++ b/ui/v2.5/src/hooks/tagsEdit.tsx @@ -1,7 +1,7 @@ import * as GQL from "src/core/generated-graphql"; import { useTagCreate } from "src/core/StashService"; import { useEffect, useState } from "react"; -import { Tag, TagSelect } from "src/components/Tags/TagSelect"; +import { Tag, TagSelect, TagSelectProps } from "src/components/Tags/TagSelect"; import { useToast } from "src/hooks/Toast"; import { useIntl } from "react-intl"; import { Badge, Button } from "react-bootstrap"; @@ -125,15 +125,10 @@ export function useTagsEdit( return ret; } - function tagsControl() { + function tagsControl(props?: TagSelectProps) { return ( <> - <TagSelect - menuPortalTarget={document.body} - isMulti - onSelect={onSetTags} - values={tags} - /> + <TagSelect isMulti onSelect={onSetTags} values={tags} {...props} /> {renderNewTags()} </> ); From c45ae068fc5cd9bd355e92363bc3b89289b7d35f Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 23 Sep 2024 11:56:31 +1000 Subject: [PATCH 42/91] Weblate translation update (#5289) * Translated using Weblate (Korean) Currently translated at 100.0% (1174 of 1174 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/ * Translated using Weblate (German) Currently translated at 87.2% (1024 of 1174 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/de/ * Translated using Weblate (Estonian) Currently translated at 100.0% (1174 of 1174 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/et/ --------- Co-authored-by: yec <yec@users.noreply.translate.codeberg.org> Co-authored-by: TheJojonas <TheJojonas@users.noreply.translate.codeberg.org> Co-authored-by: Zesty6249 <Zesty6249@users.noreply.translate.codeberg.org> --- ui/v2.5/src/locales/de-DE.json | 4 +- ui/v2.5/src/locales/et-EE.json | 100 +++++++++++++++++++++++++++++---- ui/v2.5/src/locales/ko-KR.json | 61 ++++++++++++++------ 3 files changed, 136 insertions(+), 29 deletions(-) diff --git a/ui/v2.5/src/locales/de-DE.json b/ui/v2.5/src/locales/de-DE.json index 4170de48642..40aca3495f9 100644 --- a/ui/v2.5/src/locales/de-DE.json +++ b/ui/v2.5/src/locales/de-DE.json @@ -133,7 +133,7 @@ "copy_to_clipboard": "In die Zwischenablage kopieren", "view_history": "Verlauf anzeigen", "add_o": "O hinzufügen", - "add_play": "abspielen hinzufügen", + "add_play": "Abspielen hinzufügen", "clear_date_data": "Datum-Eintrag löschen", "assign_stashid_to_parent_studio": "Stash ID existierendem Elternstudio hinzufügen und Metadaten aktualisieren", "set_cover": "Als Titelbild setzen", @@ -494,7 +494,7 @@ "field_behaviour": "{strategy} {field}", "field_options": "Feldoptionen", "heading": "Identifizieren", - "identifying_from_paths": "Identifizieren von Szenen durch den folgenden Pfad", + "identifying_from_paths": "Identifiziere Szenen von folgenden Pfad", "identifying_scenes": "Identifiziere {num} {scene}", "include_male_performers": "Männliche Darsteller einbeziehen", "set_cover_images": "Titelbild festlegen", diff --git a/ui/v2.5/src/locales/et-EE.json b/ui/v2.5/src/locales/et-EE.json index af04aad9de4..fade224d1f9 100644 --- a/ui/v2.5/src/locales/et-EE.json +++ b/ui/v2.5/src/locales/et-EE.json @@ -129,7 +129,19 @@ "optimise_database": "Optimiseeri Andmebaasi", "clean_generated": "Puhasta genereeritud faile", "choose_date": "Vali kuupäev", - "copy_to_clipboard": "Kopeeri lõikelauale" + "copy_to_clipboard": "Kopeeri lõikelauale", + "reset_cover": "Lähtesta Vaikimise Kaanepilt", + "reset_play_duration": "Lähtesta vaatamise kestus", + "reset_resume_time": "Lähtesta jätkamise aeg", + "set_cover": "Märgi Kaanepildiks", + "add_sub_groups": "Lisa Alam-grupid", + "remove_date": "Eemalda kuupäev", + "add_manual_date": "Lisa manuaalne kuupäev", + "add_o": "Lisa O", + "add_play": "Lisa mängimine", + "clear_date_data": "Eemalda kuupäeva andmed", + "view_history": "Vaata ajalugu", + "remove_from_containing_group": "Eemalda Grupist" }, "actions_name": "Tegevused", "age": "Vanus", @@ -329,6 +341,18 @@ "desc": "Edasijõudnutele: Lisaargumendid mida edastada ffmpegi väljundväljale video genereerimisel.", "heading": "FFmpeg Transkodeerimise Väljundargumendid" } + }, + "ffprobe_path": { + "heading": "FFprobe Käivitava Faili Tee", + "description": "ffprobe käivitatava faili tee (mitte ainult kaust). Kui see on tühi, lahendatakse ffprobe keskkonnast $PATHi, konfiguratsioonikataloogi või $HOME/.stash kaudu" + }, + "ffmpeg_path": { + "heading": "FFmpegi Käivitava Faili Tee", + "description": "ffmpegi käivitatava faili tee (mitte ainult kaust). Kui see on tühi, lahendatakse ffmpeg keskkonnast $PATHi, konfiguratsioonikataloogi või $HOME/.stash kaudu" + }, + "download_ffmpeg": { + "description": "Laadib FFmpegi alla konfiguratsioonikataloogi ja tühjendab ffmpegi ja ffprobe'i teed konfiguratsioonikataloogist lahendamiseks.", + "heading": "Lae alla FFmpeg" } }, "funscript_heatmap_draw_range": "Kaasa vahemik genereeritud kuumkaartidel", @@ -508,7 +532,20 @@ "generate_clip_previews_during_scan": "Loo pildiklippide eelvaateid", "generate_sprites_during_scan_tooltip": "Pildivalik video mängija all lihtsaks navigeerimiseks.", "optimise_database": "Proovige jõudlust parandada, analüüsides ja seejärel kogu andmebaasifaili uuesti üles ehitades.", - "optimise_database_warning": "Hoiatus: selle ülesande täitmise ajal nurjuvad kõik andmebaasi muutvad toimingud ja sõltuvalt teie andmebaasi suurusest võib selle lõpuleviimiseks kuluda mitu minutit. See nõuab ka minimaalselt nii palju vaba kettaruumi, kui suur on teie andmebaas, kuid soovitatav on 1,5x." + "optimise_database_warning": "Hoiatus: selle ülesande täitmise ajal nurjuvad kõik andmebaasi muutvad toimingud ja sõltuvalt teie andmebaasi suurusest võib selle lõpuleviimiseks kuluda mitu minutit. See nõuab ka minimaalselt nii palju vaba kettaruumi, kui suur on teie andmebaas, kuid soovitatav on 1,5x.", + "clean_generated": { + "blob_files": "Blobi failid", + "markers": "Markeri Eelvaated", + "image_thumbnails": "Piltide Pisipildid", + "image_thumbnails_desc": "Piltide pisipildid ning klipid", + "previews": "Stseenide Eelvaated", + "previews_desc": "Stseenide eelvaated ja pisipildid", + "sprites": "Stseeni Spraidid", + "transcodes": "Stseeni Transkodeeringud", + "description": "Eemaldab genereeritud failid ilma vastava andmebaasi kirjeta." + }, + "rescan": "Skaneeri failid uuesti", + "rescan_tooltip": "Kontrollige uuesti kõiki teel olevaid faile. Kasutatakse faili metaandmete värskendamiseks ja ZIP-failide uuesti skannimiseks." }, "tools": { "scene_duplicate_checker": "Duplikaatstseenide Kontroll", @@ -764,7 +801,8 @@ "performers": "{count, plural, one {Näitleja} other {Näitlejat}}", "scenes": "{count, plural, one {Stseen} other {Stseeni}}", "studios": "{count, plural, one {Stuudio} other {Stuudiot}}", - "tags": "{count, plural, one {Silt} other {Silti}}" + "tags": "{count, plural, one {Silt} other {Silti}}", + "groups": "{count, plural, one {Grupp} other {Grupid}}" }, "country": "Riik", "cover_image": "Kaanepilt", @@ -900,7 +938,8 @@ "video_previews": "Eelvaated", "video_previews_tooltip": "Video eelvaated, mis esitatakse kursorit stseeni kohal hoides", "clip_previews": "Pildiklipi Eelvaated", - "phash_tooltip": "De-dubleerimiseks ja stseeni tuvastamiseks" + "phash_tooltip": "De-dubleerimiseks ja stseeni tuvastamiseks", + "image_thumbnails": "Piltide Pisipildid" }, "scenes_found": "Leiti {count} stseeni", "scrape_entity_query": "{entity_type} Kraapija Päring", @@ -909,7 +948,9 @@ "scrape_results_scraped": "Kraabitud", "set_image_url_title": "Pildi URL", "unsaved_changes": "Salvestamata muudatused. Kas soovid kindlasti lahkuda?", - "performers_found": "Leiti {count} esinejat" + "performers_found": "Leiti {count} esinejat", + "clear_o_history_confirm": "Kas oled kindel, et soovid puhastada O ajaloo?", + "clear_play_history_confirm": "Kas oled kindel, et soovid puhastada vaatamise ajaloo?" }, "dimensions": "Dimensioonid", "director": "Režissöör", @@ -974,7 +1015,10 @@ "image_index_greater_than_zero": "Pildi indeks peab olema suurem kui 0", "lazy_component_error_help": "Kui uuendasid Stashi hiljuti, palun taaslae leht või tühjenda oma brauseri cache.", "something_went_wrong": "Midagi läks valesti.", - "loading_type": "Viga {type} laadimisel" + "loading_type": "Viga {type} laadimisel", + "header": "Viga", + "invalid_javascript_string": "Vigane JavaScripti kood: {error}", + "invalid_json_string": "Vigane JSON-string: {error}" }, "ethnicity": "Rahvus", "existing_value": "eksisteeriv väärtus", @@ -1062,7 +1106,8 @@ "play_count": "Esituste Arv", "play_duration": "Esitamisaeg", "stream": "Striim", - "video_codec": "Video Koodek" + "video_codec": "Video Koodek", + "o_count": "O Arv" }, "megabits_per_second": "{value} mbps", "metadata": "Metaandmed", @@ -1076,7 +1121,8 @@ "first": "Esimene", "last": "Viimane", "next": "Järgmine", - "previous": "Eelmine" + "previous": "Eelmine", + "current_total": "{current} {total}st" }, "parent_of": "{children} vanem-silt", "parent_studios": "Vanem-stuudiod", @@ -1230,7 +1276,9 @@ "support_us": "Toeta meid", "thanks_for_trying_stash": "Aitäh Stashi proovimise eest!", "welcome_contrib": "Ootame ka panust koodi (veaparandused, täiustused ja uued funktsioonid), testimise, veaaruannete, parendus- ja funktsioonitaotluste ning kasutajatoe kujul. Üksikasjad leiad rakendusesisese juhendi jaotisest Contribution.", - "your_system_has_been_created": "Edukas! Su süsteem on loodud!" + "your_system_has_been_created": "Edukas! Su süsteem on loodud!", + "download_ffmpeg": "Lae ffmpeg alla", + "missing_ffmpeg": "Teil puudub nõutav binaarfail <code>ffmpeg</code>. Saate selle automaatselt oma konfiguratsioonikataloogi alla laadida, märkides alloleva kasti. Teise võimalusena saate süsteemiseadetes anda teed binaarfailidele <code>ffmpeg</code> ja <code>ffprobe</code>. Stashi toimimiseks peavad need kahendfailid olemas olema." }, "welcome": { "config_path_logic_explained": "Stash püüab esmalt leida oma konfiguratsioonifaili (<code>config.yml</code>) praegusest töökataloogist ja kui ta seda sealt ei leia, läheb tagasi kausta <code>{fallback_path}</code>. Samuti saad panna Stashi lugema konkreetsest konfiguratsioonifailist, käivitades selle suvanditega <code>-c '<konfiguratsioonifaili tee>'</code> või <code>--config '<konfiguratsioonifaili tee>'</code>.", @@ -1355,7 +1403,8 @@ "add_source": "Lisa Allikas", "check_for_updates": "Kontrolli Uuendusi", "hide_unselected": "Peida valimata", - "installed_version": "Installitud Versioon" + "installed_version": "Installitud Versioon", + "no_upgradable": "Uuendatavaid pakette ei leitud" }, "penis_length_cm": "Peenise Pikkus (cm)", "parent_studio": "Vanemstuudio", @@ -1412,5 +1461,34 @@ "subsidiary_studio_count": "Alamstuudiote Arv", "tag_parent_tooltip": "Sellel on vanemamärked", "tag_sub_tag_tooltip": "Sellel on alammärked", - "time": "Aeg" + "time": "Aeg", + "o_history": "O Ajalugu", + "containing_group_count": "Sisalduvate Rühmade Arv", + "containing_groups": "Sisalduvad Rühmad", + "group": "Grupp", + "last_o_at": "Viimane O", + "odate_recorded_no": "O Kuupäevi Pole Salvestatud", + "play_history": "Vaatamise Ajalugu", + "playdate_recorded_no": "Vaatamise Kuupäevi Pole Salvestatud", + "studio_count": "Stuudiote Arv", + "studio_tags": "Stuudio Sildid", + "unknown_date": "Teadmata Kuupäev", + "connection_monitor": { + "websocket_connection_failed": "Websocketiga ei saa ühendust luua: vaadake üksikasju brauseri konsoolist", + "websocket_connection_reestablished": "Websocketi ühendus taastatud" + }, + "containing_group": "Sisaldab Rühma", + "group_count": "Gruppide Arv", + "group_scene_number": "Stseeni Number", + "groups": "Grupid", + "history": "Ajalugu", + "include_sub_group_content": "Sisalda alamgruppide sisu", + "sub_group": "Alamgrupp", + "sub_group_count": "Alamgruppide Arv", + "sub_group_of": "{parent}i Alamgrupp", + "sub_group_order": "Alamgruppide Järjekord", + "sub_groups": "Alamgrupid", + "include_sub_tag_content": "Sisalda alamsiltide sisu", + "include_sub_studio_content": "Sisalda alamstuudiote sisu", + "o_count": "O Arv" } diff --git a/ui/v2.5/src/locales/ko-KR.json b/ui/v2.5/src/locales/ko-KR.json index 3433d62df5a..524a35ab364 100644 --- a/ui/v2.5/src/locales/ko-KR.json +++ b/ui/v2.5/src/locales/ko-KR.json @@ -135,7 +135,13 @@ "copy_to_clipboard": "클립보드에 복사", "reload": "새로고침", "remove_date": "날짜 삭제", - "view_history": "기록 보기" + "view_history": "기록 보기", + "set_cover": "커버로 설정", + "reset_cover": "커버를 기본값으로 되돌리기", + "reset_play_duration": "재생 시간 초기화", + "reset_resume_time": "마지막 재생 위치 초기화", + "add_sub_groups": "서브그룹 추가", + "remove_from_containing_group": "그룹에서 제거" }, "actions_name": "액션", "age": "나이", @@ -468,7 +474,7 @@ }, "generate_clip_previews_during_scan": "이미지 클립 미리보기 생성하기", "generate_desc": "이미지, 스프라이트, 비디오, vtt 등 파일을 생성합니다.", - "generate_phashes_during_scan": "컨텐츠 해쉬 값 생성", + "generate_phashes_during_scan": "컨텐츠 해시 값 생성", "generate_phashes_during_scan_tooltip": "중복된 파일 확인과 영상 식별에 사용됩니다.", "generate_previews_during_scan": "움직이는 이미지 미리보기 생성", "generate_previews_during_scan_tooltip": "애니메이션(webp) 미리보기 또한 생성합니다. 영상/마커 월의 미리보기 유형이 '애니메이션 이미지'로 설정된 경우에만 필요합니다. '애니메이션 미리보기'는 '비디오 미리보기'보다 CPU를 덜 사용하지만, '비디오 미리보기'에 추가적으로 '애니메이션 미리보기'가 생성되기 때문에 파일 크기가 커집니다.", @@ -542,7 +548,9 @@ }, "generate_sprites_during_scan_tooltip": "영상에서 원하는 위치를 찾기 쉽게 하기 위해, 비디오 플레이어 아래에 표시되는 이미지들의 모음입니다.", "optimise_database_warning": "주의: 이 작업이 수행되는 동안, 데이터베이스를 수정하는 다른 모든 작업들은 실패할 것이고, 데이터베이스의 크기에 따라 작업 종료까지 몇 분이 걸릴 수 있습니다. 그리고 이 작업은 적어도 데이터베이스 크기만큼의 디스크 공간이 필요한데, 데이터베이스 크기의 1.5배의 디스크 공간을 확보하는 것을 권장합니다.", - "optimise_database": "모든 데이터베이스 파일을 분석하고 다시 만듦으로써, 성능을 향상시키려고 시도합니다." + "optimise_database": "모든 데이터베이스 파일을 분석하고 다시 만듦으로써, 성능을 향상시키려고 시도합니다.", + "rescan_tooltip": "경로 내의 모든 파일을 재스캔합니다. 파일 메타데이터를 강제 업데이트하고 zip 파일들을 재스캔하기 위해 사용됩니다.", + "rescan": "파일 재스캔" }, "tools": { "scene_duplicate_checker": "영상 중복 체크 도구", @@ -628,7 +636,7 @@ } }, "funscript_offset": { - "description": "대화형 스크립트 재생의 시간 오프셋(밀리초)입니다.", + "description": "인터랙티브 스크립트 재생의 시간 오프셋(밀리초)입니다.", "heading": "Funscript 오프셋 (단위: 밀리초)" }, "handy_connection": { @@ -642,7 +650,7 @@ "sync": "동기화" }, "handy_connection_key": { - "description": "대화형 영상에 사용할 수 있는 Handy 연결 키입니다. 이 키를 설정하면 현재 장면 정보를 handyfeeling.com과 공유할 수 있습니다", + "description": "인터랙티브 영상에 사용할 수 있는 Handy 연결 키입니다. 이 키를 설정하면 현재 장면 정보를 handyfeeling.com과 공유할 수 있습니다", "heading": "Handy 연결 키" }, "image_lightbox": { @@ -666,7 +674,7 @@ } } }, - "interactive_options": "상호작용 옵션", + "interactive_options": "인터랙티브 설정", "language": { "heading": "언어" }, @@ -708,7 +716,7 @@ "scene_player": { "heading": "영상 플레이어", "options": { - "always_start_from_beginning": "항상 처음부터 비디오 시작하기", + "always_start_from_beginning": "항상 처음부터 비디오 시작", "auto_start_video": "비디오 자동 재생", "auto_start_video_on_play_selected": { "description": "대기열, 또는 '영상' 페이지에서 (랜덤)선택한 영상을 자동 재생", @@ -725,7 +733,7 @@ "heading": "VR 태그" }, "enable_chromecast": "크롬캐스트 활성화", - "disable_mobile_media_auto_rotate": "모바일 환경에서 전체화면했을 때 자동 방향 회전 비활성화하기", + "disable_mobile_media_auto_rotate": "모바일 환경에서 전체화면 시 자동 방향 회전 비활성화", "show_ab_loop_controls": "구간반복 기능 활성화" } }, @@ -798,7 +806,8 @@ "performers": "{count, plural, one {배우} other {배우들}}", "scenes": "{count, plural, one {영상} other {영상들}}", "studios": "{count, plural, one {스튜디오} other {스튜디오들}}", - "tags": "{count, plural, one {태그} other {태그들}}" + "tags": "{count, plural, one {태그} other {태그들}}", + "groups": "{count, plural, one {그룹} other {그룹들}}" }, "country": "국적", "cover_image": "커버 이미지", @@ -897,7 +906,7 @@ "force_transcodes_tooltip": "기본적으로 트랜스코드는 비디오 파일이 브라우저에서 지원되지 않는 경우에만 생성됩니다. 이 옵션을 선택하면 비디오 파일이 브라우저에서 지원되는 것으로 보이는 경우에도 트랜스코드가 생성됩니다.", "image_previews": "움직이는 이미지 미리보기", "image_previews_tooltip": "애니메이션(webp) 미리보기도 생성합니다. 영상/마커 월 미리보기 유형이 '애니메이션 이미지'로 설정된 경우에만 필요합니다. '비디오 미리보기'보다 CPU를 덜 사용하지만, '비디오 미리보기'에 추가적으로 생성되기 때문에 파일 크기가 커집니다.", - "interactive_heatmap_speed": "대화형 영상을 위한 히트맵 및 스피드 생성", + "interactive_heatmap_speed": "인터랙티브 영상을 위한 히트맵 및 스피드 생성", "marker_image_previews": "마커 움직이는 이미지 미리보기", "marker_image_previews_tooltip": "애니메이션(webp) 미리보기도 생성합니다. 영상/마커 월 미리보기 유형이 '애니메이션 이미지'로 설정된 경우에만 필요합니다. '비디오 미리보기'보다 CPU를 덜 사용하지만, '비디오 미리보기'에 추가적으로 생성되기 때문에 파일 크기가 커집니다.", "marker_screenshots": "마커 스크린샷", @@ -1012,7 +1021,9 @@ "lazy_component_error_help": "만약 최근에 Stash를 업그레이드했다면, 웹페이지를 새로고침하거나 브라우저 캐시를 삭제해주세요.", "something_went_wrong": "오류가 발생했습니다.", "header": "오류", - "loading_type": "{type}을(를) 로딩하는 중 오류가 발생했습니다" + "loading_type": "{type}을(를) 로딩하는 중 오류가 발생했습니다", + "invalid_javascript_string": "유효하지 않은 자바스크립트 코드입니다: {error}", + "invalid_json_string": "유효하지 않은 JSON 문자열입니다: {error}" }, "ethnicity": "인종", "existing_value": "존재하는 값", @@ -1023,7 +1034,7 @@ "file": "파일", "file_count": "파일 개수", "file_info": "파일 정보", - "file_mod_time": "파일 변경 시간", + "file_mod_time": "파일 변경 시각", "files": "파일", "files_amount": "{value} 파일", "filesize": "파일 크기", @@ -1075,7 +1086,7 @@ "include_sub_studios": "자회사 스튜디오 포함", "include_sub_tags": "하위 태그 포함", "instagram": "인스타그램", - "interactive": "인터렉티브", + "interactive": "인터랙티브", "interactive_speed": "인터랙티브 속도", "isMissing": "데이터 누락됨", "last_played_at": "마지막 재생 날짜", @@ -1115,7 +1126,8 @@ "first": "처음", "last": "마지막", "next": "다음", - "previous": "이전" + "previous": "이전", + "current_total": "{total} 중 {current}" }, "parent_of": "{children}의 상위 태그", "parent_studios": "모회사 스튜디오", @@ -1333,7 +1345,7 @@ "added_entity": "{singularEntity}을(를) 추가했습니다", "added_generation_job_to_queue": "생성 작업을 대기열에 추가했습니다", "created_entity": "{entity}를 생성했습니다", - "default_filter_set": "기본 필터 셋", + "default_filter_set": "기본 필터가 설정되었습니다", "delete_past_tense": "{count, plural, one {{singularEntity}} other {{pluralEntity}}}이(가) 삭제되었습니다", "generating_screenshot": "스크린샷을 생성하는 중…", "image_index_too_large": "오류: 이미지 번호가 갤러리의 이미지 개수보다 큽니다", @@ -1461,5 +1473,22 @@ "distance": "거리", "studio_and_parent": "스튜디오 & 모회사", "tag_parent_tooltip": "상위 태그 존재 여부", - "tag_sub_tag_tooltip": "하위 태그 존재 여부" + "tag_sub_tag_tooltip": "하위 태그 존재 여부", + "group": "그룹", + "group_count": "그룹 개수", + "group_scene_number": "영상 번호", + "groups": "그룹들", + "studio_tags": "스튜디오 태그", + "containing_groups": "그룹들 포함", + "containing_group_count": "그룹 개수 포함", + "studio_count": "스튜디오 개수", + "containing_group": "그룹 포함", + "include_sub_group_content": "서브그룹 컨텐츠 포함", + "sub_group_count": "서브그룹 개수", + "sub_group_order": "서브그룹 순서", + "sub_groups": "서브그룹들", + "sub_group": "서브그룹", + "sub_group_of": "{parent}의 서브그룹", + "include_sub_studio_content": "서브스튜디오 컨텐츠 포함", + "include_sub_tag_content": "서브태그 컨텐츠 포함" } From cef5b46f930699147400750ce7d80981b5bc557a Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 25 Sep 2024 14:04:42 +1000 Subject: [PATCH 43/91] Fix merge dialog select boxes display issue (#5299) --- ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx | 2 ++ ui/v2.5/src/components/Tags/TagDetails/TagMergeDialog.tsx | 2 ++ ui/v2.5/src/docs/en/Changelog/v0270.md | 1 + 3 files changed, 5 insertions(+) diff --git a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx index c019abceaca..6fab0a150bb 100644 --- a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx @@ -771,6 +771,7 @@ export const SceneMergeModal: React.FC<ISceneMergeModalProps> = ({ isMulti onSelect={(items) => setSourceScenes(items)} values={sourceScenes} + menuPortalTarget={document.body} /> </Col> </Form.Group> @@ -803,6 +804,7 @@ export const SceneMergeModal: React.FC<ISceneMergeModalProps> = ({ <SceneSelect onSelect={(items) => setDestScene(items)} values={destScene} + menuPortalTarget={document.body} /> </Col> </Form.Group> diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagMergeDialog.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagMergeDialog.tsx index cc4051c5ae8..5d0bf80b834 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagMergeDialog.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagMergeDialog.tsx @@ -106,6 +106,7 @@ export const TagMergeModal: React.FC<ITagMergeModalProps> = ({ onSelect={(items) => setSrc(items)} values={src} excludeIds={tag?.id ? [tag.id] : []} + menuPortalTarget={document.body} /> </Col> </Form.Group> @@ -129,6 +130,7 @@ export const TagMergeModal: React.FC<ITagMergeModalProps> = ({ onSelect={(items) => setDest(items[0])} values={dest ? [dest] : undefined} excludeIds={tag?.id ? [tag.id] : []} + menuPortalTarget={document.body} /> </Col> </Form.Group> diff --git a/ui/v2.5/src/docs/en/Changelog/v0270.md b/ui/v2.5/src/docs/en/Changelog/v0270.md index b9e1c7b678e..cb8751e75af 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0270.md +++ b/ui/v2.5/src/docs/en/Changelog/v0270.md @@ -29,6 +29,7 @@ * Scene Player now allows interacting with the controls before playing video, and errors no longer prevent interacting with the Scene Player. ([#5145](https://github.com/stashapp/stash/pull/5145)) ### 🐛 Bug fixes +* **[0.27.1]** Fixed dropdowns not displaying correctly in the merge dialogs. * Fixed videos and images having incorrect dimensions when the orientation flag is set to a non-default value during scan. ([#5188](https://github.com/stashapp/stash/pull/5188), [#5189](https://github.com/stashapp/stash/pull/5189)) * Fixed mp4 videos being incorrectly transcoded when the file has opus audio codec. ([#5030](https://github.com/stashapp/stash/pull/5030)) * Fixed o-history being imported as view-history when importing from JSON. ([#5127](https://github.com/stashapp/stash/pull/5127)) From 30fc2d120938e9d215791c18a15914426ebf34e4 Mon Sep 17 00:00:00 2001 From: CJ <72030708+cj12312021@users.noreply.github.com> Date: Mon, 30 Sep 2024 00:12:22 -0500 Subject: [PATCH 44/91] fix link menu issues (#5310) --- .../components/Shared/ExternalLinksButton.tsx | 33 ++++++++----------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/ui/v2.5/src/components/Shared/ExternalLinksButton.tsx b/ui/v2.5/src/components/Shared/ExternalLinksButton.tsx index 00b318c65f3..7ecea9b30c5 100644 --- a/ui/v2.5/src/components/Shared/ExternalLinksButton.tsx +++ b/ui/v2.5/src/components/Shared/ExternalLinksButton.tsx @@ -5,6 +5,7 @@ import { Icon } from "./Icon"; import { IconDefinition, faLink } from "@fortawesome/free-solid-svg-icons"; import { useMemo } from "react"; import { faInstagram, faTwitter } from "@fortawesome/free-brands-svg-icons"; +import ReactDOM from "react-dom"; export const ExternalLinksButton: React.FC<{ icon?: IconDefinition; @@ -15,25 +16,8 @@ export const ExternalLinksButton: React.FC<{ return null; } - if (urls.length === 1) { - return ( - <Button - as={ExternalLink} - href={TextUtils.sanitiseURL(urls[0])} - className={`minimal link external-links-button ${className}`} - title={urls[0]} - > - <Icon icon={icon} /> - </Button> - ); - } - - return ( - <Dropdown className="external-links-button"> - <Dropdown.Toggle as={Button} className={`minimal link ${className}`}> - <Icon icon={icon} /> - </Dropdown.Toggle> - + const Menu = () => + ReactDOM.createPortal( <Dropdown.Menu> {urls.map((url) => ( <Dropdown.Item @@ -45,7 +29,16 @@ export const ExternalLinksButton: React.FC<{ {url} </Dropdown.Item> ))} - </Dropdown.Menu> + </Dropdown.Menu>, + document.body + ); + + return ( + <Dropdown className="external-links-button"> + <Dropdown.Toggle as={Button} className={`minimal link ${className}`}> + <Icon icon={icon} /> + </Dropdown.Toggle> + <Menu /> </Dropdown> ); }; From e253ba71f845c1f9783e5f097ca34d56b795a075 Mon Sep 17 00:00:00 2001 From: Stephan <sbeuze@hotmail.com> Date: Mon, 30 Sep 2024 07:39:57 +0200 Subject: [PATCH 45/91] Update README.md (#5309) Indicate dropped support for old Windows versions and indicate minimal Windows version required from 0.27.0 --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 8c35c134c86..ff9c4bdaf07 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,11 @@ For further information you can consult the [documentation](https://docs.stashap # Installing Stash +#### Windows Users: + +As of version 0.27.0, Stash doesn't support anymore _Windows 7, 8, Server 2008 and Server 2012._ +Windows 10 or Server 2016 are at least required. + <img src="docs/readme_assets/windows_logo.svg" width="100%" height="75"> Windows | <img src="docs/readme_assets/mac_logo.svg" width="100%" height="75"> macOS | <img src="docs/readme_assets/linux_logo.svg" width="100%" height="75"> Linux | <img src="docs/readme_assets/docker_logo.svg" width="100%" height="75"> Docker :---:|:---:|:---:|:---: [Latest Release](https://github.com/stashapp/stash/releases/latest/download/stash-win.exe) <br /> <sup><sub>[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/stash-win.exe)</sub></sup> | [Latest Release](https://github.com/stashapp/stash/releases/latest/download/Stash.app.zip) <br /> <sup><sub>[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/Stash.app.zip)</sub></sup> | [Latest Release (amd64)](https://github.com/stashapp/stash/releases/latest/download/stash-linux) <br /> <sup><sub>[Development Preview (amd64)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-linux)</sub></sup> <br /> [More Architectures...](https://github.com/stashapp/stash/releases/latest) | [Instructions](docker/production/README.md) <br /> <sup><sub>[Sample docker-compose.yml](docker/production/docker-compose.yml)</sub></sup> From 4dd8dd948e5dd10e0235f8bbcc4349c82146243c Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 30 Sep 2024 16:44:59 +1000 Subject: [PATCH 46/91] Refresh URL if random seed set (#5319) --- ui/v2.5/src/components/List/util.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index 280a65e75f5..c28a9d2a9da 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -57,12 +57,25 @@ export function useFilterURL( let newFilter = prevFilter.empty(); newFilter.configureFromQueryString(location.search); if (!isEqual(newFilter, prevFilter)) { + // filter may have changed if random seed was set, update the URL + const newParams = newFilter.makeQueryParameters(); + if (newParams !== location.search) { + history.replace({ ...history.location, search: newParams }); + } + return newFilter; } else { return prevFilter; } }); - }, [active, location.search, defaultFilter, setFilter, updateFilter]); + }, [ + active, + location.search, + defaultFilter, + setFilter, + updateFilter, + history, + ]); return { setFilter: updateFilter }; } From be6431ac13cafae5d04b63abd1a58844ed3a20f2 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 30 Sep 2024 17:13:45 +1000 Subject: [PATCH 47/91] Fix parent/child tag sort order (#5320) --- pkg/sqlite/table.go | 4 +++- pkg/sqlite/tables.go | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/sqlite/table.go b/pkg/sqlite/table.go index 8d72bdcae44..80d6b718a7f 100644 --- a/pkg/sqlite/table.go +++ b/pkg/sqlite/table.go @@ -167,7 +167,9 @@ func (t *joinTable) invert() *joinTable { table: t.table.table, idColumn: t.fkColumn, }, - fkColumn: t.table.idColumn, + fkColumn: t.table.idColumn, + foreignTable: t.foreignTable, + orderBy: t.orderBy, } } diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 74a5ebe698c..481c4ee06a4 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -333,7 +333,9 @@ var ( table: tagRelationsJoinTable, idColumn: tagRelationsJoinTable.Col(tagChildIDColumn), }, - fkColumn: tagRelationsJoinTable.Col(tagParentIDColumn), + fkColumn: tagRelationsJoinTable.Col(tagParentIDColumn), + foreignTable: tagTableMgr, + orderBy: tagTableMgr.table.Col("name").Asc(), } tagsChildTagsTableMgr = *tagsParentTagsTableMgr.invert() From 93a2ee127763cd3ca8ca64aea36b0e92ddde685f Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 30 Sep 2024 17:13:57 +1000 Subject: [PATCH 48/91] Fix page > total redirecting to first page instead of last (#5321) --- ui/v2.5/src/components/List/util.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index c28a9d2a9da..86aa6c6f67a 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -385,7 +385,7 @@ export function useEnsureValidPage( const totalPages = Math.ceil(totalCount / filter.itemsPerPage); if (totalPages > 0 && filter.currentPage > totalPages) { - setFilter((prevFilter) => prevFilter.changePage(1)); + setFilter((prevFilter) => prevFilter.changePage(totalPages)); } }, [filter, totalCount, setFilter]); } From 76a5b2a06a02bdbf129fdf9a2e5c89c1d2c80986 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 2 Oct 2024 09:58:48 +1000 Subject: [PATCH 49/91] Fix UI error when image has no files (#5325) --- .../components/Images/ImageDetails/Image.tsx | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx index 0a77180ceaa..e18571cd0e8 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx @@ -279,7 +279,10 @@ const ImagePage: React.FC<IProps> = ({ image }) => { ); const title = objectTitle(image); - const ImageView = isVideo(image.visual_files[0]) ? "video" : "img"; + const ImageView = + image.visual_files.length > 0 && isVideo(image.visual_files[0]) + ? "video" + : "img"; const resolution = useMemo(() => { return file?.width && file?.height @@ -362,19 +365,21 @@ const ImagePage: React.FC<IProps> = ({ image }) => { {renderTabs()} </div> <div className="image-container"> - <ImageView - loop={image.visual_files[0].__typename == "VideoFile"} - autoPlay={image.visual_files[0].__typename == "VideoFile"} - controls={image.visual_files[0].__typename == "VideoFile"} - className="m-sm-auto no-gutter image-image" - style={ - image.visual_files[0].__typename == "VideoFile" - ? { width: "100%", height: "100%" } - : {} - } - alt={title} - src={image.paths.image ?? ""} - /> + {image.visual_files.length > 0 && ( + <ImageView + loop={image.visual_files[0].__typename == "VideoFile"} + autoPlay={image.visual_files[0].__typename == "VideoFile"} + controls={image.visual_files[0].__typename == "VideoFile"} + className="m-sm-auto no-gutter image-image" + style={ + image.visual_files[0].__typename == "VideoFile" + ? { width: "100%", height: "100%" } + : {} + } + alt={title} + src={image.paths.image ?? ""} + /> + )} </div> </div> ); From c6c3754f021d208d795bdf8b1ca04d027cdd827b Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 2 Oct 2024 12:19:13 +1000 Subject: [PATCH 50/91] Fix panic when deleting image with no files (#5328) --- pkg/sqlite/image.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index db40d4f474b..4f61c17773c 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -528,7 +528,10 @@ func (qb *ImageStore) GetFiles(ctx context.Context, id int) ([]models.File, erro return nil, err } - return files, nil + ret := make([]models.File, len(files)) + copy(ret, files) + + return ret, nil } func (qb *ImageStore) GetManyFileIDs(ctx context.Context, ids []int) ([][]models.FileID, error) { From 9765b6d50ed4d3b9756be0365e00e0c2bb3317d4 Mon Sep 17 00:00:00 2001 From: huochexizhan <huochexizhan@outlook.com> Date: Wed, 2 Oct 2024 14:23:10 +0800 Subject: [PATCH 51/91] fix: fix slice init length (#5327) --- pkg/models/model_joins.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/models/model_joins.go b/pkg/models/model_joins.go index 7b7cae3e46a..c6cc8c2b228 100644 --- a/pkg/models/model_joins.go +++ b/pkg/models/model_joins.go @@ -33,7 +33,7 @@ func (u *UpdateGroupIDs) SceneMovieInputs() []SceneMovieInput { return nil } - ret := make([]SceneMovieInput, len(u.Groups)) + ret := make([]SceneMovieInput, 0, len(u.Groups)) for _, id := range u.Groups { ret = append(ret, id.SceneMovieInput()) } From c92de09ece2b5df2e16dcc52d6770de021e81e18 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 3 Oct 2024 11:26:18 +1000 Subject: [PATCH 52/91] Fix rating display in filter tags (#5334) --- ui/v2.5/src/components/List/ItemList.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/List/ItemList.tsx b/ui/v2.5/src/components/List/ItemList.tsx index 0efe3b03da9..05639e8fb81 100644 --- a/ui/v2.5/src/components/List/ItemList.tsx +++ b/ui/v2.5/src/components/List/ItemList.tsx @@ -1,6 +1,7 @@ import React, { PropsWithChildren, useCallback, + useContext, useEffect, useMemo, useState, @@ -36,6 +37,7 @@ import { IItemListOperation, } from "./FilteredListToolbar"; import { PagedList } from "./PagedList"; +import { ConfigurationContext } from "src/hooks/Config"; interface IItemListProps<T extends QueryResult, E extends IHasID> { view?: View; @@ -304,18 +306,20 @@ export const ItemListContext = <T extends QueryResult, E extends IHasID>( children, } = props; + const { configuration: config } = useContext(ConfigurationContext); + const emptyFilter = useMemo( () => providedDefaultFilter?.clone() ?? - new ListFilterModel(filterMode, undefined, { + new ListFilterModel(filterMode, config, { defaultSortBy: defaultSort, }), - [filterMode, defaultSort, providedDefaultFilter] + [config, filterMode, defaultSort, providedDefaultFilter] ); const [filter, setFilterState] = useState<ListFilterModel>( () => - new ListFilterModel(filterMode, undefined, { defaultSortBy: defaultSort }) + new ListFilterModel(filterMode, config, { defaultSortBy: defaultSort }) ); const { defaultFilter, loading: defaultFilterLoading } = useDefaultFilter( From 9b567fa6f45efa53e2e100be5af589bb03e55671 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 3 Oct 2024 11:53:29 +1000 Subject: [PATCH 53/91] Exclude null values from image studio id index (#5335) --- pkg/sqlite/database.go | 2 +- pkg/sqlite/migrations/68_image_studio_index.up.sql | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 pkg/sqlite/migrations/68_image_studio_index.up.sql diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index eed335f0973..0510d7baf26 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 67 +var appSchemaVersion uint = 68 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/68_image_studio_index.up.sql b/pkg/sqlite/migrations/68_image_studio_index.up.sql new file mode 100644 index 00000000000..3bb354e7390 --- /dev/null +++ b/pkg/sqlite/migrations/68_image_studio_index.up.sql @@ -0,0 +1,7 @@ +-- with the existing index, if no images have a studio id, then the index is +-- not used when filtering by studio id. The assumption with this change is that +-- most images don't have a studio id, so filtering by non-null studio id should +-- be faster with this index. This is a tradeoff, as filtering by null studio id +-- will be slower. +DROP INDEX index_images_on_studio_id; +CREATE INDEX `index_images_on_studio_id` on `images` (`studio_id`) WHERE `studio_id` IS NOT NULL; \ No newline at end of file From f05518860f3c8099378364d08a6318a66e326c0d Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Wed, 2 Oct 2024 22:31:43 -0400 Subject: [PATCH 54/91] Add include_sub_groups message ID (#5318) --- .../components/List/Filters/HierarchicalLabelValueFilter.tsx | 2 +- ui/v2.5/src/locales/en-GB.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/List/Filters/HierarchicalLabelValueFilter.tsx b/ui/v2.5/src/components/List/Filters/HierarchicalLabelValueFilter.tsx index 8d96a8a8d42..c51c8a2d5f6 100644 --- a/ui/v2.5/src/components/List/Filters/HierarchicalLabelValueFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/HierarchicalLabelValueFilter.tsx @@ -70,7 +70,7 @@ export const HierarchicalLabelValueFilter: React.FC< if (inputType === "studios") { id = "include_sub_studios"; } else if (inputType === "groups") { - id = "include-sub-groups"; + id = "include_sub_groups"; } else if (type === "children") { id = "include_parent_tags"; } else { diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 74073d1ccfb..b90a77d2dd3 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1098,6 +1098,7 @@ "images": "Images", "include_parent_tags": "Include parent tags", "include_sub_group_content": "Include sub-group content", + "include_sub_groups": "Include sub-groups", "include_sub_studio_content": "Include sub-studio content", "include_sub_studios": "Include subsidiary studios", "include_sub_tag_content": "Include sub-tag content", From 58c58beb4a9658f8457fced803a69b0c94dda3d1 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 3 Oct 2024 12:50:46 +1000 Subject: [PATCH 55/91] Fix match studio/performer links in performer view (#5337) --- .../Tagger/scenes/PerformerResult.tsx | 46 +++++++++---------- .../components/Tagger/scenes/StudioResult.tsx | 42 +++++++++-------- 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx index 25f0ca88f73..6b202be54c7 100755 --- a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { Button, ButtonGroup } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; @@ -13,24 +13,24 @@ import { } from "src/components/Performers/PerformerSelect"; import { getStashboxBase } from "src/utils/stashbox"; import { ExternalLink } from "src/components/Shared/ExternalLink"; +import { Link } from "react-router-dom"; -interface IPerformerName { +const PerformerLink: React.FC<{ performer: GQL.ScrapedPerformer | Performer; - id: string | undefined | null; - baseURL: string | undefined; -} - -const PerformerName: React.FC<IPerformerName> = ({ - performer, - id, - baseURL, -}) => { - const name = - baseURL && id ? ( - <ExternalLink href={`${baseURL}${id}`}>{performer.name}</ExternalLink> + url: string | undefined; + internal?: boolean; +}> = ({ performer, url, internal = false }) => { + const name = useMemo(() => { + if (!url) return performer.name; + + return internal ? ( + <Link to={url} target="_blank"> + {performer.name} + </Link> ) : ( - performer.name + <ExternalLink href={url}>{performer.name}</ExternalLink> ); + }, [url, performer.name, internal]); return ( <> @@ -115,10 +115,9 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({ <div className="entity-name"> <FormattedMessage id="countables.performers" values={{ count: 1 }} />: <b className="ml-2"> - <PerformerName + <PerformerLink performer={performer} - id={performer.remote_site_id} - baseURL={stashboxPerformerPrefix} + url={`${stashboxPerformerPrefix}${performer.remote_site_id}`} /> </b> </div> @@ -134,10 +133,10 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({ <FormattedMessage id="component_tagger.verb_matched" />: </span> <b className="col-3 text-right"> - <PerformerName + <PerformerLink performer={matchedPerformer} - id={matchedPerformer.id} - baseURL={performerURLPrefix} + url={`${performerURLPrefix}${matchedPerformer.id}`} + internal /> </b> </div> @@ -169,10 +168,9 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({ <div className="entity-name"> <FormattedMessage id="countables.performers" values={{ count: 1 }} />: <b className="ml-2"> - <PerformerName + <PerformerLink performer={performer} - id={performer.remote_site_id} - baseURL={stashboxPerformerPrefix} + url={`${stashboxPerformerPrefix}${performer.remote_site_id}`} /> </b> </div> diff --git a/ui/v2.5/src/components/Tagger/scenes/StudioResult.tsx b/ui/v2.5/src/components/Tagger/scenes/StudioResult.tsx index 4d099d2a6e2..13ffe6bff82 100755 --- a/ui/v2.5/src/components/Tagger/scenes/StudioResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StudioResult.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useMemo } from "react"; import { Button, ButtonGroup } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import cx from "classnames"; @@ -12,20 +12,24 @@ import { OptionalField } from "../IncludeButton"; import { faSave } from "@fortawesome/free-solid-svg-icons"; import { getStashboxBase } from "src/utils/stashbox"; import { ExternalLink } from "src/components/Shared/ExternalLink"; +import { Link } from "react-router-dom"; -interface IStudioName { +const StudioLink: React.FC<{ studio: GQL.ScrapedStudio | GQL.SlimStudioDataFragment; - id: string | undefined | null; - baseURL: string | undefined; -} - -const StudioName: React.FC<IStudioName> = ({ studio, id, baseURL }) => { - const name = - baseURL && id ? ( - <ExternalLink href={`${baseURL}${id}`}>{studio.name}</ExternalLink> + url: string | undefined; + internal?: boolean; +}> = ({ studio, url, internal = false }) => { + const name = useMemo(() => { + if (!url) return studio.name; + + return internal ? ( + <Link to={url} target="_blank"> + {studio.name} + </Link> ) : ( - studio.name + <ExternalLink href={url}>{studio.name}</ExternalLink> ); + }, [url, studio.name, internal]); return <span>{name}</span>; }; @@ -82,10 +86,9 @@ const StudioResult: React.FC<IStudioResultProps> = ({ <div className="entity-name"> <FormattedMessage id="countables.studios" values={{ count: 1 }} />: <b className="ml-2"> - <StudioName + <StudioLink studio={studio} - id={studio.remote_site_id} - baseURL={stashboxStudioPrefix} + url={`${stashboxStudioPrefix}${studio.remote_site_id}`} /> </b> </div> @@ -101,10 +104,10 @@ const StudioResult: React.FC<IStudioResultProps> = ({ <FormattedMessage id="component_tagger.verb_matched" />: </span> <b className="col-3 text-right"> - <StudioName + <StudioLink studio={matchedStudio} - id={matchedStudio.id} - baseURL={studioURLPrefix} + url={`${studioURLPrefix}${matchedStudio.id}`} + internal /> </b> </div> @@ -136,10 +139,9 @@ const StudioResult: React.FC<IStudioResultProps> = ({ <div className="entity-name"> <FormattedMessage id="countables.studios" values={{ count: 1 }} />: <b className="ml-2"> - <StudioName + <StudioLink studio={studio} - id={studio.remote_site_id} - baseURL={stashboxStudioPrefix} + url={`${stashboxStudioPrefix}${studio.remote_site_id}`} /> </b> </div> From 3e4515e62abd90c66de5466828553dabeb1f6e90 Mon Sep 17 00:00:00 2001 From: forcalas <forcalas@proton.me> Date: Thu, 3 Oct 2024 04:51:07 +0200 Subject: [PATCH 56/91] Add Open Container Image annotations as labels to Docker image (#5323) --- docker/ci/x86_64/Dockerfile | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docker/ci/x86_64/Dockerfile b/docker/ci/x86_64/Dockerfile index 96610b7d219..2c7fb0d8d7b 100644 --- a/docker/ci/x86_64/Dockerfile +++ b/docker/ci/x86_64/Dockerfile @@ -23,5 +23,13 @@ RUN apk add --no-cache --virtual .build-deps gcc python3-dev musl-dev \ && apk del .build-deps ENV STASH_CONFIG_FILE=/root/.stash/config.yml +# Basic build-time metadata as defined at https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys +LABEL org.opencontainers.image.title="Stash" \ + org.opencontainers.image.description="An organizer for your porn, written in Go." \ + org.opencontainers.image.url="https://stashapp.cc" \ + org.opencontainers.image.documentation="https://docs.stashapp.cc" \ + org.opencontainers.image.source="https://github.com/stashapp/stash" \ + org.opencontainers.image.licenses="AGPL-3.0" + EXPOSE 9999 CMD ["stash"] From 46972712947cb4e17abb241aa2857f7f1a966a5e Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 7 Oct 2024 09:00:49 +1100 Subject: [PATCH 57/91] Scene player fixes and improvements (#5340) * Don't log context canceled error during live transcode * Pause live transcode if still scrubbing * Debounce loading live transcode source to avoid multiple ffmpeg instances * Don't start from start or resume time if seeking before playing * Play video when seeked before playing --- pkg/ffmpeg/stream_transcode.go | 6 ++- .../components/ScenePlayer/ScenePlayer.tsx | 18 +++++++ ui/v2.5/src/components/ScenePlayer/live.ts | 54 +++++++++++-------- 3 files changed, 56 insertions(+), 22 deletions(-) diff --git a/pkg/ffmpeg/stream_transcode.go b/pkg/ffmpeg/stream_transcode.go index e0a30cdd9e5..bb701664f8e 100644 --- a/pkg/ffmpeg/stream_transcode.go +++ b/pkg/ffmpeg/stream_transcode.go @@ -1,6 +1,7 @@ package ffmpeg import ( + "context" "errors" "io" "net/http" @@ -230,7 +231,10 @@ func (sm *StreamManager) ServeTranscode(w http.ResponseWriter, r *http.Request, handler, err := sm.getTranscodeStream(lockCtx, options) if err != nil { - logger.Errorf("[transcode] error transcoding video file: %v", err) + // don't log context canceled errors + if !errors.Is(err, context.Canceled) { + logger.Errorf("[transcode] error transcoding video file: %v", err) + } w.WriteHeader(http.StatusBadRequest) if _, err := w.Write([]byte(err.Error())); err != nil { logger.Warnf("[transcode] error writing response: %v", err) diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index 897ac870bf3..bd3f0052208 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -451,12 +451,28 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({ if (!player) return; function canplay(this: VideoJsPlayer) { + // if we're seeking before starting, don't set the initial timestamp + // when starting from the beginning, there is a small delay before the event + // is triggered, so we can't just check if the time is 0 + if (this.currentTime() >= 0.1) { + return; + } + if (initialTimestamp.current !== -1) { this.currentTime(initialTimestamp.current); initialTimestamp.current = -1; } } + function timeupdate(this: VideoJsPlayer) { + // fired when seeking + // check if we haven't started playing yet + // if so, start playing + if (!started.current) { + this.play(); + } + } + function playing(this: VideoJsPlayer) { // This still runs even if autoplay failed on Safari, // only set flag if actually playing @@ -477,12 +493,14 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({ player.on("playing", playing); player.on("loadstart", loadstart); player.on("fullscreenchange", fullscreenchange); + player.on("timeupdate", timeupdate); return () => { player.off("canplay", canplay); player.off("playing", playing); player.off("loadstart", loadstart); player.off("fullscreenchange", fullscreenchange); + player.off("timeupdate", timeupdate); }; }, [getPlayer]); diff --git a/ui/v2.5/src/components/ScenePlayer/live.ts b/ui/v2.5/src/components/ScenePlayer/live.ts index e55456cb950..2ab51f763e2 100644 --- a/ui/v2.5/src/components/ScenePlayer/live.ts +++ b/ui/v2.5/src/components/ScenePlayer/live.ts @@ -1,3 +1,4 @@ +import { debounce } from "lodash-es"; import videojs, { VideoJsPlayer } from "video.js"; export interface ISource extends videojs.Tech.SourceObject { @@ -10,6 +11,9 @@ interface ICue extends TextTrackCue { _endTime?: number; } +// delay before loading new source after setting currentTime +const loadDelay = 200; + function offsetMiddleware(player: VideoJsPlayer) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- allow access to private tech methods let tech: any; @@ -50,6 +54,34 @@ function offsetMiddleware(player: VideoJsPlayer) { } } + const loadSource = debounce( + (seconds: number) => { + const srcUrl = new URL(source.src); + srcUrl.searchParams.set("start", seconds.toString()); + source.src = srcUrl.toString(); + + const poster = player.poster(); + const playbackRate = tech.playbackRate(); + seeking = tech.paused() ? 1 : 2; + player.poster(""); + tech.setSource(source); + tech.setPlaybackRate(playbackRate); + tech.one("canplay", () => { + player.poster(poster); + if (seeking === 1 || tech.scrubbing()) { + tech.pause(); + } + seeking = 0; + }); + tech.trigger("timeupdate"); + tech.trigger("pause"); + tech.trigger("seeking"); + tech.play(); + }, + loadDelay, + { leading: true } + ); + return { setTech(newTech: videojs.Tech) { tech = newTech; @@ -144,27 +176,7 @@ function offsetMiddleware(player: VideoJsPlayer) { updateOffsetStart(seconds); - const srcUrl = new URL(source.src); - srcUrl.searchParams.set("start", seconds.toString()); - source.src = srcUrl.toString(); - - const poster = player.poster(); - const playbackRate = tech.playbackRate(); - seeking = tech.paused() ? 1 : 2; - player.poster(""); - tech.setSource(source); - tech.setPlaybackRate(playbackRate); - tech.one("canplay", () => { - player.poster(poster); - if (seeking === 1) { - tech.pause(); - } - seeking = 0; - }); - tech.trigger("timeupdate"); - tech.trigger("pause"); - tech.trigger("seeking"); - tech.play(); + loadSource(seconds); return 0; }, From 7199d2b5aca033b34fef44bdda4cc335d15be0b0 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 7 Oct 2024 10:19:36 +1100 Subject: [PATCH 58/91] Handle legacy scene movies criterion in saved filters (#5348) --- ui/v2.5/src/models/list-filter/criteria/groups.ts | 10 ++++++++++ ui/v2.5/src/models/list-filter/scenes.ts | 6 +++++- ui/v2.5/src/models/list-filter/types.ts | 1 + 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/models/list-filter/criteria/groups.ts b/ui/v2.5/src/models/list-filter/criteria/groups.ts index f7fd42492de..762ebf6e8b7 100644 --- a/ui/v2.5/src/models/list-filter/criteria/groups.ts +++ b/ui/v2.5/src/models/list-filter/criteria/groups.ts @@ -42,3 +42,13 @@ export const SubGroupsCriterionOption = new BaseGroupsCriterionOption( "sub_groups", "sub_groups" ); + +// redirects to GroupsCriterion +export const LegacyMoviesCriterionOption = new CriterionOption({ + messageID: "groups", + type: "movies", + modifierOptions, + defaultModifier, + inputType, + makeCriterion: () => new GroupsCriterion(GroupsCriterionOption), +}); diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index 965fa31be10..7496da6b62d 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -8,7 +8,10 @@ import { } from "./criteria/criterion"; import { HasMarkersCriterionOption } from "./criteria/has-markers"; import { SceneIsMissingCriterionOption } from "./criteria/is-missing"; -import { GroupsCriterionOption } from "./criteria/groups"; +import { + GroupsCriterionOption, + LegacyMoviesCriterionOption, +} from "./criteria/groups"; import { GalleriesCriterionOption } from "./criteria/galleries"; import { OrganizedCriterionOption } from "./criteria/organized"; import { PerformersCriterionOption } from "./criteria/performers"; @@ -106,6 +109,7 @@ const criterionOptions = [ // StudioTagsCriterionOption, StudiosCriterionOption, GroupsCriterionOption, + LegacyMoviesCriterionOption, GalleriesCriterionOption, createStringCriterionOption("url"), StashIDCriterionOption, diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index b632609ab8b..48e37c0461d 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -148,6 +148,7 @@ export type CriterionType = | "studios" | "scenes" | "groups" + | "movies" // legacy | "containing_groups" | "containing_group_count" | "sub_groups" From 35b74be585bf92848b060ff94fc10ff9f9fc8abe Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 7 Oct 2024 11:20:20 +1100 Subject: [PATCH 59/91] Restore persistence in selection when paging (#5349) --- ui/v2.5/src/components/List/util.ts | 95 ++++++++++++++--------------- 1 file changed, 47 insertions(+), 48 deletions(-) diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index 86aa6c6f67a..2cd4f90ea8e 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -191,39 +191,55 @@ export function useListKeyboardShortcuts(props: { } export function useListSelect<T extends { id: string }>(items: T[]) { - const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); + const [itemsSelected, setItemsSelected] = useState<T[]>([]); const [lastClickedId, setLastClickedId] = useState<string>(); - const prevItems = usePrevious(items); + const selectedIds = useMemo(() => { + const newSelectedIds = new Set<string>(); + itemsSelected.forEach((item) => { + newSelectedIds.add(item.id); + }); - useEffect(() => { - if (prevItems === items) { - return; - } + return newSelectedIds; + }, [itemsSelected]); - // filter out any selectedIds that are no longer in the list - const newSelectedIds = new Set<string>(); + // const prevItems = usePrevious(items); - selectedIds.forEach((id) => { - if (items.some((item) => item.id === id)) { - newSelectedIds.add(id); - } - }); + // #5341 - HACK/TODO: this is a regression of previous behaviour. I don't like the idea + // of keeping selected items that are no longer in the list, since its not + // clear to the user that the item is still selected, but there is now an expectation of + // this behaviour. + // useEffect(() => { + // if (prevItems === items) { + // return; + // } + + // // filter out any selectedIds that are no longer in the list + // const newSelectedIds = new Set<string>(); - setSelectedIds(newSelectedIds); - }, [prevItems, items, selectedIds]); + // selectedIds.forEach((id) => { + // if (items.some((item) => item.id === id)) { + // newSelectedIds.add(id); + // } + // }); + + // setSelectedIds(newSelectedIds); + // }, [prevItems, items, selectedIds]); function singleSelect(id: string, selected: boolean) { setLastClickedId(id); - const newSelectedIds = new Set(selectedIds); - if (selected) { - newSelectedIds.add(id); - } else { - newSelectedIds.delete(id); - } - - setSelectedIds(newSelectedIds); + setItemsSelected((prevSelected) => { + if (selected) { + const item = items.find((i) => i.id === id); + if (item) { + return [...prevSelected, item]; + } + return prevSelected; + } else { + return prevSelected.filter((item) => item.id !== id); + } + }); } function selectRange(startIndex: number, endIndex: number) { @@ -236,13 +252,9 @@ export function useListSelect<T extends { id: string }>(items: T[]) { } const subset = items.slice(start, end + 1); - const newSelectedIds = new Set<string>(); - - subset.forEach((item) => { - newSelectedIds.add(item.id); - }); - setSelectedIds(newSelectedIds); + const newSelected = itemsSelected.concat(subset); + setItemsSelected(newSelected); } function multiSelect(id: string) { @@ -271,32 +283,19 @@ export function useListSelect<T extends { id: string }>(items: T[]) { } function onSelectAll() { - const newSelectedIds = new Set<string>(); - items.forEach((item) => { - newSelectedIds.add(item.id); - }); - - setSelectedIds(newSelectedIds); + // #5341 - HACK/TODO: maintaining legacy behaviour of replacing selected items with + // all items on the current page. To be consistent with the existing behaviour, it + // should probably _add_ all items on the current page to the selected items. + setItemsSelected([...items]); setLastClickedId(undefined); } function onSelectNone() { - const newSelectedIds = new Set<string>(); - setSelectedIds(newSelectedIds); + setItemsSelected([]); setLastClickedId(undefined); } - const getSelected = useMemo(() => { - let cached: T[] | undefined; - return () => { - if (cached) { - return cached; - } - - cached = items.filter((value) => selectedIds.has(value.id)); - return cached; - }; - }, [items, selectedIds]); + const getSelected = useCallback(() => itemsSelected, [itemsSelected]); return { selectedIds, From f82e24762b944ead4279c021a3f56f15268018e0 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 7 Oct 2024 11:20:29 +1100 Subject: [PATCH 60/91] Add blobs location to env binds (#5350) --- internal/manager/config/init.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/manager/config/init.go b/internal/manager/config/init.go index 9c9caafb386..09f1c18bc26 100644 --- a/internal/manager/config/init.go +++ b/internal/manager/config/init.go @@ -39,6 +39,7 @@ var ( "external_host": ExternalHost, "generated": Generated, "metadata": Metadata, + "blobs": BlobsPath, "cache": Cache, "stash": Stash, "ui": UILocation, From b6db4c31ca689cfb8b9054e493c94c80abc24f80 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 10 Oct 2024 10:54:39 +1100 Subject: [PATCH 61/91] Prevent duplicate entries in selection list (#5358) --- ui/v2.5/src/components/List/util.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index 2cd4f90ea8e..addb5601238 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -229,6 +229,11 @@ export function useListSelect<T extends { id: string }>(items: T[]) { function singleSelect(id: string, selected: boolean) { setLastClickedId(id); + // prevent duplicates + if (selected && selectedIds.has(id)) { + return; + } + setItemsSelected((prevSelected) => { if (selected) { const item = items.find((i) => i.id === id); @@ -253,7 +258,10 @@ export function useListSelect<T extends { id: string }>(items: T[]) { const subset = items.slice(start, end + 1); - const newSelected = itemsSelected.concat(subset); + // prevent duplicates + const toAdd = subset.filter((item) => !selectedIds.has(item.id)); + + const newSelected = itemsSelected.concat(toAdd); setItemsSelected(newSelected); } From a54996d8a210fc32097fae4fd9d00fa743a2c21f Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 10 Oct 2024 11:52:11 +1100 Subject: [PATCH 62/91] Weblate translation update (#5359) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Korean) Currently translated at 100.0% (1174 of 1174 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1174 of 1174 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1174 of 1174 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/ * Translated using Weblate (Hungarian) Currently translated at 41.9% (493 of 1175 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/hu/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1175 of 1175 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/ * Translated using Weblate (French) Currently translated at 100.0% (1175 of 1175 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/ * Translated using Weblate (Hungarian) Currently translated at 43.7% (514 of 1175 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/hu/ * Translated using Weblate (Czech) Currently translated at 100.0% (1175 of 1175 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/cs/ * Translated using Weblate (German) Currently translated at 87.2% (1025 of 1175 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/de/ * Translated using Weblate (Norwegian Bokmål) Currently translated at 18.7% (220 of 1175 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/nb_NO/ --------- Co-authored-by: yec <yec@users.noreply.translate.codeberg.org> Co-authored-by: wql219 <wanqinglin219@hotmail.com> Co-authored-by: tozoktala <tozoktala@users.noreply.translate.codeberg.org> Co-authored-by: zdh <zdh@users.noreply.translate.codeberg.org> Co-authored-by: doodoo <doodoo@users.noreply.translate.codeberg.org> Co-authored-by: NymeriaCZ <NymeriaCZ@users.noreply.translate.codeberg.org> Co-authored-by: augeee <augeee@users.noreply.translate.codeberg.org> Co-authored-by: noTranslator <noTranslator@users.noreply.translate.codeberg.org> --- ui/v2.5/src/locales/cs-CZ.json | 42 +++++- ui/v2.5/src/locales/de-DE.json | 3 +- ui/v2.5/src/locales/fr-FR.json | 3 +- ui/v2.5/src/locales/hu-HU.json | 69 +++++++-- ui/v2.5/src/locales/ko-KR.json | 8 +- ui/v2.5/src/locales/nb_NO.json | 248 ++++++++++++++++++++++++++++++++- ui/v2.5/src/locales/zh-CN.json | 31 +++-- 7 files changed, 365 insertions(+), 39 deletions(-) diff --git a/ui/v2.5/src/locales/cs-CZ.json b/ui/v2.5/src/locales/cs-CZ.json index f2617b0f428..1384be8657b 100644 --- a/ui/v2.5/src/locales/cs-CZ.json +++ b/ui/v2.5/src/locales/cs-CZ.json @@ -135,7 +135,13 @@ "remove_date": "Odstranit datum", "add_manual_date": "Přidat datum ručně", "add_play": "Přidat přehrávání", - "view_history": "Zobrazit historii" + "view_history": "Zobrazit historii", + "reset_cover": "Obnovit výchozí obal", + "add_sub_groups": "Přidat podskupinu", + "set_cover": "Nastavit jako obal", + "remove_from_containing_group": "Odebrat ze skupiny", + "reset_resume_time": "Obnovit čas pokračování", + "reset_play_duration": "Obnovit dobu přehrávání" }, "actions_name": "Akce", "age": "Věk", @@ -531,7 +537,9 @@ "anonymise_database": "Vytvoří kopii databáze do adresáře záloh, anonymizuje všechna citlivá data. To pak může být poskytnuto ostatním pro účely odstraňování problémů a debuggování. Původní databáze se nemění. Anonymizovaná databáze používá formát názvu souboru {filename_format}.", "optimise_database": "Pokusit se zlepšit výkon analyzováním a opětovným sestavením celého databázového souboru.", "generate_clip_previews_during_scan": "Generování náhledů pro obrázkové klipy", - "optimise_database_warning": "Varování: Pokud je tato úloha spuštěna, všechny operace, které upravují databázi, selžou a v závislosti na velikosti databáze může její dokončení trvat několik minut. Vyžaduje také minimálně tolik volného místa na disku, jak je vaše databáze velká, ale doporučuje se 1,5x." + "optimise_database_warning": "Varování: Pokud je tato úloha spuštěna, všechny operace, které upravují databázi, selžou a v závislosti na velikosti databáze může její dokončení trvat několik minut. Vyžaduje také minimálně tolik volného místa na disku, jak je vaše databáze velká, ale doporučuje se 1,5x.", + "rescan": "Znovu skenovat soubory", + "rescan_tooltip": "Znovu skenovat každý soubor v cestě. Používá se k vynucení aktualizace metadat souboru a opětovnému skenování zip souborů." }, "tools": { "scene_duplicate_checker": "Kontrola na duplikaci scén", @@ -787,7 +795,8 @@ "performers": "{count, plural, one {Účinkující} other {Účinkující}}", "scenes": "{count, plural, one {Scéna} other {Scény}}", "studios": "{count, plural, one {Studio} other {Studia}}", - "tags": "{count, plural, one {Tag} other {Tagy}}" + "tags": "{count, plural, one {Tag} other {Tagy}}", + "groups": "{count, plural, one {Skupina} other {Skupiny}}" }, "country": "Země", "cover_image": "Obrázek obalu", @@ -1215,7 +1224,9 @@ "lazy_component_error_help": "Pokud jste nedávno aktualizovali Stash, znovu načtěte stránku nebo vymažte mezipaměť prohlížeče.", "something_went_wrong": "Něco se pokazilo.", "header": "Chyba", - "loading_type": "Chyba při načítání {type}" + "loading_type": "Chyba při načítání {type}", + "invalid_javascript_string": "Neplatný kód javascriptu : {error}", + "invalid_json_string": "Neplatný string JSON: {error}" }, "eye_color": "Barva očí", "fake_tits": "Falešná prsa", @@ -1275,7 +1286,8 @@ "last": "Poslední", "next": "Další", "previous": "Předchozí", - "first": "První" + "first": "První", + "current_total": "{current} z {total}" }, "parent_tag_count": "Počet nadřazených tagů", "parent_tags": "Nadřazené tagy", @@ -1461,5 +1473,23 @@ "years_old": "Let", "updated_at": "Aktualizováno", "stash_id": "Stash ID", - "sub_tag_of": "Počet sub-tagů od {parent}" + "sub_tag_of": "Počet sub-tagů od {parent}", + "containing_group": "Obsahující skupina", + "containing_group_count": "Počet obsahujících skupin", + "include_sub_group_content": "Zahrnout obsah podskupiny", + "group": "Skupina", + "group_count": "Počet skupin", + "group_scene_number": "Číslo scény", + "studio_count": "Počet studií", + "studio_tags": "Tagy studií", + "containing_groups": "Obsahující skupiny", + "groups": "Skupiny", + "sub_group_of": "Podskupina {parent}", + "sub_group_order": "Pořadí podskupin", + "sub_groups": "Podskupiny", + "sub_group": "Podskupina", + "sub_group_count": "Počet podskupin", + "include_sub_studio_content": "Zahrnout obsah podstudií", + "include_sub_tag_content": "Zahrnout obsah podtagů", + "include_sub_groups": "Zahrnout podskupiny" } diff --git a/ui/v2.5/src/locales/de-DE.json b/ui/v2.5/src/locales/de-DE.json index 40aca3495f9..4cc7b95f894 100644 --- a/ui/v2.5/src/locales/de-DE.json +++ b/ui/v2.5/src/locales/de-DE.json @@ -545,7 +545,8 @@ "transcodes": "Transkodierte Szenen", "sprites": "Sprites der Szenen" }, - "generate_sprites_during_scan_tooltip": "Die Anzahl an Bilder, die unter dem Video Player, zur einfacheren Navigation, angezeigt werden." + "generate_sprites_during_scan_tooltip": "Die Anzahl an Bilder, die unter dem Video Player, zur einfacheren Navigation, angezeigt werden.", + "optimise_database_warning": "Achtung: Während diese Aufgabe ausgeführt wird, schlagen alle Operationen, die die Datenbank verändern, fehl, und je nach Größe Ihrer Datenbank kann es mehrere Minuten dauern, bis sie abgeschlossen ist. Außerdem wird mindestens so viel freier Speicherplatz benötigt, wie Ihre Datenbank groß ist, empfohlen wird jedoch das 1,5-fache." }, "tools": { "scene_duplicate_checker": "Duplikatsprüfung für Szenen", diff --git a/ui/v2.5/src/locales/fr-FR.json b/ui/v2.5/src/locales/fr-FR.json index 5d80067522a..d930b93f28f 100644 --- a/ui/v2.5/src/locales/fr-FR.json +++ b/ui/v2.5/src/locales/fr-FR.json @@ -1490,5 +1490,6 @@ "sub_groups": "Groupes affiliés", "sub_group_of": "Groupe affilié de {parent}", "sub_group": "Groupe affilié", - "sub_group_count": "Nombre de groupes affiliés" + "sub_group_count": "Nombre de groupes affiliés", + "include_sub_groups": "Inclure les groupes affiliés" } diff --git a/ui/v2.5/src/locales/hu-HU.json b/ui/v2.5/src/locales/hu-HU.json index d4909ace6f2..be47f77a978 100644 --- a/ui/v2.5/src/locales/hu-HU.json +++ b/ui/v2.5/src/locales/hu-HU.json @@ -126,7 +126,14 @@ "view_random": "Véletlenszerű megtekintés", "enable": "Engedélyezés", "open_in_external_player": "Megnyitás külső lejátszóban", - "reload": "Újratöltés" + "reload": "Újratöltés", + "add_sub_groups": "Al kategóriák hozzá adása", + "add_manual_date": "Dátum Manuális hozzá adása", + "add_o": "O hozzá adása", + "choose_date": "Válassz egy dátumot", + "clean_generated": "Generált fájlok takarítása", + "clear_date_data": "Dátum adatok törlése", + "copy_to_clipboard": "Vágólapra másolás" }, "age": "Kor", "aliases": "Álnevek", @@ -398,7 +405,8 @@ }, "scene_gen": { "preview_exclude_end_time_desc": "Az utolsó x másodperc kihagyása a jelenetek bemutatóiból. Ez az érték lehet másodpercben, vagy a jelenet teljes hosszának százalékában (pl. 2%) is megadva.", - "preview_exclude_start_time_desc": "Az első x másodperc kihagyása a jelenetek bemutatóiból. Ez az érték lehet másodpercben, vagy a jelenet teljes hosszának százalékában (pl. 2%) is megadva." + "preview_exclude_start_time_desc": "Az első x másodperc kihagyása a jelenetek bemutatóiból. Ez az érték lehet másodpercben, vagy a jelenet teljes hosszának százalékában (pl. 2%) is megadva.", + "video_previews": "Előnézetek" }, "scrape_results_existing": "Létező", "set_image_url_title": "Kép URL" @@ -420,7 +428,10 @@ "medium": "Közepes" }, "search_accuracy_label": "Keresési Pontosság", - "title": "Megkettőzött Jelenetek" + "title": "Megkettőzött Jelenetek", + "duration_options": { + "any": "Akármelyik" + } }, "duplicated_phash": "Megkettőzőtt (phash)", "duration": "Hossz", @@ -516,7 +527,8 @@ "network_error": "Hálózati Hiba", "tag_status": "Címke Státusza", "update_performer": "Szereplő Frissítése", - "update_performers": "Szereplők Frissítése" + "update_performers": "Szereplők Frissítése", + "name_already_exists": "A név már létezik" }, "performer_tags": "Szereplő Címkék", "performers": "Szereplők", @@ -543,7 +555,8 @@ "nearly_there": "Már majdnem kész!" }, "errors": { - "something_went_wrong_while_setting_up_your_system": "Valami hiba történt a rendszer beállításakor. Itt a hibaüzenet: {error}" + "something_went_wrong_while_setting_up_your_system": "Valami hiba történt a rendszer beállításakor. Itt a hibaüzenet: {error}", + "something_went_wrong": "Valami elromlott" }, "folder": { "file_path": "Fájl elérési út" @@ -554,18 +567,23 @@ "migration_failed": "Sikertelen áttelepítés", "migration_irreversible_warning": "A séma áttelepítése nem visszafordítható folyamat. Amint az áttelepítés elkezdődik, az adatbázis összeegyeztethetetlen lesz a Stash előző verzióival.", "migration_required": "Áttelepítés szükséges", - "schema_too_old": "A jelenlegi Stash adatbázis verziója <strong>{databaseSchema}</strong> , amit át kell telepíteni <strong>{appSchema}</strong> verzióra. A Stash ezen verziója nem fog működni az adatbázis áttelepítése nélkül." + "schema_too_old": "A jelenlegi Stash adatbázis verziója <strong>{databaseSchema}</strong> , amit át kell telepíteni <strong>{appSchema}</strong> verzióra. A Stash ezen verziója nem fog működni az adatbázis áttelepítése nélkül.", + "perform_schema_migration": "sémamigráció végre hajtása" }, "success": { "help_links": "Ha problémába ütközöl, kérdésed, vagy javaslatod van, nyugodtan jelezd {githubLink}, vagy kérdezd meg a közösségtől {discordLink}.", - "support_us": "Támogatás" + "support_us": "Támogass minket" }, "welcome": { "config_path_logic_explained": "A Stash elöszőr a jelenlegi munkakönyvtárban próbálja a konfigurációs fájlját (<code>config.yml</code>) megkeresni. Ha ott nem találja, akkor a következő helyen próbálkozik: <code>$HOME/.stash/config.yml</code> (Windows rendszeren: <code>%USERPROFILE%\\.stash\\config.yml</code>). Meghatározhatja hogy a Stash egy bizonyos konfigurációs fájlt használjon, ammennyiben a <code>-c '<path to config file>'</code> or <code>--config '<path to config file>'</code> paraméterekkel indítja.", "unexpected_explained": "Amennyiben ez a képernyő váratlanul bukkant fel, próbáld újraindítani a Stasht a megfelelő munkakönyvtárban, vagy a <code>-c</code> flag-gel." }, "welcome_specific_config": { - "unable_to_locate_specified_config": "Ha ezt olvasod, akkor a Stash nem találta meg a konfigurációs fájlt, amit megadtál a parancssorban. Ez a varázsló végigvezet a lépéseken, hogy új konfigurációs fájlt tudj beállítani." + "unable_to_locate_specified_config": "Ha ezt olvasod, akkor a Stash nem találta meg a konfigurációs fájlt, amit megadtál a parancssorban. Ez a varázsló végigvezet a lépéseken, hogy új konfigurációs fájlt tudj beállítani.", + "config_path": "A Stash a következő konfigurációs fájl útvonalat fogja használni: <code>{path}</code>" + }, + "paths": { + "where_is_your_porn_located": "Hol található a pornód?" } }, "stashbox": { @@ -591,7 +609,10 @@ "created_entity": "{entity} Létrehozva", "merged_tags": "Összevont címkék", "saved_entity": "{entity} Mentve", - "updated_entity": "{entity} Frissítve" + "updated_entity": "{entity} Frissítve", + "started_generating": "Generálás megkezdve", + "started_importing": "Importálás megkezdve", + "default_filter_set": "Alapvető szűrő beállítva" }, "total": "Összesen", "true": "Igaz", @@ -617,5 +638,33 @@ }, "captions": "Feliratok", "chapters": "Fejezetek", - "circumcised": "Körülmetélt" + "circumcised": "Körülmetélt", + "weight_kg": "Súly (kg)", + "sub_tags": "Al címkék", + "sub_group": "Al csoport", + "unknown_date": "Ismeretlen dátum", + "urls": "URL-ek", + "validation": { + "blank": "${path} nem lehet üres" + }, + "studio_tags": "Stúdió címkék", + "type": "Típus", + "measurements": "Mérések", + "package_manager": { + "install": "Letöltés", + "version": "Verzió" + }, + "folder": "mappa", + "second": "Második", + "studio_tagger": { + "current_page": "Jelenlegi oldal", + "network_error": "Hálózati hiba", + "tag_status": "Címke státusz" + }, + "history": "Történet", + "sub_groups": "Al csoportok", + "sub_group_count": "Al csoportok száma", + "primary_file": "Elsődleges fájl", + "groups": "Csoportok", + "penis_length_cm": "Pénisz hosszúság (cm)" } diff --git a/ui/v2.5/src/locales/ko-KR.json b/ui/v2.5/src/locales/ko-KR.json index 524a35ab364..fb32e6f403c 100644 --- a/ui/v2.5/src/locales/ko-KR.json +++ b/ui/v2.5/src/locales/ko-KR.json @@ -1127,7 +1127,7 @@ "last": "마지막", "next": "다음", "previous": "이전", - "current_total": "{total} 중 {current}" + "current_total": "{current} / {total}" }, "parent_of": "{children}의 상위 태그", "parent_studios": "모회사 스튜디오", @@ -1477,16 +1477,16 @@ "group": "그룹", "group_count": "그룹 개수", "group_scene_number": "영상 번호", - "groups": "그룹들", + "groups": "그룹", "studio_tags": "스튜디오 태그", - "containing_groups": "그룹들 포함", + "containing_groups": "그룹 포함", "containing_group_count": "그룹 개수 포함", "studio_count": "스튜디오 개수", "containing_group": "그룹 포함", "include_sub_group_content": "서브그룹 컨텐츠 포함", "sub_group_count": "서브그룹 개수", "sub_group_order": "서브그룹 순서", - "sub_groups": "서브그룹들", + "sub_groups": "서브그룹", "sub_group": "서브그룹", "sub_group_of": "{parent}의 서브그룹", "include_sub_studio_content": "서브스튜디오 컨텐츠 포함", diff --git a/ui/v2.5/src/locales/nb_NO.json b/ui/v2.5/src/locales/nb_NO.json index cfc26530200..a6a72d8a049 100644 --- a/ui/v2.5/src/locales/nb_NO.json +++ b/ui/v2.5/src/locales/nb_NO.json @@ -4,6 +4,250 @@ "anonymise": "Anonymiser", "confirm": "Bekreft", "continue": "Fortsett", - "close": "Lukk" - } + "close": "Lukk", + "reset_cover": "Tilbakestill Standard Omslag", + "remove": "Fjern", + "running": "kjører", + "submit_stash_box": "Send til Stash-Box", + "delete_generated_supporting_files": "Slett genererte støttende filer", + "select_entity": "Velg {entityType}", + "copy_to_clipboard": "Kopier til utklippstavle", + "delete_file_and_funscript": "Slett fil (og funscript)", + "clear_front_image": "Fjern front bilde", + "next_action": "Neste", + "tasks": { + "clean_confirm_message": "Er du sikker du vil Rydde opp? Dette vil slette databaseinformasjon og generert innhold for alle scener og gallerier som ikke lenger finnes i filsystemet.", + "dry_mode_selected": "Tørrmodus valgt. Ingen faktisk sletting vil finne sted, kun logging.", + "import_warning": "Er du sikker på at du vil importere? Dette vil slette databasen og re-importere fra dine eksporterte metadata." + }, + "generate_thumb_default": "Generer standard miniatyrbilde", + "merge_into": "Slå sammen til", + "scrape_query": "Skrape forespørsel", + "reset_play_duration": "Tilbakestill avspillingsvarigheten", + "reset_resume_time": "Tilbakestill gjenoppta tid", + "save": "Lagre", + "save_delete_settings": "Bruk disse alternativene som standard når du sletter", + "save_filter": "Lagre filter", + "scan": "Skann", + "scrape": "Skrape", + "create": "Opprett", + "create_chapters": "Opprett Kapittel", + "create_marker": "Opprett Markør", + "delete": "Slett", + "delete_file": "Slett fil", + "disable": "Deaktiver", + "download": "Last ned", + "download_backup": "Last ned Sikkerhetskopi", + "edit": "Rediger", + "edit_entity": "Rediger {entityType}", + "enable": "Aktiver", + "export": "Eksporter", + "find": "Finn", + "finish": "Fullfør", + "from_file": "Fra fil…", + "from_url": "Fra URL…", + "generate": "Generer", + "generate_thumb_from_current": "Generer miniatyrbilde fra nåværende", + "hide": "Skjul", + "hide_configuration": "Skjul Konfigurasjon", + "identify": "Identifiser", + "ignore": "Ignorer", + "import": "Importer…", + "add_sub_groups": "Legg til undergrupper", + "create_entity": "Opprett {entityType}", + "delete_entity": "Slett {entityType}", + "encoding_image": "Omsetter bilde til kode…", + "merge": "Slå sammen", + "created_entity": "Opprettet {entity_type}: {entity_name}", + "merge_from": "Slå sammen fra", + "clean_generated": "Rydd opp i genererte filer", + "clear": "Tøm", + "clear_back_image": "Fjern bakbilde", + "clear_date_data": "Fjern dato data", + "clear_image": "Fjern Bilde", + "create_parent_studio": "Opprett foreldre studio", + "customise": "Tilpass", + "disallow": "Ikke tillat", + "download_anonymised": "Last ned anonymisert", + "export_all": "Eksporter alle…", + "full_export": "Full eksport", + "full_import": "Full Import", + "hash_migration": "hash migrering", + "make_primary": "Gjør til Primær", + "previous_action": "Tilbake", + "refresh": "Oppdater", + "reload": "Last på nytt", + "not_running": "Kjører ikke", + "open_in_external_player": "Åpne i ekstern spiller", + "remove_date": "Fjern dato", + "remove_from_containing_group": "Fjern fra Gruppe", + "remove_from_gallery": "Fjern fra Galleri", + "scrape_with": "Skrap med…", + "search": "Søk", + "select_all": "Velg Alle", + "select_folders": "Velg mapper", + "select_none": "Velg Ingen", + "selective_scan": "Selektiv Skann", + "set_as_default": "Sett som standard", + "set_front_image": "Frontbilde…", + "show": "Vis", + "show_configuration": "Vis Konfigurasjon", + "skip": "Hopp over", + "split": "Splitt", + "stop": "Stopp", + "submit_update": "Send inn oppdatering", + "submit": "Send inn", + "swap": "Bytt", + "temp_disable": "Deaktiver midlertidig…", + "temp_enable": "Aktiver midlertidig…", + "unset": "Velg bort", + "use_default": "Bruk standard", + "view_history": "Visningshistorikk", + "view_random": "Vis Tilfeldig", + "migrate_blobs": "Migrer Blobs", + "migrate_scene_screenshots": "Migrer Scene Skjermbilder", + "reassign": "Omplasser", + "reload_plugins": "Last inn plugins på nytt", + "reload_scrapers": "Last inn skrapere på nytt", + "scrape_scene_fragment": "Skrap etter fragment", + "set_back_image": "Baksidebilde…", + "set_cover": "Velg som Omslag", + "allow": "Tillat", + "allow_temporarily": "Tillat midlertidig", + "backup": "Sikkerhetskopi", + "browse_for_image": "Bla gjennom bilder…", + "cancel": "Avbryt", + "apply": "Bruk", + "assign_stashid_to_parent_studio": "Tildel Stash ID til eksisterende foreldre studio og oppdater metadata", + "add_to_entity": "Legg til til {entityType}", + "add_entity": "Legg til {entityType}", + "add_manual_date": "Legg til manuell dato", + "add_directory": "Legg til mappe", + "add_o": "Legg til O", + "add_play": "Legg til avspilling", + "auto_tag": "Automatisk Tagging", + "choose_date": "Velg en dato", + "clean": "Rydd opp", + "import_from_file": "Importer fra fil", + "logout": "Logg ut", + "overwrite": "Overskriv", + "preview": "Forhåndsvis", + "optimise_database": "Optimaliser Database", + "play_random": "Spill av Tilfeldig", + "open_random": "Åpne tilfeldig", + "play_selected": "Spill av valgte", + "rescan": "Skann på nytt", + "reshuffle": "Stokk om", + "rename_gen_files": "Gi nytt navn til genererte filer", + "selective_auto_tag": "Selektiv Automatisk Tagging" + }, + "component_tagger": { + "config": { + "mark_organized_desc": "Umiddelbart marker scenen som Organisert etter at Lagre-knappen er trykket.", + "mark_organized_label": "Merk som Organisert ved lagring", + "query_mode_auto_desc": "Bruk metadata hvis tilstede, eller filnavn", + "blacklist_label": "Svarteliste", + "query_mode_auto": "Auto", + "query_mode_dir_desc": "Bruker bare mappen til videofilen", + "query_mode_filename": "Filnavn", + "query_mode_label": "Forespørselsmodus", + "active_instance": "Aktiv stash-box instans:", + "blacklist_desc": "Svartelisteelementer er ekskludert fra forespørsler. Merk at de er regulære uttrykk og skiller mellom store og små bokstaver. Enkelte tegn må forutgås av en omvendt skråstrek: {chars_require_escape}", + "query_mode_dir": "Mappe", + "query_mode_filename_desc": "Bruker kun filnavn", + "query_mode_metadata": "Metadata", + "query_mode_metadata_desc": "Bruker kun metadata", + "query_mode_path": "Filbane", + "query_mode_path_desc": "Bruker hele filbanen", + "set_cover_desc": "Bytt ut scenens omslag hvis en finnes.", + "source": "Kilde" + } + }, + "config": { + "dlna": { + "default_ip_whitelist": "Standard IP hviteliste", + "allowed_ip_temporarily": "Tillatt IP midlertidig", + "default_ip_whitelist_desc": "Standard IP-adresser gir tilgang til DLNA. Bruk {wildcard} for å tillatte alle IP-adresser.", + "recent_ip_addresses": "Nylige IP-adresser", + "disabled_dlna_temporarily": "Deaktivert DLNA midlertidig", + "disallowed_ip": "Ikke tillatt IP", + "enabled_by_default": "Aktivert som standard", + "enabled_dlna_temporarily": "Aktiverte DLNA midlertidig", + "network_interfaces": "Grensesnitt", + "allow_temp_ip": "Tillatt {tempIP}", + "allowed_ip_addresses": "Tillatt IP adresser", + "server_port": "Serverport", + "server_display_name": "Server Visningsnavn" + }, + "about": { + "stash_open_collective": "Støtt oss gjennom {url}", + "version": "Versjon", + "new_version_notice": "[NY]", + "release_date": "Utgivelsesdato:", + "stash_discord": "Bli med på vår {url} kanal", + "check_for_new_version": "Sjekk for ny versjon", + "latest_version": "Siste versjon", + "latest_version_build_hash": "Siste Versjon Build Hash:", + "build_time": "Kompileringstid:" + }, + "advanced_mode": "Avansert Modus", + "categories": { + "about": "Om", + "changelog": "Endringslogg", + "interface": "Grensesnitt", + "logs": "Logger", + "plugins": "Plugins", + "security": "Sikkerhet", + "services": "Tjenester", + "system": "System", + "metadata_providers": "Metadataleverandører", + "scraping": "Skarping", + "tools": "Verktøy", + "tasks": "Oppgaver" + }, + "general": { + "auth": { + "username": "Brukernavn", + "api_key": "API-nøkkel", + "log_file": "Log-fil", + "generate_api_key": "Generer API-nøkkel", + "log_to_terminal": "Log til terminal", + "password": "Passord" + }, + "db_path_head": "Database filbane", + "ffmpeg": { + "download_ffmpeg": { + "heading": "Last ned FFmpeg" + }, + "hardware_acceleration": { + "heading": "FFmpeg maskinvare encoding" + } + }, + "database": "Database" + } + }, + "appears_with": "Opptrer med", + "ascending": "Stigende", + "also_known_as": "Også kjent som", + "audio_codec": "Lyd Codec", + "average_resolution": "Gjennomsnittlig Oppløsning", + "actions_name": "Handlinger", + "age": "Alder", + "aliases": "Aliaser", + "birthdate": "Fødselsdato", + "bitrate": "Bithastighet", + "blobs_storage_type": { + "database": "Database", + "filesystem": "Filsystem" + }, + "captions": "Undertekster", + "career_length": "Karriere Lengde", + "chapters": "Kapitler", + "circumcised": "Omskåret", + "between_and": "og", + "circumcised_types": { + "CUT": "Omskåret", + "UNCUT": "Ikke omskåret" + }, + "birth_year": "Fødselsår" } diff --git a/ui/v2.5/src/locales/zh-CN.json b/ui/v2.5/src/locales/zh-CN.json index ce3aa6f178d..2262043efb6 100644 --- a/ui/v2.5/src/locales/zh-CN.json +++ b/ui/v2.5/src/locales/zh-CN.json @@ -140,8 +140,8 @@ "set_cover": "设置为封面", "reset_play_duration": "重置播放时长", "reset_resume_time": "重置恢复时间", - "add_sub_groups": "添加子群组", - "remove_from_containing_group": "从群组中移除" + "add_sub_groups": "添加子集合", + "remove_from_containing_group": "从集合中移除" }, "actions_name": "操作", "age": "年龄", @@ -807,7 +807,7 @@ "scenes": "{count, plural, one {短片} other {短片}}", "studios": "{count, plural, one {工作室} other {工作室}}", "tags": "{count, plural, one {标签} other {标签}}", - "groups": "{count, plural, one {群组} other {群组}}" + "groups": "{count, plural, one {集合} other {集合}}" }, "country": "国家", "cover_image": "封面图片", @@ -1476,19 +1476,20 @@ "o_count": "高潮次数", "studio_tags": "工作室标签", "studio_count": "工作室计数", - "group": "群组", - "group_count": "群组总计", + "group": "集合", + "group_count": "集合总计", "group_scene_number": "短片序号", - "groups": "群组", + "groups": "集合", "include_sub_studio_content": "包括子工作室内容", "include_sub_tag_content": "包括子标签内容", - "include_sub_group_content": "包括子群组内容", - "containing_group": "包含群组", - "containing_group_count": "包含群组计数", - "containing_groups": "包含群组", - "sub_group": "子群组", - "sub_group_count": "子群组计数", - "sub_group_of": "{parent}的子群组", - "sub_group_order": "子群组排序", - "sub_groups": "子群组" + "include_sub_group_content": "包括子集合内容", + "containing_group": "包含的集合", + "containing_group_count": "包含的集合计数", + "containing_groups": "被包含于集合", + "sub_group": "子集合", + "sub_group_count": "子集合计数", + "sub_group_of": "{parent}的子集合", + "sub_group_order": "子集合排序", + "sub_groups": "子集合", + "include_sub_groups": "包括子组" } From ad00bee393d43ebe1c1446d82f82a8720f463c06 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 10 Oct 2024 11:53:22 +1100 Subject: [PATCH 63/91] Update changelog for patch --- ui/v2.5/src/docs/en/Changelog/v0270.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/docs/en/Changelog/v0270.md b/ui/v2.5/src/docs/en/Changelog/v0270.md index cb8751e75af..d3e2473a0e9 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0270.md +++ b/ui/v2.5/src/docs/en/Changelog/v0270.md @@ -16,6 +16,8 @@ * Added option to rescan all files in the Scan task. ([#5254](https://github.com/stashapp/stash/pull/5254)) ### 🎨 Improvements +* **[0.27.1]** Live transcode requests are now debounced to spawn fewer `ffmpeg` instances while scrubbing. ([#5340](https://github.com/stashapp/stash/pull/5340)) +* **[0.27.1]** Blobs location may now be set using environment variable `STASH_BLOBS`. ([#5345](https://github.com/stashapp/stash/pull/5345)) * Added button to view sub-studio/sub-tag content on Studio/Tag details pages. ([#5080](https://github.com/stashapp/stash/pull/5080)) * Made tagger settings persistent. ([#5165](https://github.com/stashapp/stash/pull/5165)) * Added birthdate and age to Performer select. ([#5076](https://github.com/stashapp/stash/pull/5076)) @@ -29,7 +31,19 @@ * Scene Player now allows interacting with the controls before playing video, and errors no longer prevent interacting with the Scene Player. ([#5145](https://github.com/stashapp/stash/pull/5145)) ### 🐛 Bug fixes -* **[0.27.1]** Fixed dropdowns not displaying correctly in the merge dialogs. +* **[0.27.1]** Fixed UI infinite loop when sorting by random without a seed in the URL. ([#5319](https://github.com/stashapp/stash/pull/5319)) +* **[0.27.1]** Fixed dropdowns not displaying correctly in the merge dialogs. ([#5299](https://github.com/stashapp/stash/pull/5299)) +* **[0.27.1]** For single URLs, link icon now shows the dropdown menu instead of navigating to the URL. ([#5310](https://github.com/stashapp/stash/pull/5310)) +* **[0.27.1]** Fixed redirection when page > total pages to the last page instead of the first. ([#5321](https://github.com/stashapp/stash/pull/5321)) +* **[0.27.1]** Fixed display of rating criterion when using decimal rating system. ([#5334](https://github.com/stashapp/stash/pull/5334)) +* **[0.27.1]** Fixed parent/child Tags not showing in alphabetical order. ([#5320](https://github.com/stashapp/stash/pull/5320)) +* **[0.27.1]** Fixed performance issue when viewing studios where system has many images with no studios. ([#5335](https://github.com/stashapp/stash/pull/5335)) +* **[0.27.1]** Clicking on the video player timeline before video is started now plays the video from that point instead of playing from the beginning. ([#5340](https://github.com/stashapp/stash/pull/5340)) +* **[0.27.1]** Fixed UI crash when front page has filters using legacy `movies` scene filter. ([#5348](https://github.com/stashapp/stash/pull/5348)) +* **[0.27.1]** Restored legacy behaviour where selection is persisted when paging or changing filter. ([#5349](https://github.com/stashapp/stash/pull/5349)) +* **[0.27.1]** Fixed UI crash when navigating to image without files. ([#5325](https://github.com/stashapp/stash/pull/5325)) +* **[0.27.1]** Fixed panic when deleting image without files. ([#5328](https://github.com/stashapp/stash/pull/5328)) +* **[0.27.1]** Fixed matched performer and studio links not including base URL in Tagger. ([#5337](https://github.com/stashapp/stash/pull/5337)) * Fixed videos and images having incorrect dimensions when the orientation flag is set to a non-default value during scan. ([#5188](https://github.com/stashapp/stash/pull/5188), [#5189](https://github.com/stashapp/stash/pull/5189)) * Fixed mp4 videos being incorrectly transcoded when the file has opus audio codec. ([#5030](https://github.com/stashapp/stash/pull/5030)) * Fixed o-history being imported as view-history when importing from JSON. ([#5127](https://github.com/stashapp/stash/pull/5127)) From 32c48443b55c43b0f31de454d5d0163fd9866149 Mon Sep 17 00:00:00 2001 From: Arshad <49522121+arshad-k7@users.noreply.github.com> Date: Tue, 15 Oct 2024 07:40:47 +0530 Subject: [PATCH 64/91] adding exists check before dropping constraints (#5363) Co-authored-by: Arshad Khan <arshad@Arshads-MacBook-Air-2.local> --- pkg/sqlite/migrations/62_performer_urls.up.sql | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/sqlite/migrations/62_performer_urls.up.sql b/pkg/sqlite/migrations/62_performer_urls.up.sql index cebfa86d616..d3073463731 100644 --- a/pkg/sqlite/migrations/62_performer_urls.up.sql +++ b/pkg/sqlite/migrations/62_performer_urls.up.sql @@ -144,9 +144,9 @@ INSERT INTO `performer_urls` FROM `performers` WHERE `performers`.`instagram` IS NOT NULL AND `performers`.`instagram` != ''; -DROP INDEX `performers_name_disambiguation_unique`; -DROP INDEX `performers_name_unique`; -DROP TABLE `performers`; +DROP INDEX IF EXISTS `performers_name_disambiguation_unique`; +DROP INDEX IF EXISTS `performers_name_unique`; +DROP TABLE IF EXISTS `performers`; ALTER TABLE `performers_new` rename to `performers`; CREATE UNIQUE INDEX `performers_name_disambiguation_unique` on `performers` (`name`, `disambiguation`) WHERE `disambiguation` IS NOT NULL; From 5283eb8ce3f9e37aa013e27ad28fe217e421718e Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 15 Oct 2024 14:29:29 +1100 Subject: [PATCH 65/91] Fix duplicate items appearing in selected list (again) (#5377) * Fix duplicate detection in useListSelect * Prevent double invocation of select handler --- ui/v2.5/src/components/List/util.ts | 10 +++++----- ui/v2.5/src/components/Shared/GridCard/GridCard.tsx | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index addb5601238..329eba289b0 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -229,13 +229,13 @@ export function useListSelect<T extends { id: string }>(items: T[]) { function singleSelect(id: string, selected: boolean) { setLastClickedId(id); - // prevent duplicates - if (selected && selectedIds.has(id)) { - return; - } - setItemsSelected((prevSelected) => { if (selected) { + // prevent duplicates + if (prevSelected.some((v) => v.id === id)) { + return prevSelected; + } + const item = items.find((i) => i.id === id); if (item) { return [...prevSelected, item]; diff --git a/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx b/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx index 33aa24e32cd..db35f83f923 100644 --- a/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx +++ b/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx @@ -154,6 +154,7 @@ export const GridCard: React.FC<ICardProps> = (props: ICardProps) => { if (props.selecting) { props.onSelectedChanged(!props.selected, shiftKey); event.preventDefault(); + event.stopPropagation(); } } From 6d07ecf751b3620d7f63eaf6fa21cee861c52c38 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 15 Oct 2024 16:03:56 +1100 Subject: [PATCH 66/91] More scene player bug fixes (#5379) * Don't play video when seeking non-started video * Set initial time on load instead of play * Continue playing from current position when switching sources on error * Remove unnecessary ref --- .../components/ScenePlayer/ScenePlayer.tsx | 23 ++++--------------- .../components/ScenePlayer/source-selector.ts | 4 ++++ 2 files changed, 8 insertions(+), 19 deletions(-) diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index bd3f0052208..6858e2cd15b 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -243,7 +243,6 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({ const [fullscreen, setFullscreen] = useState(false); const [showScrubber, setShowScrubber] = useState(false); - const initialTimestamp = useRef(-1); const started = useRef(false); const auto = useRef(false); const interactiveReady = useRef(false); @@ -457,20 +456,6 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({ if (this.currentTime() >= 0.1) { return; } - - if (initialTimestamp.current !== -1) { - this.currentTime(initialTimestamp.current); - initialTimestamp.current = -1; - } - } - - function timeupdate(this: VideoJsPlayer) { - // fired when seeking - // check if we haven't started playing yet - // if so, start playing - if (!started.current) { - this.play(); - } } function playing(this: VideoJsPlayer) { @@ -493,14 +478,12 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({ player.on("playing", playing); player.on("loadstart", loadstart); player.on("fullscreenchange", fullscreenchange); - player.on("timeupdate", timeupdate); return () => { player.off("canplay", canplay); player.off("playing", playing); player.off("loadstart", loadstart); player.off("fullscreenchange", fullscreenchange); - player.off("timeupdate", timeupdate); }; }, [getPlayer]); @@ -675,7 +658,6 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({ startPosition = resumeTime; } - initialTimestamp.current = startPosition; setTime(startPosition); player.load(); @@ -683,6 +665,10 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({ player.ready(() => { player.vttThumbnails().src(scene.paths.vtt ?? null); + + if (startPosition) { + player.currentTime(startPosition); + } }); started.current = false; @@ -811,7 +797,6 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({ if (started.current) { getPlayer()?.currentTime(seconds); } else { - initialTimestamp.current = seconds; setTime(seconds); } } diff --git a/ui/v2.5/src/components/ScenePlayer/source-selector.ts b/ui/v2.5/src/components/ScenePlayer/source-selector.ts index 7cf6cfd757d..be1126ff9a6 100644 --- a/ui/v2.5/src/components/ScenePlayer/source-selector.ts +++ b/ui/v2.5/src/components/ScenePlayer/source-selector.ts @@ -196,8 +196,12 @@ class SourceSelectorPlugin extends videojs.getPlugin("plugin") { console.log(`Trying next source in playlist: '${newSource.label}'`); this.menu.setSelectedSource(newSource); + const currentTime = player.currentTime(); player.src(newSource); player.load(); + player.one("canplay", () => { + player.currentTime(currentTime); + }); player.play(); } else { console.log("No more sources in playlist"); From 76648fee66bce1356dbce2de5871205f68f6f3f0 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 16 Oct 2024 08:08:37 +1100 Subject: [PATCH 67/91] Update changelog for patch release --- ui/v2.5/src/docs/en/Changelog/v0270.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ui/v2.5/src/docs/en/Changelog/v0270.md b/ui/v2.5/src/docs/en/Changelog/v0270.md index d3e2473a0e9..24ebd6fbe26 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0270.md +++ b/ui/v2.5/src/docs/en/Changelog/v0270.md @@ -16,6 +16,7 @@ * Added option to rescan all files in the Scan task. ([#5254](https://github.com/stashapp/stash/pull/5254)) ### 🎨 Improvements +* **[0.27.2]** Scene player now shows the starting position when resume time is set. ([#5379](https://github.com/stashapp/stash/pull/5379)) * **[0.27.1]** Live transcode requests are now debounced to spawn fewer `ffmpeg` instances while scrubbing. ([#5340](https://github.com/stashapp/stash/pull/5340)) * **[0.27.1]** Blobs location may now be set using environment variable `STASH_BLOBS`. ([#5345](https://github.com/stashapp/stash/pull/5345)) * Added button to view sub-studio/sub-tag content on Studio/Tag details pages. ([#5080](https://github.com/stashapp/stash/pull/5080)) @@ -31,6 +32,11 @@ * Scene Player now allows interacting with the controls before playing video, and errors no longer prevent interacting with the Scene Player. ([#5145](https://github.com/stashapp/stash/pull/5145)) ### 🐛 Bug fixes +* **[0.27.2]** Fixed items being selected twice when selecting items in the Grid list. ([#5377](https://github.com/stashapp/stash/pull/5377)) +* **[0.27.2]** Fixed 62 migration error for some users. ([#5363](https://github.com/stashapp/stash/pull/5363)) +* **[0.27.2]** Fixed scenes incorrectly autoplaying on queue selection. ([#5379](https://github.com/stashapp/stash/pull/5379)) +* **[0.27.2]** Videos no longer begin playing when seeking before video has started. ([#5379](https://github.com/stashapp/stash/pull/5379)) +* **[0.27.2]** Videos will now resume from the correct time when switching sources due to error. ([#5379](https://github.com/stashapp/stash/pull/5379)) * **[0.27.1]** Fixed UI infinite loop when sorting by random without a seed in the URL. ([#5319](https://github.com/stashapp/stash/pull/5319)) * **[0.27.1]** Fixed dropdowns not displaying correctly in the merge dialogs. ([#5299](https://github.com/stashapp/stash/pull/5299)) * **[0.27.1]** For single URLs, link icon now shows the dropdown menu instead of navigating to the URL. ([#5310](https://github.com/stashapp/stash/pull/5310)) From 33ca4f88876c749ff61d1bbca46566cd5423ce75 Mon Sep 17 00:00:00 2001 From: feederbox826 <144178721+feederbox826@users.noreply.github.com> Date: Mon, 28 Oct 2024 19:14:52 -0400 Subject: [PATCH 68/91] remove bencoder.pyx, update vips (#5416) * remove bencoder.pyx * revert vips downgrade --------- Co-authored-by: feederbox826 <feederbox826@users.noreply.github.com> --- docker/ci/x86_64/Dockerfile | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/docker/ci/x86_64/Dockerfile b/docker/ci/x86_64/Dockerfile index 2c7fb0d8d7b..f0f1e242b78 100644 --- a/docker/ci/x86_64/Dockerfile +++ b/docker/ci/x86_64/Dockerfile @@ -12,15 +12,9 @@ RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then BIN=stash-linux-arm32v6; \ FROM --platform=$TARGETPLATFORM alpine:latest AS app COPY --from=binary /stash /usr/bin/ -# vips version 8.15.0-r0 breaks thumbnail generation on arm32v6 -# need to use 8.14.3-r0 from alpine 3.18 instead - -RUN apk add --no-cache --virtual .build-deps gcc python3-dev musl-dev \ - && apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg ruby tzdata \ - && apk add --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/v3.18/community vips=8.14.3-r0 vips-tools=8.14.3-r0 \ - && pip install --user --break-system-packages mechanicalsoup cloudscraper bencoder.pyx stashapp-tools \ - && gem install faraday \ - && apk del .build-deps +RUN apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg ruby tzdata vips vips-tools \ + && pip install --user --break-system-packages mechanicalsoup cloudscraper stashapp-tools \ + && gem install faraday ENV STASH_CONFIG_FILE=/root/.stash/config.yml # Basic build-time metadata as defined at https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys From eca41dc7b4a11dcf34bc3d93dabdcd38873067ae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 29 Oct 2024 11:03:43 +1100 Subject: [PATCH 69/91] Bump vite from 4.5.3 to 4.5.5 in /ui/v2.5 (#5270) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 4.5.3 to 4.5.5. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v4.5.5/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v4.5.5/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-type: direct:development ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- ui/v2.5/package.json | 2 +- ui/v2.5/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/v2.5/package.json b/ui/v2.5/package.json index ca68160622b..210b750fe0c 100644 --- a/ui/v2.5/package.json +++ b/ui/v2.5/package.json @@ -125,7 +125,7 @@ "terser": "^5.9.0", "ts-node": "^10.9.1", "typescript": "~4.8.4", - "vite": "^4.5.3", + "vite": "^4.5.5", "vite-plugin-compression": "^0.5.1", "vite-tsconfig-paths": "^4.0.5" } diff --git a/ui/v2.5/yarn.lock b/ui/v2.5/yarn.lock index 911fa135f20..2a75ab76a93 100644 --- a/ui/v2.5/yarn.lock +++ b/ui/v2.5/yarn.lock @@ -8008,10 +8008,10 @@ vite-tsconfig-paths@^4.0.5: globrex "^0.1.2" tsconfck "^2.0.1" -vite@^4.5.3: - version "4.5.3" - resolved "https://registry.yarnpkg.com/vite/-/vite-4.5.3.tgz#d88a4529ea58bae97294c7e2e6f0eab39a50fb1a" - integrity sha512-kQL23kMeX92v3ph7IauVkXkikdDRsYMGTVl5KY2E9OY4ONLvkHf04MDTbnfo6NKxZiDLWzVpP5oTa8hQD8U3dg== +vite@^4.5.5: + version "4.5.5" + resolved "https://registry.yarnpkg.com/vite/-/vite-4.5.5.tgz#639b9feca5c0a3bfe3c60cb630ef28bf219d742e" + integrity sha512-ifW3Lb2sMdX+WU91s3R0FyQlAyLxOzCSCP37ujw0+r5POeHPwe6udWVIElKQq8gk3t7b8rkmvqC6IHBpCff4GQ== dependencies: esbuild "^0.18.10" postcss "^8.4.27" From 33e46bad6492c08b5afeb04ef40f23b6a11acc00 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 29 Oct 2024 11:06:58 +1100 Subject: [PATCH 70/91] Bump path-to-regexp from 1.8.0 to 1.9.0 in /ui/v2.5 (#5257) Bumps [path-to-regexp](https://github.com/pillarjs/path-to-regexp) from 1.8.0 to 1.9.0. - [Release notes](https://github.com/pillarjs/path-to-regexp/releases) - [Changelog](https://github.com/pillarjs/path-to-regexp/blob/master/History.md) - [Commits](https://github.com/pillarjs/path-to-regexp/compare/v1.8.0...v1.9.0) --- updated-dependencies: - dependency-name: path-to-regexp dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- ui/v2.5/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/yarn.lock b/ui/v2.5/yarn.lock index 2a75ab76a93..ccd35256036 100644 --- a/ui/v2.5/yarn.lock +++ b/ui/v2.5/yarn.lock @@ -6224,9 +6224,9 @@ path-root@^0.1.1: path-root-regex "^0.1.0" path-to-regexp@^1.7.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" - integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA== + version "1.9.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.9.0.tgz#5dc0753acbf8521ca2e0f137b4578b917b10cf24" + integrity sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g== dependencies: isarray "0.0.1" From 8c5ebf37973ba3455001c45c9faa4d112545e814 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 29 Oct 2024 11:09:41 +1100 Subject: [PATCH 71/91] Bump dset from 3.1.2 to 3.1.4 in /ui/v2.5 (#5258) Bumps [dset](https://github.com/lukeed/dset) from 3.1.2 to 3.1.4. - [Release notes](https://github.com/lukeed/dset/releases) - [Commits](https://github.com/lukeed/dset/compare/v3.1.2...v3.1.4) --- updated-dependencies: - dependency-name: dset dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- ui/v2.5/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/yarn.lock b/ui/v2.5/yarn.lock index ccd35256036..69d14e2f623 100644 --- a/ui/v2.5/yarn.lock +++ b/ui/v2.5/yarn.lock @@ -3718,9 +3718,9 @@ dotenv@^16.0.0: integrity sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ== dset@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/dset/-/dset-3.1.2.tgz#89c436ca6450398396dc6538ea00abc0c54cd45a" - integrity sha512-g/M9sqy3oHe477Ar4voQxWtaPIFw1jTdKZuomOjhCcBx9nHUNn0pu6NopuFFrTh/TRZIKEj+76vLWFu9BNKk+Q== + version "3.1.4" + resolved "https://registry.yarnpkg.com/dset/-/dset-3.1.4.tgz#f8eaf5f023f068a036d08cd07dc9ffb7d0065248" + integrity sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA== electron-to-chromium@^1.4.648: version "1.4.648" From 093de3bce2625b64004f2e3c89b655c185300a5a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 29 Oct 2024 11:11:30 +1100 Subject: [PATCH 72/91] Bump rollup from 3.29.4 to 3.29.5 in /ui/v2.5 (#5305) Bumps [rollup](https://github.com/rollup/rollup) from 3.29.4 to 3.29.5. - [Release notes](https://github.com/rollup/rollup/releases) - [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md) - [Commits](https://github.com/rollup/rollup/compare/v3.29.4...v3.29.5) --- updated-dependencies: - dependency-name: rollup dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- ui/v2.5/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/yarn.lock b/ui/v2.5/yarn.lock index 69d14e2f623..733e10d2565 100644 --- a/ui/v2.5/yarn.lock +++ b/ui/v2.5/yarn.lock @@ -6894,9 +6894,9 @@ rimraf@^3.0.2: glob "^7.1.3" rollup@^3.27.1: - version "3.29.4" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.29.4.tgz#4d70c0f9834146df8705bfb69a9a19c9e1109981" - integrity sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw== + version "3.29.5" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.29.5.tgz#8a2e477a758b520fb78daf04bca4c522c1da8a54" + integrity sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w== optionalDependencies: fsevents "~2.3.2" From c6bcdd89be67399221a3e217fc8deb185d361451 Mon Sep 17 00:00:00 2001 From: its-josh4 <74079536+its-josh4@users.noreply.github.com> Date: Mon, 28 Oct 2024 17:26:23 -0700 Subject: [PATCH 73/91] Use slices package from the stdlib when possible (#5360) * Use slices from the stdlib when possible * Add some unit tests * More small tweaks + add benchmark func --- internal/api/resolver_query_find_image.go | 8 +- internal/api/resolver_query_find_scene.go | 14 ++-- internal/api/resolver_query_package.go | 4 +- internal/autotag/gallery.go | 6 +- internal/autotag/image.go | 6 +- internal/autotag/performer.go | 8 +- internal/autotag/scene.go | 6 +- internal/autotag/tag.go | 8 +- internal/dlna/cds.go | 4 +- internal/dlna/whitelist.go | 5 +- internal/identify/identify.go | 3 +- internal/identify/identify_test.go | 4 +- pkg/gallery/import.go | 5 +- pkg/group/import.go | 3 +- pkg/group/validate.go | 3 +- pkg/image/import.go | 5 +- pkg/image/scan.go | 4 +- pkg/performer/import.go | 3 +- pkg/plugin/plugins.go | 10 +-- pkg/scene/import.go | 5 +- pkg/scene/merge.go | 3 +- pkg/sliceutil/collections.go | 48 +++++------ pkg/sliceutil/collections_test.go | 83 +++++++++++++++++++ .../stringslice/string_collections.go | 4 +- pkg/sqlite/gallery.go | 4 +- pkg/sqlite/gallery_chapter.go | 4 +- pkg/sqlite/group.go | 4 +- pkg/sqlite/group_test.go | 9 +- pkg/sqlite/image.go | 3 +- pkg/sqlite/performer.go | 4 +- pkg/sqlite/saved_filter.go | 4 +- pkg/sqlite/scene.go | 3 +- pkg/sqlite/scene_marker.go | 4 +- pkg/sqlite/scene_marker_test.go | 4 +- pkg/sqlite/setup_test.go | 4 +- pkg/sqlite/studio.go | 4 +- pkg/sqlite/tag.go | 4 +- pkg/studio/import.go | 3 +- 38 files changed, 200 insertions(+), 110 deletions(-) diff --git a/internal/api/resolver_query_find_image.go b/internal/api/resolver_query_find_image.go index b3d674613a8..48b926345de 100644 --- a/internal/api/resolver_query_find_image.go +++ b/internal/api/resolver_query_find_image.go @@ -2,11 +2,11 @@ package api import ( "context" + "slices" "strconv" "github.com/99designs/gqlgen/graphql" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) @@ -95,11 +95,11 @@ func (r *queryResolver) FindImages( result, err = qb.Query(ctx, models.ImageQueryOptions{ QueryOptions: models.QueryOptions{ FindFilter: filter, - Count: sliceutil.Contains(fields, "count"), + Count: slices.Contains(fields, "count"), }, ImageFilter: imageFilter, - Megapixels: sliceutil.Contains(fields, "megapixels"), - TotalSize: sliceutil.Contains(fields, "filesize"), + Megapixels: slices.Contains(fields, "megapixels"), + TotalSize: slices.Contains(fields, "filesize"), }) if err == nil { images, err = result.Resolve(ctx) diff --git a/internal/api/resolver_query_find_scene.go b/internal/api/resolver_query_find_scene.go index 0ea35a490e5..44b5cfd5ee5 100644 --- a/internal/api/resolver_query_find_scene.go +++ b/internal/api/resolver_query_find_scene.go @@ -2,13 +2,13 @@ package api import ( "context" + "slices" "strconv" "github.com/99designs/gqlgen/graphql" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" - "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) @@ -119,11 +119,11 @@ func (r *queryResolver) FindScenes( result, err = r.repository.Scene.Query(ctx, models.SceneQueryOptions{ QueryOptions: models.QueryOptions{ FindFilter: filter, - Count: sliceutil.Contains(fields, "count"), + Count: slices.Contains(fields, "count"), }, SceneFilter: sceneFilter, - TotalDuration: sliceutil.Contains(fields, "duration"), - TotalSize: sliceutil.Contains(fields, "filesize"), + TotalDuration: slices.Contains(fields, "duration"), + TotalSize: slices.Contains(fields, "filesize"), }) if err == nil { scenes, err = result.Resolve(ctx) @@ -174,11 +174,11 @@ func (r *queryResolver) FindScenesByPathRegex(ctx context.Context, filter *model result, err := r.repository.Scene.Query(ctx, models.SceneQueryOptions{ QueryOptions: models.QueryOptions{ FindFilter: queryFilter, - Count: sliceutil.Contains(fields, "count"), + Count: slices.Contains(fields, "count"), }, SceneFilter: sceneFilter, - TotalDuration: sliceutil.Contains(fields, "duration"), - TotalSize: sliceutil.Contains(fields, "filesize"), + TotalDuration: slices.Contains(fields, "duration"), + TotalSize: slices.Contains(fields, "filesize"), }) if err != nil { return err diff --git a/internal/api/resolver_query_package.go b/internal/api/resolver_query_package.go index 5a42221d476..7e772413263 100644 --- a/internal/api/resolver_query_package.go +++ b/internal/api/resolver_query_package.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "sort" "strings" @@ -11,7 +12,6 @@ import ( "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/pkg" - "github.com/stashapp/stash/pkg/sliceutil" ) var ErrInvalidPackageType = errors.New("invalid package type") @@ -166,7 +166,7 @@ func (r *queryResolver) InstalledPackages(ctx context.Context, typeArg PackageTy var ret []*Package - if sliceutil.Contains(graphql.CollectAllFields(ctx), "source_package") { + if slices.Contains(graphql.CollectAllFields(ctx), "source_package") { ret, err = r.getInstalledPackagesWithUpgrades(ctx, pm) if err != nil { return nil, err diff --git a/internal/autotag/gallery.go b/internal/autotag/gallery.go index cbf5ebf0919..031079e494b 100644 --- a/internal/autotag/gallery.go +++ b/internal/autotag/gallery.go @@ -2,11 +2,11 @@ package autotag import ( "context" + "slices" "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/sliceutil" ) type GalleryFinderUpdater interface { @@ -53,7 +53,7 @@ func GalleryPerformers(ctx context.Context, s *models.Gallery, rw GalleryPerform } existing := s.PerformerIDs.List() - if sliceutil.Contains(existing, otherID) { + if slices.Contains(existing, otherID) { return false, nil } @@ -91,7 +91,7 @@ func GalleryTags(ctx context.Context, s *models.Gallery, rw GalleryTagUpdater, t } existing := s.TagIDs.List() - if sliceutil.Contains(existing, otherID) { + if slices.Contains(existing, otherID) { return false, nil } diff --git a/internal/autotag/image.go b/internal/autotag/image.go index 63544123ab4..e4acbcd3af6 100644 --- a/internal/autotag/image.go +++ b/internal/autotag/image.go @@ -2,11 +2,11 @@ package autotag import ( "context" + "slices" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/sliceutil" ) type ImageFinderUpdater interface { @@ -44,7 +44,7 @@ func ImagePerformers(ctx context.Context, s *models.Image, rw ImagePerformerUpda } existing := s.PerformerIDs.List() - if sliceutil.Contains(existing, otherID) { + if slices.Contains(existing, otherID) { return false, nil } @@ -82,7 +82,7 @@ func ImageTags(ctx context.Context, s *models.Image, rw ImageTagUpdater, tagRead } existing := s.TagIDs.List() - if sliceutil.Contains(existing, otherID) { + if slices.Contains(existing, otherID) { return false, nil } diff --git a/internal/autotag/performer.go b/internal/autotag/performer.go index 12dac0e9344..7badda39047 100644 --- a/internal/autotag/performer.go +++ b/internal/autotag/performer.go @@ -2,13 +2,13 @@ package autotag import ( "context" + "slices" "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" - "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/txn" ) @@ -63,7 +63,7 @@ func (tagger *Tagger) PerformerScenes(ctx context.Context, p *models.Performer, } existing := o.PerformerIDs.List() - if sliceutil.Contains(existing, p.ID) { + if slices.Contains(existing, p.ID) { return false, nil } @@ -92,7 +92,7 @@ func (tagger *Tagger) PerformerImages(ctx context.Context, p *models.Performer, } existing := o.PerformerIDs.List() - if sliceutil.Contains(existing, p.ID) { + if slices.Contains(existing, p.ID) { return false, nil } @@ -121,7 +121,7 @@ func (tagger *Tagger) PerformerGalleries(ctx context.Context, p *models.Performe } existing := o.PerformerIDs.List() - if sliceutil.Contains(existing, p.ID) { + if slices.Contains(existing, p.ID) { return false, nil } diff --git a/internal/autotag/scene.go b/internal/autotag/scene.go index 751d0ed62c3..273378b9bc1 100644 --- a/internal/autotag/scene.go +++ b/internal/autotag/scene.go @@ -2,11 +2,11 @@ package autotag import ( "context" + "slices" "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" - "github.com/stashapp/stash/pkg/sliceutil" ) type SceneFinderUpdater interface { @@ -44,7 +44,7 @@ func ScenePerformers(ctx context.Context, s *models.Scene, rw ScenePerformerUpda } existing := s.PerformerIDs.List() - if sliceutil.Contains(existing, otherID) { + if slices.Contains(existing, otherID) { return false, nil } @@ -82,7 +82,7 @@ func SceneTags(ctx context.Context, s *models.Scene, rw SceneTagUpdater, tagRead } existing := s.TagIDs.List() - if sliceutil.Contains(existing, otherID) { + if slices.Contains(existing, otherID) { return false, nil } diff --git a/internal/autotag/tag.go b/internal/autotag/tag.go index 5b1b5c319b2..4ebbf28a31d 100644 --- a/internal/autotag/tag.go +++ b/internal/autotag/tag.go @@ -2,13 +2,13 @@ package autotag import ( "context" + "slices" "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" - "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/txn" ) @@ -61,7 +61,7 @@ func (tagger *Tagger) TagScenes(ctx context.Context, p *models.Tag, paths []stri } existing := o.TagIDs.List() - if sliceutil.Contains(existing, p.ID) { + if slices.Contains(existing, p.ID) { return false, nil } @@ -90,7 +90,7 @@ func (tagger *Tagger) TagImages(ctx context.Context, p *models.Tag, paths []stri } existing := o.TagIDs.List() - if sliceutil.Contains(existing, p.ID) { + if slices.Contains(existing, p.ID) { return false, nil } @@ -119,7 +119,7 @@ func (tagger *Tagger) TagGalleries(ctx context.Context, p *models.Tag, paths []s } existing := o.TagIDs.List() - if sliceutil.Contains(existing, p.ID) { + if slices.Contains(existing, p.ID) { return false, nil } diff --git a/internal/dlna/cds.go b/internal/dlna/cds.go index a38e0e55bed..034ebbbc17f 100644 --- a/internal/dlna/cds.go +++ b/internal/dlna/cds.go @@ -30,6 +30,7 @@ import ( "os" "path" "path/filepath" + "slices" "strconv" "strings" "time" @@ -40,7 +41,6 @@ import ( "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" - "github.com/stashapp/stash/pkg/sliceutil" ) var pageSize = 100 @@ -521,7 +521,7 @@ func (me *contentDirectoryService) getPageVideos(sceneFilter *models.SceneFilter } func getPageFromID(paths []string) *int { - i := sliceutil.Index(paths, "page") + i := slices.Index(paths, "page") if i == -1 || i+1 >= len(paths) { return nil } diff --git a/internal/dlna/whitelist.go b/internal/dlna/whitelist.go index 609ecd38f4c..e423ec58b37 100644 --- a/internal/dlna/whitelist.go +++ b/internal/dlna/whitelist.go @@ -1,10 +1,9 @@ package dlna import ( + "slices" "sync" "time" - - "github.com/stashapp/stash/pkg/sliceutil" ) // only keep the 10 most recent IP addresses @@ -30,7 +29,7 @@ func (m *ipWhitelistManager) addRecent(addr string) bool { m.mutex.Lock() defer m.mutex.Unlock() - i := sliceutil.Index(m.recentIPAddresses, addr) + i := slices.Index(m.recentIPAddresses, addr) if i != -1 { if i == 0 { // don't do anything if it's already at the start diff --git a/internal/identify/identify.go b/internal/identify/identify.go index 5eecd0d9927..dca1a68d71b 100644 --- a/internal/identify/identify.go +++ b/internal/identify/identify.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "slices" "strconv" "github.com/stashapp/stash/pkg/logger" @@ -333,7 +334,7 @@ func (t *SceneIdentifier) addTagToScene(ctx context.Context, s *models.Scene, ta } existing := s.TagIDs.List() - if sliceutil.Contains(existing, tagID) { + if slices.Contains(existing, tagID) { // skip if the scene was already tagged return nil } diff --git a/internal/identify/identify_test.go b/internal/identify/identify_test.go index 5dc339eace6..4d8c6e21231 100644 --- a/internal/identify/identify_test.go +++ b/internal/identify/identify_test.go @@ -4,13 +4,13 @@ import ( "context" "errors" "reflect" + "slices" "strconv" "testing" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stashapp/stash/pkg/scraper" - "github.com/stashapp/stash/pkg/sliceutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) @@ -23,7 +23,7 @@ type mockSceneScraper struct { } func (s mockSceneScraper) ScrapeScenes(ctx context.Context, sceneID int) ([]*scraper.ScrapedScene, error) { - if sliceutil.Contains(s.errIDs, sceneID) { + if slices.Contains(s.errIDs, sceneID) { return nil, errors.New("scrape scene error") } return s.results[sceneID], nil diff --git a/pkg/gallery/import.go b/pkg/gallery/import.go index c332e0ce043..aaf37bd27e4 100644 --- a/pkg/gallery/import.go +++ b/pkg/gallery/import.go @@ -3,6 +3,7 @@ package gallery import ( "context" "fmt" + "slices" "strings" "github.com/stashapp/stash/pkg/models" @@ -153,7 +154,7 @@ func (i *Importer) populatePerformers(ctx context.Context) error { } missingPerformers := sliceutil.Filter(names, func(name string) bool { - return !sliceutil.Contains(pluckedNames, name) + return !slices.Contains(pluckedNames, name) }) if len(missingPerformers) > 0 { @@ -212,7 +213,7 @@ func (i *Importer) populateTags(ctx context.Context) error { } missingTags := sliceutil.Filter(names, func(name string) bool { - return !sliceutil.Contains(pluckedNames, name) + return !slices.Contains(pluckedNames, name) }) if len(missingTags) > 0 { diff --git a/pkg/group/import.go b/pkg/group/import.go index 589e75df30d..3fc7db8f15a 100644 --- a/pkg/group/import.go +++ b/pkg/group/import.go @@ -3,6 +3,7 @@ package group import ( "context" "fmt" + "slices" "strings" "github.com/stashapp/stash/pkg/models" @@ -96,7 +97,7 @@ func importTags(ctx context.Context, tagWriter models.TagFinderCreator, names [] } missingTags := sliceutil.Filter(names, func(name string) bool { - return !sliceutil.Contains(pluckedNames, name) + return !slices.Contains(pluckedNames, name) }) if len(missingTags) > 0 { diff --git a/pkg/group/validate.go b/pkg/group/validate.go index 723b9f6997a..255152a9577 100644 --- a/pkg/group/validate.go +++ b/pkg/group/validate.go @@ -2,6 +2,7 @@ package group import ( "context" + "slices" "strings" "github.com/stashapp/stash/pkg/models" @@ -105,7 +106,7 @@ func (s *Service) validateUpdateGroupHierarchy(ctx context.Context, existing *mo subIDs := idsFromGroupDescriptions(effectiveSubGroups) // ensure we haven't set the group as a subgroup of itself - if sliceutil.Contains(containingIDs, existing.ID) || sliceutil.Contains(subIDs, existing.ID) { + if slices.Contains(containingIDs, existing.ID) || slices.Contains(subIDs, existing.ID) { return ErrHierarchyLoop } diff --git a/pkg/image/import.go b/pkg/image/import.go index fa8fe21610e..660eb1da18d 100644 --- a/pkg/image/import.go +++ b/pkg/image/import.go @@ -3,6 +3,7 @@ package image import ( "context" "fmt" + "slices" "strings" "github.com/stashapp/stash/pkg/models" @@ -239,7 +240,7 @@ func (i *Importer) populatePerformers(ctx context.Context) error { } missingPerformers := sliceutil.Filter(names, func(name string) bool { - return !sliceutil.Contains(pluckedNames, name) + return !slices.Contains(pluckedNames, name) }) if len(missingPerformers) > 0 { @@ -375,7 +376,7 @@ func importTags(ctx context.Context, tagWriter models.TagFinderCreator, names [] } missingTags := sliceutil.Filter(names, func(name string) bool { - return !sliceutil.Contains(pluckedNames, name) + return !slices.Contains(pluckedNames, name) }) if len(missingTags) > 0 { diff --git a/pkg/image/scan.go b/pkg/image/scan.go index b388a814518..a6002057f41 100644 --- a/pkg/image/scan.go +++ b/pkg/image/scan.go @@ -6,13 +6,13 @@ import ( "fmt" "os" "path/filepath" + "slices" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/paths" "github.com/stashapp/stash/pkg/plugin" "github.com/stashapp/stash/pkg/plugin/hook" - "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/txn" ) @@ -356,7 +356,7 @@ func (h *ScanHandler) getGalleryToAssociate(ctx context.Context, newImage *model return nil, err } - if g != nil && !sliceutil.Contains(newImage.GalleryIDs.List(), g.ID) { + if g != nil && !slices.Contains(newImage.GalleryIDs.List(), g.ID) { return g, nil } diff --git a/pkg/performer/import.go b/pkg/performer/import.go index d50384fa3d3..49a2ce291ae 100644 --- a/pkg/performer/import.go +++ b/pkg/performer/import.go @@ -3,6 +3,7 @@ package performer import ( "context" "fmt" + "slices" "strconv" "strings" @@ -75,7 +76,7 @@ func importTags(ctx context.Context, tagWriter models.TagFinderCreator, names [] } missingTags := sliceutil.Filter(names, func(name string) bool { - return !sliceutil.Contains(pluckedNames, name) + return !slices.Contains(pluckedNames, name) }) if len(missingTags) > 0 { diff --git a/pkg/plugin/plugins.go b/pkg/plugin/plugins.go index 38cde68bccc..9671f890195 100644 --- a/pkg/plugin/plugins.go +++ b/pkg/plugin/plugins.go @@ -14,6 +14,7 @@ import ( "net/http" "os" "path/filepath" + "slices" "strconv" "strings" @@ -23,7 +24,6 @@ import ( "github.com/stashapp/stash/pkg/plugin/common" "github.com/stashapp/stash/pkg/plugin/hook" "github.com/stashapp/stash/pkg/session" - "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/txn" "github.com/stashapp/stash/pkg/utils" ) @@ -168,7 +168,7 @@ func (c Cache) enabledPlugins() []Config { var ret []Config for _, p := range c.plugins { - disabled := sliceutil.Contains(disabledPlugins, p.id) + disabled := slices.Contains(disabledPlugins, p.id) if !disabled { ret = append(ret, p) @@ -181,7 +181,7 @@ func (c Cache) enabledPlugins() []Config { func (c Cache) pluginDisabled(id string) bool { disabledPlugins := c.config.GetDisabledPlugins() - return sliceutil.Contains(disabledPlugins, id) + return slices.Contains(disabledPlugins, id) } // ListPlugins returns plugin details for all of the loaded plugins. @@ -192,7 +192,7 @@ func (c Cache) ListPlugins() []*Plugin { for _, s := range c.plugins { p := s.toPlugin() - disabled := sliceutil.Contains(disabledPlugins, p.ID) + disabled := slices.Contains(disabledPlugins, p.ID) p.Enabled = !disabled ret = append(ret, p) @@ -209,7 +209,7 @@ func (c Cache) GetPlugin(id string) *Plugin { if plugin != nil { p := plugin.toPlugin() - disabled := sliceutil.Contains(disabledPlugins, p.ID) + disabled := slices.Contains(disabledPlugins, p.ID) p.Enabled = !disabled return p } diff --git a/pkg/scene/import.go b/pkg/scene/import.go index b36e1bd68ab..c1b065bcf8a 100644 --- a/pkg/scene/import.go +++ b/pkg/scene/import.go @@ -3,6 +3,7 @@ package scene import ( "context" "fmt" + "slices" "strings" "time" @@ -290,7 +291,7 @@ func (i *Importer) populatePerformers(ctx context.Context) error { } missingPerformers := sliceutil.Filter(names, func(name string) bool { - return !sliceutil.Contains(pluckedNames, name) + return !slices.Contains(pluckedNames, name) }) if len(missingPerformers) > 0 { @@ -517,7 +518,7 @@ func importTags(ctx context.Context, tagWriter models.TagFinderCreator, names [] } missingTags := sliceutil.Filter(names, func(name string) bool { - return !sliceutil.Contains(pluckedNames, name) + return !slices.Contains(pluckedNames, name) }) if len(missingTags) > 0 { diff --git a/pkg/scene/merge.go b/pkg/scene/merge.go index e7c9ab2f70d..77b551ab27e 100644 --- a/pkg/scene/merge.go +++ b/pkg/scene/merge.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "time" "github.com/stashapp/stash/pkg/fsutil" @@ -28,7 +29,7 @@ func (s *Service) Merge(ctx context.Context, sourceIDs []int, destinationID int, sourceIDs = sliceutil.AppendUniques(nil, sourceIDs) // ensure destination is not in source list - if sliceutil.Contains(sourceIDs, destinationID) { + if slices.Contains(sourceIDs, destinationID) { return errors.New("destination scene cannot be in source list") } diff --git a/pkg/sliceutil/collections.go b/pkg/sliceutil/collections.go index 18930df259e..eff28fc40bc 100644 --- a/pkg/sliceutil/collections.go +++ b/pkg/sliceutil/collections.go @@ -1,26 +1,14 @@ // Package sliceutil provides utilities for working with slices. package sliceutil -// Index returns the first index of the provided value in the provided -// slice. It returns -1 if it is not found. -func Index[T comparable](vs []T, t T) int { - for i, v := range vs { - if v == t { - return i - } - } - return -1 -} - -// Contains returns whether the vs slice contains t. -func Contains[T comparable](vs []T, t T) bool { - return Index(vs, t) >= 0 -} +import ( + "slices" +) // AppendUnique appends toAdd to the vs slice if toAdd does not already // exist in the slice. It returns the new or unchanged slice. func AppendUnique[T comparable](vs []T, toAdd T) []T { - if Contains(vs, toAdd) { + if slices.Contains(vs, toAdd) { return vs } @@ -31,6 +19,13 @@ func AppendUnique[T comparable](vs []T, toAdd T) []T { // appends values that do not already exist in the slice. // It returns the new or unchanged slice. func AppendUniques[T comparable](vs []T, toAdd []T) []T { + if len(toAdd) == 0 { + return vs + } + + // Extend the slice's capacity to avoid multiple re-allocations even in the worst case + vs = slices.Grow(vs, len(toAdd)) + for _, v := range toAdd { vs = AppendUnique(vs, v) } @@ -41,9 +36,9 @@ func AppendUniques[T comparable](vs []T, toAdd []T) []T { // Exclude returns a copy of the vs slice, excluding all values // that are also present in the toExclude slice. func Exclude[T comparable](vs []T, toExclude []T) []T { - var ret []T + ret := make([]T, 0, len(vs)) for _, v := range vs { - if !Contains(toExclude, v) { + if !slices.Contains(toExclude, v) { ret = append(ret, v) } } @@ -53,8 +48,8 @@ func Exclude[T comparable](vs []T, toExclude []T) []T { // Unique returns a copy of the vs slice, with non-unique values removed. func Unique[T comparable](vs []T) []T { - distinctValues := make(map[T]struct{}) - var ret []T + distinctValues := make(map[T]struct{}, len(vs)) + ret := make([]T, 0, len(vs)) for _, v := range vs { if _, exists := distinctValues[v]; !exists { distinctValues[v] = struct{}{} @@ -66,7 +61,7 @@ func Unique[T comparable](vs []T) []T { // Delete returns a copy of the vs slice with toDel values removed. func Delete[T comparable](vs []T, toDel T) []T { - var ret []T + ret := make([]T, 0, len(vs)) for _, v := range vs { if v != toDel { ret = append(ret, v) @@ -79,7 +74,7 @@ func Delete[T comparable](vs []T, toDel T) []T { func Intersect[T comparable](a []T, b []T) []T { var ret []T for _, v := range a { - if Contains(b, v) { + if slices.Contains(b, v) { ret = append(ret, v) } } @@ -91,13 +86,13 @@ func Intersect[T comparable](a []T, b []T) []T { func NotIntersect[T comparable](a []T, b []T) []T { var ret []T for _, v := range a { - if !Contains(b, v) { + if !slices.Contains(b, v) { ret = append(ret, v) } } for _, v := range b { - if !Contains(a, v) { + if !slices.Contains(a, v) { ret = append(ret, v) } } @@ -166,8 +161,9 @@ func PtrsToValues[T any](vs []*T) []T { func ValuesToPtrs[T any](vs []T) []*T { ret := make([]*T, len(vs)) for i, v := range vs { - vv := v - ret[i] = &vv + // We can do this safely because go.mod indicates Go 1.22 + // See: https://go.dev/blog/loopvar-preview + ret[i] = &v } return ret } diff --git a/pkg/sliceutil/collections_test.go b/pkg/sliceutil/collections_test.go index 70d34946fce..ab739607eb3 100644 --- a/pkg/sliceutil/collections_test.go +++ b/pkg/sliceutil/collections_test.go @@ -1,6 +1,7 @@ package sliceutil import ( + "reflect" "testing" "github.com/stretchr/testify/assert" @@ -66,3 +67,85 @@ func TestSliceSame(t *testing.T) { }) } } + +func TestAppendUniques(t *testing.T) { + type args struct { + vs []int + toAdd []int + } + tests := []struct { + name string + args args + want []int + }{ + { + name: "append to empty slice", + args: args{ + vs: []int{}, + toAdd: []int{1, 2, 3}, + }, + want: []int{1, 2, 3}, + }, + { + name: "append all unique values", + args: args{ + vs: []int{1, 2, 3}, + toAdd: []int{4, 5, 6}, + }, + want: []int{1, 2, 3, 4, 5, 6}, + }, + { + name: "append with some duplicates", + args: args{ + vs: []int{1, 2, 3}, + toAdd: []int{3, 4, 5}, + }, + want: []int{1, 2, 3, 4, 5}, + }, + { + name: "append all duplicates", + args: args{ + vs: []int{1, 2, 3}, + toAdd: []int{1, 2, 3}, + }, + want: []int{1, 2, 3}, + }, + { + name: "append to nil slice", + args: args{ + vs: nil, + toAdd: []int{1, 2, 3}, + }, + want: []int{1, 2, 3}, + }, + { + name: "append empty slice", + args: args{ + vs: []int{1, 2, 3}, + toAdd: []int{}, + }, + want: []int{1, 2, 3}, + }, + { + name: "append nil to slice", + args: args{ + vs: []int{1, 2, 3}, + toAdd: nil, + }, + want: []int{1, 2, 3}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := AppendUniques(tt.args.vs, tt.args.toAdd); !reflect.DeepEqual(got, tt.want) { + t.Errorf("AppendUniques() = %v, want %v", got, tt.want) + } + }) + } +} + +func BenchmarkAppendUniques(b *testing.B) { + for i := 0; i < b.N; i++ { + AppendUniques([]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, []int{3, 4, 4, 11, 12, 13, 14, 15, 16, 17, 18}) + } +} diff --git a/pkg/sliceutil/stringslice/string_collections.go b/pkg/sliceutil/stringslice/string_collections.go index e4b9ca6a915..f6ea1361c5f 100644 --- a/pkg/sliceutil/stringslice/string_collections.go +++ b/pkg/sliceutil/stringslice/string_collections.go @@ -33,8 +33,8 @@ func FromString(s string, sep string) []string { // Unique returns a slice containing only unique values from the provided slice. // The comparison is case-insensitive. func UniqueFold(s []string) []string { - seen := make(map[string]struct{}) - var ret []string + seen := make(map[string]struct{}, len(s)) + ret := make([]string, 0, len(s)) for _, v := range s { if _, exists := seen[strings.ToLower(v)]; exists { continue diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 5473b9c36ee..90e46bebcba 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -6,12 +6,12 @@ import ( "errors" "fmt" "path/filepath" + "slices" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/sliceutil" "gopkg.in/guregu/null.v4" "gopkg.in/guregu/null.v4/zero" ) @@ -412,7 +412,7 @@ func (qb *GalleryStore) FindMany(ctx context.Context, ids []int) ([]*models.Gall } for _, s := range unsorted { - i := sliceutil.Index(ids, s.ID) + i := slices.Index(ids, s.ID) galleries[i] = s } diff --git a/pkg/sqlite/gallery_chapter.go b/pkg/sqlite/gallery_chapter.go index f0d9c52980b..92ae63f5f83 100644 --- a/pkg/sqlite/gallery_chapter.go +++ b/pkg/sqlite/gallery_chapter.go @@ -5,13 +5,13 @@ import ( "database/sql" "errors" "fmt" + "slices" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/sliceutil" ) const ( @@ -162,7 +162,7 @@ func (qb *GalleryChapterStore) FindMany(ctx context.Context, ids []int) ([]*mode } for _, s := range unsorted { - i := sliceutil.Index(ids, s.ID) + i := slices.Index(ids, s.ID) ret[i] = s } diff --git a/pkg/sqlite/group.go b/pkg/sqlite/group.go index 603494fe71a..5a214f81853 100644 --- a/pkg/sqlite/group.go +++ b/pkg/sqlite/group.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "slices" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" @@ -13,7 +14,6 @@ import ( "gopkg.in/guregu/null.v4/zero" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/sliceutil" ) const ( @@ -295,7 +295,7 @@ func (qb *GroupStore) FindMany(ctx context.Context, ids []int) ([]*models.Group, } for _, s := range unsorted { - i := sliceutil.Index(ids, s.ID) + i := slices.Index(ids, s.ID) ret[i] = s } diff --git a/pkg/sqlite/group_test.go b/pkg/sqlite/group_test.go index 1d3637c8611..d4a177e86cf 100644 --- a/pkg/sqlite/group_test.go +++ b/pkg/sqlite/group_test.go @@ -6,6 +6,7 @@ package sqlite_test import ( "context" "fmt" + "slices" "strconv" "strings" "testing" @@ -1605,7 +1606,7 @@ func TestGroupReorderSubGroups(t *testing.T) { // get ids of groups newIDs := sliceutil.Map(gd, func(gd models.GroupIDDescription) int { return gd.GroupID }) - newIdxs := sliceutil.Map(newIDs, func(id int) int { return sliceutil.Index(idxToId, id) }) + newIdxs := sliceutil.Map(newIDs, func(id int) int { return slices.Index(idxToId, id) }) assert.ElementsMatch(t, tt.expectedIdxs, newIdxs) }) @@ -1733,7 +1734,7 @@ func TestGroupAddSubGroups(t *testing.T) { // get ids of groups newIDs := sliceutil.Map(gd, func(gd models.GroupIDDescription) int { return gd.GroupID }) - newIdxs := sliceutil.Map(newIDs, func(id int) int { return sliceutil.Index(idxToId, id) }) + newIdxs := sliceutil.Map(newIDs, func(id int) int { return slices.Index(idxToId, id) }) assert.ElementsMatch(t, tt.expectedIdxs, newIdxs) }) @@ -1828,7 +1829,7 @@ func TestGroupRemoveSubGroups(t *testing.T) { // get ids of groups newIDs := sliceutil.Map(gd, func(gd models.GroupIDDescription) int { return gd.GroupID }) - newIdxs := sliceutil.Map(newIDs, func(id int) int { return sliceutil.Index(idxToId, id) }) + newIdxs := sliceutil.Map(newIDs, func(id int) int { return slices.Index(idxToId, id) }) assert.ElementsMatch(t, tt.expectedIdxs, newIdxs) }) @@ -1883,7 +1884,7 @@ func TestGroupFindSubGroupIDs(t *testing.T) { } // get ids of groups - foundIdxs := sliceutil.Map(found, func(id int) int { return sliceutil.Index(groupIDs, id) }) + foundIdxs := sliceutil.Map(found, func(id int) int { return slices.Index(groupIDs, id) }) assert.ElementsMatch(t, tt.expectedIdxs, foundIdxs) }) diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 4f61c17773c..4bc28fad820 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "path/filepath" + "slices" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/models" @@ -398,7 +399,7 @@ func (qb *ImageStore) FindMany(ctx context.Context, ids []int) ([]*models.Image, } for _, s := range unsorted { - i := sliceutil.Index(ids, s.ID) + i := slices.Index(ids, s.ID) images[i] = s } diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 7ff6f5401a0..e20dc9c4cc5 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -5,12 +5,12 @@ import ( "database/sql" "errors" "fmt" + "slices" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/utils" "gopkg.in/guregu/null.v4" "gopkg.in/guregu/null.v4/zero" @@ -398,7 +398,7 @@ func (qb *PerformerStore) FindMany(ctx context.Context, ids []int) ([]*models.Pe } for _, s := range unsorted { - i := sliceutil.Index(ids, s.ID) + i := slices.Index(ids, s.ID) ret[i] = s } diff --git a/pkg/sqlite/saved_filter.go b/pkg/sqlite/saved_filter.go index 8f58b05e76c..583e2406259 100644 --- a/pkg/sqlite/saved_filter.go +++ b/pkg/sqlite/saved_filter.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" @@ -13,7 +14,6 @@ import ( "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/sliceutil" ) const ( @@ -165,7 +165,7 @@ func (qb *SavedFilterStore) FindMany(ctx context.Context, ids []int, ignoreNotFo } for _, s := range unsorted { - i := sliceutil.Index(ids, s.ID) + i := slices.Index(ids, s.ID) ret[i] = s } diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 5df614b886f..edd36348329 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "path/filepath" + "slices" "sort" "strconv" "strings" @@ -504,7 +505,7 @@ func (qb *SceneStore) FindMany(ctx context.Context, ids []int) ([]*models.Scene, } for _, s := range unsorted { - i := sliceutil.Index(ids, s.ID) + i := slices.Index(ids, s.ID) scenes[i] = s } diff --git a/pkg/sqlite/scene_marker.go b/pkg/sqlite/scene_marker.go index 87a849d2084..4af4d6b4bae 100644 --- a/pkg/sqlite/scene_marker.go +++ b/pkg/sqlite/scene_marker.go @@ -5,13 +5,13 @@ import ( "database/sql" "errors" "fmt" + "slices" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/sliceutil" ) const sceneMarkerTable = "scene_markers" @@ -188,7 +188,7 @@ func (qb *SceneMarkerStore) FindMany(ctx context.Context, ids []int) ([]*models. } for _, s := range unsorted { - i := sliceutil.Index(ids, s.ID) + i := slices.Index(ids, s.ID) ret[i] = s } diff --git a/pkg/sqlite/scene_marker_test.go b/pkg/sqlite/scene_marker_test.go index 0a8343a8bfc..ce8f4d3ad6b 100644 --- a/pkg/sqlite/scene_marker_test.go +++ b/pkg/sqlite/scene_marker_test.go @@ -5,11 +5,11 @@ package sqlite_test import ( "context" + "slices" "strconv" "testing" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stretchr/testify/assert" ) @@ -133,7 +133,7 @@ func verifyIDs(t *testing.T, modifier models.CriterionModifier, values []int, re case models.CriterionModifierNotEquals: foundAll := true for _, v := range values { - if !sliceutil.Contains(results, v) { + if !slices.Contains(results, v) { foundAll = false break } diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 624ffb4e222..1c3f914d3ba 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -10,13 +10,13 @@ import ( "fmt" "os" "path/filepath" + "slices" "strconv" "testing" "time" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/sqlite" "github.com/stashapp/stash/pkg/txn" @@ -1585,7 +1585,7 @@ func getTagMarkerCount(id int) int { count := 0 idx := indexFromID(tagIDs, id) for _, s := range markerSpecs { - if s.primaryTagIdx == idx || sliceutil.Contains(s.tagIdxs, idx) { + if s.primaryTagIdx == idx || slices.Contains(s.tagIdxs, idx) { count++ } } diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index 95edf4173e2..6b81109b1fa 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "slices" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" @@ -13,7 +14,6 @@ import ( "gopkg.in/guregu/null.v4/zero" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/studio" ) @@ -305,7 +305,7 @@ func (qb *StudioStore) FindMany(ctx context.Context, ids []int) ([]*models.Studi } for _, s := range unsorted { - i := sliceutil.Index(ids, s.ID) + i := slices.Index(ids, s.ID) ret[i] = s } diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 42bdd9bbe45..919ef4847df 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "slices" "strings" "github.com/doug-martin/goqu/v9" @@ -14,7 +15,6 @@ import ( "gopkg.in/guregu/null.v4/zero" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/sliceutil" ) const ( @@ -312,7 +312,7 @@ func (qb *TagStore) FindMany(ctx context.Context, ids []int) ([]*models.Tag, err } for _, s := range unsorted { - i := sliceutil.Index(ids, s.ID) + i := slices.Index(ids, s.ID) ret[i] = s } diff --git a/pkg/studio/import.go b/pkg/studio/import.go index d880650787d..3aaceb09374 100644 --- a/pkg/studio/import.go +++ b/pkg/studio/import.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "strings" "github.com/stashapp/stash/pkg/models" @@ -80,7 +81,7 @@ func importTags(ctx context.Context, tagWriter models.TagFinderCreator, names [] } missingTags := sliceutil.Filter(names, func(name string) bool { - return !sliceutil.Contains(pluckedNames, name) + return !slices.Contains(pluckedNames, name) }) if len(missingTags) > 0 { From 069a4b1f80bd19ba885242af787cb6e7bb64c1c2 Mon Sep 17 00:00:00 2001 From: CJ <72030708+cj12312021@users.noreply.github.com> Date: Mon, 28 Oct 2024 17:35:58 -0700 Subject: [PATCH 74/91] show/hide details via CSS rather than Javascript (#5396) --- .../Groups/GroupDetails/GroupDetailsPanel.tsx | 38 +++-------- ui/v2.5/src/components/Groups/styles.scss | 17 ++++- .../PerformerDetailsPanel.tsx | 67 +++++++------------ ui/v2.5/src/components/Performers/styles.scss | 15 +++++ .../StudioDetails/StudioDetailsPanel.tsx | 27 ++------ ui/v2.5/src/components/Studios/styles.scss | 10 +++ 6 files changed, 81 insertions(+), 93 deletions(-) diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx index 6a20eb9081a..d93b0646636 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx @@ -36,7 +36,6 @@ interface IGroupDetailsPanel { export const GroupDetailsPanel: React.FC<IGroupDetailsPanel> = ({ group, - collapsed, fullWidth, }) => { // Network state @@ -55,32 +54,6 @@ export const GroupDetailsPanel: React.FC<IGroupDetailsPanel> = ({ ); } - function maybeRenderExtraDetails() { - if (!collapsed) { - return ( - <> - <DetailItem - id="synopsis" - value={group.synopsis} - fullWidth={fullWidth} - /> - <DetailItem - id="tags" - value={renderTagsField()} - fullWidth={fullWidth} - /> - {group.containing_groups.length > 0 && ( - <DetailItem - id="containing_groups" - value={<GroupsList groups={group.containing_groups} />} - fullWidth={fullWidth} - /> - )} - </> - ); - } - } - return ( <div className="detail-group"> <DetailItem @@ -108,7 +81,6 @@ export const GroupDetailsPanel: React.FC<IGroupDetailsPanel> = ({ } fullWidth={fullWidth} /> - <DetailItem id="director" value={ @@ -120,7 +92,15 @@ export const GroupDetailsPanel: React.FC<IGroupDetailsPanel> = ({ } fullWidth={fullWidth} /> - {maybeRenderExtraDetails()} + <DetailItem id="synopsis" value={group.synopsis} fullWidth={fullWidth} /> + <DetailItem id="tags" value={renderTagsField()} fullWidth={fullWidth} /> + {group.containing_groups.length > 0 && ( + <DetailItem + id="containing_groups" + value={<GroupsList groups={group.containing_groups} />} + fullWidth={fullWidth} + /> + )} </div> ); }; diff --git a/ui/v2.5/src/components/Groups/styles.scss b/ui/v2.5/src/components/Groups/styles.scss index 1b80045c73d..927cd38a0ac 100644 --- a/ui/v2.5/src/components/Groups/styles.scss +++ b/ui/v2.5/src/components/Groups/styles.scss @@ -41,8 +41,21 @@ } } -#group-page .rating-number .text-input { - width: auto; +#group-page { + .rating-number .text-input { + width: auto; + } + + // the detail element ids are the same as field type name + // which don't follow the correct convention + /* stylelint-disable selector-class-pattern */ + .collapsed { + .detail-item.tags, + .detail-item.containing_groups { + display: none; + } + } + /* stylelint-enable selector-class-pattern */ } .group-select-option { diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx index 94e27a3628d..e805c03e621 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx @@ -28,7 +28,7 @@ const PerformerDetailGroup: React.FC<PropsWithChildren<IPerformerDetails>> = export const PerformerDetailsPanel: React.FC<IPerformerDetails> = PatchComponent("PerformerDetailsPanel", (props) => { - const { performer, collapsed, fullWidth } = props; + const { performer, fullWidth } = props; // Network state const intl = useIntl(); @@ -62,45 +62,9 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ); } - function maybeRenderExtraDetails() { - if (!collapsed) { - /* Remove extra urls provided in details since they will be present by perfomr name */ - /* This code can be removed once multple urls are supported for performers */ - let details = performer?.details - ?.replace(/\[((?:http|www\.)[^\n\]]+)\]/gm, "") - .trim(); - return ( - <> - <DetailItem - id="tattoos" - value={performer?.tattoos} - fullWidth={fullWidth} - /> - <DetailItem - id="piercings" - value={performer?.piercings} - fullWidth={fullWidth} - /> - <DetailItem - id="career_length" - value={performer?.career_length} - fullWidth={fullWidth} - /> - <DetailItem id="details" value={details} fullWidth={fullWidth} /> - <DetailItem - id="tags" - value={renderTagsField()} - fullWidth={fullWidth} - /> - <DetailItem - id="stash_ids" - value={renderStashIDs()} - fullWidth={fullWidth} - /> - </> - ); - } - } + let details = performer?.details + ?.replace(/\[((?:http|www\.)[^\n\]]+)\]/gm, "") + .trim(); return ( <PerformerDetailGroup {...props}> @@ -190,7 +154,28 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = value={performer?.fake_tits} fullWidth={fullWidth} /> - {maybeRenderExtraDetails()} + <DetailItem + id="tattoos" + value={performer?.tattoos} + fullWidth={fullWidth} + /> + <DetailItem + id="piercings" + value={performer?.piercings} + fullWidth={fullWidth} + /> + <DetailItem + id="career_length" + value={performer?.career_length} + fullWidth={fullWidth} + /> + <DetailItem id="details" value={details} fullWidth={fullWidth} /> + <DetailItem id="tags" value={renderTagsField()} fullWidth={fullWidth} /> + <DetailItem + id="stash_ids" + value={renderStashIDs()} + fullWidth={fullWidth} + /> </PerformerDetailGroup> ); }); diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index c1f891f6b3e..786054d986b 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -40,6 +40,21 @@ .alias { font-weight: bold; } + + // the detail element ids are the same as field type name + // which don't follow the correct convention + /* stylelint-disable selector-class-pattern */ + .collapsed { + .detail-item.tattoos, + .detail-item.piercings, + .detail-item.career_length, + .detail-item.details, + .detail-item.tags, + .detail-item.stash_ids { + display: none; + } + } + /* stylelint-enable selector-class-pattern */ } .new-view { diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx index 5bf877b11f2..81e3897656d 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx @@ -13,7 +13,6 @@ interface IStudioDetailsPanel { export const StudioDetailsPanel: React.FC<IStudioDetailsPanel> = ({ studio, - collapsed, fullWidth, }) => { function renderTagsField() { @@ -47,25 +46,6 @@ export const StudioDetailsPanel: React.FC<IStudioDetailsPanel> = ({ ); } - function maybeRenderExtraDetails() { - if (!collapsed) { - return ( - <> - <DetailItem - id="tags" - value={renderTagsField()} - fullWidth={fullWidth} - /> - <DetailItem - id="stash_ids" - value={renderStashIDs()} - fullWidth={fullWidth} - /> - </> - ); - } - } - return ( <div className="detail-group"> <DetailItem id="details" value={studio.details} fullWidth={fullWidth} /> @@ -82,7 +62,12 @@ export const StudioDetailsPanel: React.FC<IStudioDetailsPanel> = ({ } fullWidth={fullWidth} /> - {maybeRenderExtraDetails()} + <DetailItem id="tags" value={renderTagsField()} fullWidth={fullWidth} /> + <DetailItem + id="stash_ids" + value={renderStashIDs()} + fullWidth={fullWidth} + /> </div> ); }; diff --git a/ui/v2.5/src/components/Studios/styles.scss b/ui/v2.5/src/components/Studios/styles.scss index 9d919f42d94..eaab21d1015 100644 --- a/ui/v2.5/src/components/Studios/styles.scss +++ b/ui/v2.5/src/components/Studios/styles.scss @@ -40,4 +40,14 @@ .rating-number .text-input { width: auto; } + + // the detail element ids are the same as field type name + // which don't follow the correct convention + /* stylelint-disable selector-class-pattern */ + .collapsed { + .detail-item.stash_ids { + display: none; + } + } + /* stylelint-enable selector-class-pattern */ } From 7fb8f9172eb8459a2c25e14b20777fa8d77fb720 Mon Sep 17 00:00:00 2001 From: CJ <72030708+cj12312021@users.noreply.github.com> Date: Mon, 28 Oct 2024 17:40:46 -0700 Subject: [PATCH 75/91] Group details: Flippable images in expanded view. (#5367) * flippable images in expanded view * adjust table title width * cleanup * eliminate bounce and other improvements * expand support to non full-width option --- .../components/Groups/GroupDetails/Group.tsx | 31 ++++++++-- ui/v2.5/src/components/Groups/styles.scss | 56 +++++++++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx b/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx index 0aa4dbd5472..913b2bc52c7 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx @@ -21,7 +21,7 @@ import { GroupDetailsPanel, } from "./GroupDetailsPanel"; import { GroupEditPanel } from "./GroupEditPanel"; -import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; +import { faRefresh, faTrashAlt } from "@fortawesome/free-solid-svg-icons"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { ConfigurationContext } from "src/hooks/Config"; import { DetailImage } from "src/components/Shared/DetailImage"; @@ -39,8 +39,9 @@ import { TabTitleCounter, useTabKey, } from "src/components/Shared/DetailsPage/Tabs"; -import { Tab, Tabs } from "react-bootstrap"; +import { Button, Tab, Tabs } from "react-bootstrap"; import { GroupSubGroupsPanel } from "./GroupSubGroupsPanel"; +import { Icon } from "src/components/Shared/Icon"; const validTabs = ["default", "scenes", "subgroups"] as const; type TabKey = (typeof validTabs)[number]; @@ -130,6 +131,8 @@ const GroupPage: React.FC<IProps> = ({ group, tabKey }) => { const showAllDetails = uiConfig?.showAllDetails ?? true; const abbreviateCounter = uiConfig?.abbreviateCounters ?? false; + const [focusedOnFront, setFocusedOnFront] = useState<boolean>(true); + const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails); const loadStickyHeader = useLoadStickyHeader(); @@ -328,7 +331,13 @@ const GroupPage: React.FC<IProps> = ({ group, tabKey }) => { <div className="group-images"> {!!activeFrontImage && ( <LightboxLink images={lightboxImages}> - <DetailImage alt="Front Cover" src={activeFrontImage} /> + <DetailImage + className={`front-cover ${ + focusedOnFront ? "active" : "inactive" + }`} + alt="Front Cover" + src={activeFrontImage} + /> </LightboxLink> )} {!!activeBackImage && ( @@ -336,9 +345,23 @@ const GroupPage: React.FC<IProps> = ({ group, tabKey }) => { images={lightboxImages} index={lightboxImages.length - 1} > - <DetailImage alt="Back Cover" src={activeBackImage} /> + <DetailImage + className={`back-cover ${ + !focusedOnFront ? "active" : "inactive" + }`} + alt="Back Cover" + src={activeBackImage} + /> </LightboxLink> )} + {!!(activeFrontImage && activeBackImage) && ( + <Button + className="flip" + onClick={() => setFocusedOnFront(!focusedOnFront)} + > + <Icon icon={faRefresh} /> + </Button> + )} </div> </HeaderImage> <div className="row"> diff --git a/ui/v2.5/src/components/Groups/styles.scss b/ui/v2.5/src/components/Groups/styles.scss index 927cd38a0ac..dd159ff21d1 100644 --- a/ui/v2.5/src/components/Groups/styles.scss +++ b/ui/v2.5/src/components/Groups/styles.scss @@ -104,6 +104,62 @@ } } +button.flip { + display: none; +} + +#group-page .detail-header:not(.collapsed) { + .group-images { + padding: 0.375rem 0.75rem; + position: relative; + z-index: 1; + + button.btn-link { + padding: 0; + position: relative; + transition: all 0.3s; + z-index: 1; + } + + button:has(.active) { + z-index: 2; + } + + button:has(.inactive) { + opacity: 0.5; + padding: 0; + transform: rotateY(180deg); + } + + button.flip { + align-items: center; + border-radius: 50%; + bottom: -5px; + display: flex; + font-size: 20px; + height: 40px; + justify-content: center; + padding: 0; + position: absolute; + right: -5px; + width: 40px; + z-index: 2; + } + + img.active { + max-width: 22rem; + } + + img.inactive { + display: none; + } + } + + .detail-item .detail-item-title { + width: 150px; + } +} + .groups-list { list-style-type: none; padding-inline-start: 0; From 1b7e729750263a65a471f23459963ba0e24a95b7 Mon Sep 17 00:00:00 2001 From: randemgame <61895715+randemgame@users.noreply.github.com> Date: Tue, 29 Oct 2024 03:19:49 +0200 Subject: [PATCH 76/91] Update Scenes' 'Updated At' Date on Heatmap Gen & Expand Error Log (#5401) * Update Scenes' 'Updated At' Date on Heatmap Gen & Expand Error Log The UpdatedAt field of a scene is now correctly updated after Generate Task is run and a heatmap linked to a scene.. I thought it made more sense here in the generate heatmap compared to scan.go, owing to funscript data not being tracked/stored in a typical sense with the scan message "updating metadata". I used a simplified error messaging as I did not think it was critcal but I do not know if did not use the correct code structure If updating the UpdatedAt field should be done there when the file is marked as interactive I can try and do that? This would fix this long-standing issue https://github.com/stashapp/stash/issues/3738 The error message change is useful as I could not tell which scripts were causing errors before but now it is clear in the logs * Use single transaction --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- .../task_generate_interactive_heatmap_speed.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/internal/manager/task_generate_interactive_heatmap_speed.go b/internal/manager/task_generate_interactive_heatmap_speed.go index 61350f09c2b..8a9543d9a68 100644 --- a/internal/manager/task_generate_interactive_heatmap_speed.go +++ b/internal/manager/task_generate_interactive_heatmap_speed.go @@ -36,7 +36,7 @@ func (t *GenerateInteractiveHeatmapSpeedTask) Start(ctx context.Context) { err := generator.Generate(funscriptPath, heatmapPath, t.Scene.Files.Primary().Duration) if err != nil { - logger.Errorf("error generating heatmap: %s", err.Error()) + logger.Errorf("error generating heatmap for %s: %s", t.Scene.Path, err.Error()) return } @@ -46,8 +46,16 @@ func (t *GenerateInteractiveHeatmapSpeedTask) Start(ctx context.Context) { if err := r.WithTxn(ctx, func(ctx context.Context) error { primaryFile := t.Scene.Files.Primary() primaryFile.InteractiveSpeed = &median - qb := r.File - return qb.Update(ctx, primaryFile) + if err := r.File.Update(ctx, primaryFile); err != nil { + return fmt.Errorf("updating interactive speed for %s: %w", primaryFile.Path, err) + } + + // update the scene UpdatedAt field + // NewScenePartial sets the UpdatedAt field to the current time + if _, err := r.Scene.UpdatePartial(ctx, t.Scene.ID, models.NewScenePartial()); err != nil { + return fmt.Errorf("updating UpdatedAt field for scene %d: %w", t.Scene.ID, err) + } + return nil }); err != nil && ctx.Err() == nil { logger.Error(err.Error()) } From edb66bd4e4acc2d4f3747401f71a19a1f637e3d8 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 29 Oct 2024 13:52:17 +1100 Subject: [PATCH 77/91] Remove unnecessary scroll to top on mount for top level query pages (#5288) --- ui/v2.5/src/components/Galleries/Galleries.tsx | 3 --- ui/v2.5/src/components/Groups/Groups.tsx | 3 --- ui/v2.5/src/components/Images/Images.tsx | 3 --- ui/v2.5/src/components/Performers/Performers.tsx | 3 --- ui/v2.5/src/components/Scenes/Scenes.tsx | 5 ----- ui/v2.5/src/components/Studios/Studios.tsx | 3 --- ui/v2.5/src/components/Tags/Tags.tsx | 3 --- 7 files changed, 23 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/Galleries.tsx b/ui/v2.5/src/components/Galleries/Galleries.tsx index db3db8dddc0..c845a153c89 100644 --- a/ui/v2.5/src/components/Galleries/Galleries.tsx +++ b/ui/v2.5/src/components/Galleries/Galleries.tsx @@ -5,7 +5,6 @@ import { useTitleProps } from "src/hooks/title"; import Gallery from "./GalleryDetails/Gallery"; import GalleryCreate from "./GalleryDetails/GalleryCreate"; import { GalleryList } from "./GalleryList"; -import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; import { View } from "../List/views"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { ErrorMessage } from "../Shared/ErrorMessage"; @@ -41,8 +40,6 @@ const GalleryImage: React.FC<RouteComponentProps<IGalleryImageParams>> = ({ }; const Galleries: React.FC = () => { - useScrollToTopOnMount(); - return <GalleryList view={View.Galleries} />; }; diff --git a/ui/v2.5/src/components/Groups/Groups.tsx b/ui/v2.5/src/components/Groups/Groups.tsx index a2e4d90834d..5ec7b4eaf06 100644 --- a/ui/v2.5/src/components/Groups/Groups.tsx +++ b/ui/v2.5/src/components/Groups/Groups.tsx @@ -5,12 +5,9 @@ import { useTitleProps } from "src/hooks/title"; import Group from "./GroupDetails/Group"; import GroupCreate from "./GroupDetails/GroupCreate"; import { GroupList } from "./GroupList"; -import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; import { View } from "../List/views"; const Groups: React.FC = () => { - useScrollToTopOnMount(); - return <GroupList view={View.Groups} />; }; diff --git a/ui/v2.5/src/components/Images/Images.tsx b/ui/v2.5/src/components/Images/Images.tsx index c0a6b67c814..91edfdf7985 100644 --- a/ui/v2.5/src/components/Images/Images.tsx +++ b/ui/v2.5/src/components/Images/Images.tsx @@ -4,12 +4,9 @@ import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; import Image from "./ImageDetails/Image"; import { ImageList } from "./ImageList"; -import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; import { View } from "../List/views"; const Images: React.FC = () => { - useScrollToTopOnMount(); - return <ImageList view={View.Images} />; }; diff --git a/ui/v2.5/src/components/Performers/Performers.tsx b/ui/v2.5/src/components/Performers/Performers.tsx index 96c44e938ef..d240ce988b6 100644 --- a/ui/v2.5/src/components/Performers/Performers.tsx +++ b/ui/v2.5/src/components/Performers/Performers.tsx @@ -5,12 +5,9 @@ import { useTitleProps } from "src/hooks/title"; import Performer from "./PerformerDetails/Performer"; import PerformerCreate from "./PerformerDetails/PerformerCreate"; import { PerformerList } from "./PerformerList"; -import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; import { View } from "../List/views"; const Performers: React.FC = () => { - useScrollToTopOnMount(); - return <PerformerList view={View.Performers} />; }; diff --git a/ui/v2.5/src/components/Scenes/Scenes.tsx b/ui/v2.5/src/components/Scenes/Scenes.tsx index 7e8031ab5a7..a9124fb8fad 100644 --- a/ui/v2.5/src/components/Scenes/Scenes.tsx +++ b/ui/v2.5/src/components/Scenes/Scenes.tsx @@ -3,7 +3,6 @@ import { Route, Switch } from "react-router-dom"; import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; import { lazyComponent } from "src/utils/lazyComponent"; -import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; import { View } from "../List/views"; const SceneList = lazyComponent(() => import("./SceneList")); @@ -12,14 +11,10 @@ const Scene = lazyComponent(() => import("./SceneDetails/Scene")); const SceneCreate = lazyComponent(() => import("./SceneDetails/SceneCreate")); const Scenes: React.FC = () => { - useScrollToTopOnMount(); - return <SceneList view={View.Scenes} />; }; const SceneMarkers: React.FC = () => { - useScrollToTopOnMount(); - const titleProps = useTitleProps({ id: "markers" }); return ( <> diff --git a/ui/v2.5/src/components/Studios/Studios.tsx b/ui/v2.5/src/components/Studios/Studios.tsx index e60dbdc06c2..545de936f52 100644 --- a/ui/v2.5/src/components/Studios/Studios.tsx +++ b/ui/v2.5/src/components/Studios/Studios.tsx @@ -5,12 +5,9 @@ import { useTitleProps } from "src/hooks/title"; import Studio from "./StudioDetails/Studio"; import StudioCreate from "./StudioDetails/StudioCreate"; import { StudioList } from "./StudioList"; -import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; import { View } from "../List/views"; const Studios: React.FC = () => { - useScrollToTopOnMount(); - return <StudioList view={View.Studios} />; }; diff --git a/ui/v2.5/src/components/Tags/Tags.tsx b/ui/v2.5/src/components/Tags/Tags.tsx index 5ed35448e7f..806a0f7a6a5 100644 --- a/ui/v2.5/src/components/Tags/Tags.tsx +++ b/ui/v2.5/src/components/Tags/Tags.tsx @@ -5,11 +5,8 @@ import { useTitleProps } from "src/hooks/title"; import Tag from "./TagDetails/Tag"; import TagCreate from "./TagDetails/TagCreate"; import { TagList } from "./TagList"; -import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; const Tags: React.FC = () => { - useScrollToTopOnMount(); - return <TagList />; }; From f949fab231753344092280877a7343c7c52405a0 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 29 Oct 2024 14:17:46 +1100 Subject: [PATCH 78/91] Move modifiers into selectable options (#5203) --- .../src/components/List/CriterionEditor.tsx | 38 ++- .../List/Filters/SelectableFilter.tsx | 286 +++++++++++++----- .../components/List/Filters/StudiosFilter.tsx | 1 + ui/v2.5/src/components/List/styles.scss | 9 + ui/v2.5/src/locales/en-GB.json | 6 + 5 files changed, 267 insertions(+), 73 deletions(-) diff --git a/ui/v2.5/src/components/List/CriterionEditor.tsx b/ui/v2.5/src/components/List/CriterionEditor.tsx index 96278560bed..ffc707807ce 100644 --- a/ui/v2.5/src/components/List/CriterionEditor.tsx +++ b/ui/v2.5/src/components/List/CriterionEditor.tsx @@ -61,6 +61,27 @@ const GenericCriterionEditor: React.FC<IGenericCriterionEditor> = ({ const { options, modifierOptions } = criterion.criterionOption; + const showModifierSelector = useMemo(() => { + if ( + criterion instanceof PerformersCriterion || + criterion instanceof StudiosCriterion || + criterion instanceof TagsCriterion + ) { + return false; + } + + return modifierOptions && modifierOptions.length > 1; + }, [criterion, modifierOptions]); + + const alwaysShowFilter = useMemo(() => { + return ( + criterion instanceof StashIDCriterion || + criterion instanceof PerformersCriterion || + criterion instanceof StudiosCriterion || + criterion instanceof TagsCriterion + ); + }, [criterion]); + const onChangedModifierSelect = useCallback( (m: CriterionModifier) => { const newCriterion = cloneDeep(criterion); @@ -71,7 +92,7 @@ const GenericCriterionEditor: React.FC<IGenericCriterionEditor> = ({ ); const modifierSelector = useMemo(() => { - if (!modifierOptions || modifierOptions.length === 0) { + if (!showModifierSelector) { return; } @@ -90,7 +111,13 @@ const GenericCriterionEditor: React.FC<IGenericCriterionEditor> = ({ ))} </Form.Group> ); - }, [modifierOptions, onChangedModifierSelect, criterion.modifier, intl]); + }, [ + showModifierSelector, + modifierOptions, + onChangedModifierSelect, + criterion.modifier, + intl, + ]); const valueControl = useMemo(() => { function onValueChanged(value: CriterionValue) { @@ -108,8 +135,9 @@ const GenericCriterionEditor: React.FC<IGenericCriterionEditor> = ({ // Hide the value select if the modifier is "IsNull" or "NotNull" if ( - criterion.modifier === CriterionModifier.IsNull || - criterion.modifier === CriterionModifier.NotNull + !alwaysShowFilter && + (criterion.modifier === CriterionModifier.IsNull || + criterion.modifier === CriterionModifier.NotNull) ) { return; } @@ -229,7 +257,7 @@ const GenericCriterionEditor: React.FC<IGenericCriterionEditor> = ({ return ( <InputFilter criterion={criterion} onValueChanged={onValueChanged} /> ); - }, [criterion, setCriterion, options]); + }, [criterion, setCriterion, options, alwaysShowFilter]); return ( <div> diff --git a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx index e6f8f9fcf98..a53dc6effc5 100644 --- a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx @@ -24,19 +24,23 @@ import { CriterionModifier } from "src/core/generated-graphql"; import { keyboardClickHandler } from "src/utils/keyboard"; import { useDebounce } from "src/hooks/debounce"; import useFocus from "src/utils/focus"; +import cx from "classnames"; import ScreenUtils from "src/utils/screen"; import { NumberField } from "src/utils/form"; interface ISelectedItem { - item: ILabeledId; + label: string; excluded?: boolean; onClick: () => void; + // true if the object is a special modifier value + modifier?: boolean; } const SelectedItem: React.FC<ISelectedItem> = ({ - item, + label, excluded = false, onClick, + modifier = false, }) => { const iconClassName = excluded ? "exclude-icon" : "include-button"; const spanClassName = excluded @@ -61,21 +65,66 @@ const SelectedItem: React.FC<ISelectedItem> = ({ } return ( - <a - onClick={() => onClick()} - onKeyDown={keyboardClickHandler(onClick)} - onMouseEnter={() => onMouseOver()} - onMouseLeave={() => onMouseOut()} - onFocus={() => onMouseOver()} - onBlur={() => onMouseOut()} - tabIndex={0} - > - <div> - <Icon className={`fa-fw ${iconClassName}`} icon={icon} /> - <span className={spanClassName}>{item.label}</span> - </div> - <div></div> - </a> + <li className={cx("selected-object", { "modifier-object": modifier })}> + <a + onClick={() => onClick()} + onKeyDown={keyboardClickHandler(onClick)} + onMouseEnter={() => onMouseOver()} + onMouseLeave={() => onMouseOut()} + onFocus={() => onMouseOver()} + onBlur={() => onMouseOut()} + tabIndex={0} + > + <div> + <Icon className={`fa-fw ${iconClassName}`} icon={icon} /> + <span className={spanClassName}>{label}</span> + </div> + <div></div> + </a> + </li> + ); +}; + +const UnselectedItem: React.FC<{ + onSelect: (exclude: boolean) => void; + label: string; + canExclude: boolean; + // true if the object is a special modifier value + modifier?: boolean; +}> = ({ onSelect, label, canExclude, modifier = false }) => { + const includeIcon = <Icon className="fa-fw include-button" icon={faPlus} />; + const excludeIcon = <Icon className="fa-fw exclude-icon" icon={faMinus} />; + + return ( + <li className={cx("unselected-object", { "modifier-object": modifier })}> + <a + onClick={() => onSelect(false)} + onKeyDown={keyboardClickHandler(() => onSelect(false))} + tabIndex={0} + > + <div> + {includeIcon} + <span className="unselected-object-label">{label}</span> + </div> + <div> + {/* TODO item count */} + {/* <span className="object-count">{p.id}</span> */} + {canExclude && ( + <Button + onClick={(e) => { + e.stopPropagation(); + onSelect(true); + }} + onKeyDown={(e) => e.stopPropagation()} + className="minimal exclude-button" + > + <span className="exclude-button-text">exclude</span> + {excludeIcon} + </Button> + )} + </div> + </a> + </li> ); }; @@ -83,6 +132,7 @@ interface ISelectableFilter { query: string; onQueryChange: (query: string) => void; modifier: CriterionModifier; + showModifierValues: boolean; inputFocus: ReturnType<typeof useFocus>; canExclude: boolean; queryResults: ILabeledId[]; @@ -90,12 +140,31 @@ interface ISelectableFilter { excluded: ILabeledId[]; onSelect: (value: ILabeledId, exclude: boolean) => void; onUnselect: (value: ILabeledId) => void; + onSetModifier: (modifier: CriterionModifier) => void; + // true if the filter is for a single value + singleValue?: boolean; +} + +type SpecialValue = "any" | "none" | "any_of" | "only"; + +function modifierValueToModifier(key: SpecialValue): CriterionModifier { + switch (key) { + case "any": + return CriterionModifier.NotNull; + case "none": + return CriterionModifier.IsNull; + case "any_of": + return CriterionModifier.Includes; + case "only": + return CriterionModifier.Equals; + } } const SelectableFilter: React.FC<ISelectableFilter> = ({ query, onQueryChange, modifier, + showModifierValues, inputFocus, canExclude, queryResults, @@ -103,23 +172,73 @@ const SelectableFilter: React.FC<ISelectableFilter> = ({ excluded, onSelect, onUnselect, + onSetModifier, + singleValue, }) => { const intl = useIntl(); const objects = useMemo(() => { + if ( + modifier === CriterionModifier.IsNull || + modifier === CriterionModifier.NotNull + ) { + return []; + } return queryResults.filter( (p) => selected.find((s) => s.id === p.id) === undefined && excluded.find((s) => s.id === p.id) === undefined ); - }, [queryResults, selected, excluded]); + }, [modifier, queryResults, selected, excluded]); const includingOnly = modifier == CriterionModifier.Equals; const excludingOnly = modifier == CriterionModifier.Excludes || modifier == CriterionModifier.NotEquals; - const includeIcon = <Icon className="fa-fw include-button" icon={faPlus} />; - const excludeIcon = <Icon className="fa-fw exclude-icon" icon={faMinus} />; + const modifierValues = useMemo(() => { + return { + any: modifier === CriterionModifier.NotNull, + none: modifier === CriterionModifier.IsNull, + any_of: !singleValue && modifier === CriterionModifier.Includes, + only: !singleValue && modifier === CriterionModifier.Equals, + }; + }, [modifier, singleValue]); + + const defaultModifier = useMemo(() => { + if (singleValue) { + return CriterionModifier.Includes; + } + return CriterionModifier.IncludesAll; + }, [singleValue]); + + const availableModifierValues: Record<SpecialValue, boolean> = useMemo(() => { + return { + any: + modifier === defaultModifier && + selected.length === 0 && + excluded.length === 0, + none: + modifier === defaultModifier && + selected.length === 0 && + excluded.length === 0, + any_of: + !singleValue && modifier === defaultModifier && selected.length > 1, + only: + !singleValue && + modifier === defaultModifier && + selected.length > 0 && + excluded.length === 0, + }; + }, [singleValue, defaultModifier, modifier, selected, excluded]); + + function onModifierValueSelect(key: SpecialValue) { + const m = modifierValueToModifier(key); + onSetModifier(m); + } + + function onModifierValueUnselect() { + onSetModifier(defaultModifier); + } return ( <div className="selectable-filter"> @@ -130,50 +249,67 @@ const SelectableFilter: React.FC<ISelectableFilter> = ({ placeholder={`${intl.formatMessage({ id: "actions.search" })}…`} /> <ul> - {selected.map((p) => ( - <li key={p.id} className="selected-object"> + {Object.entries(modifierValues).map(([key, value]) => { + if (!value) { + return null; + } + + return ( <SelectedItem - item={p} - excluded={excludingOnly} - onClick={() => onUnselect(p)} + key={key} + onClick={() => onModifierValueUnselect()} + label={`(${intl.formatMessage({ + id: `criterion_modifier_values.${key}`, + })})`} + modifier /> - </li> + ); + })} + {selected.map((p) => ( + <SelectedItem + key={p.id} + label={p.label} + excluded={excludingOnly} + onClick={() => onUnselect(p)} + /> ))} {excluded.map((p) => ( <li key={p.id} className="excluded-object"> - <SelectedItem item={p} excluded onClick={() => onUnselect(p)} /> + <SelectedItem + label={p.label} + excluded + onClick={() => onUnselect(p)} + /> </li> ))} + {showModifierValues && ( + <> + {Object.entries(availableModifierValues).map(([key, value]) => { + if (!value) { + return null; + } + + return ( + <UnselectedItem + key={key} + onSelect={() => onModifierValueSelect(key as SpecialValue)} + label={`(${intl.formatMessage({ + id: `criterion_modifier_values.${key}`, + })})`} + canExclude={false} + modifier + /> + ); + })} + </> + )} {objects.map((p) => ( - <li key={p.id} className="unselected-object"> - <a - onClick={() => onSelect(p, false)} - onKeyDown={keyboardClickHandler(() => onSelect(p, false))} - tabIndex={0} - > - <div> - {!excludingOnly ? includeIcon : excludeIcon} - <span>{p.label}</span> - </div> - <div> - {/* TODO item count */} - {/* <span className="object-count">{p.id}</span> */} - {canExclude && !includingOnly && !excludingOnly && ( - <Button - onClick={(e) => { - e.stopPropagation(); - onSelect(p, true); - }} - onKeyDown={(e) => e.stopPropagation()} - className="minimal exclude-button" - > - <span className="exclude-button-text">exclude</span> - {excludeIcon} - </Button> - )} - </div> - </a> - </li> + <UnselectedItem + key={p.id} + onSelect={(exclude) => onSelect(p, exclude)} + label={p.label} + canExclude={canExclude && !includingOnly && !excludingOnly} + /> ))} </ul> </div> @@ -184,6 +320,7 @@ interface IObjectsFilter<T extends Criterion<ILabeledValueListValue>> { criterion: T; setCriterion: (criterion: T) => void; useResults: (query: string) => { results: ILabeledId[]; loading: boolean }; + singleValue?: boolean; } export const ObjectsFilter = < @@ -192,6 +329,7 @@ export const ObjectsFilter = < criterion, setCriterion, useResults, + singleValue, }: IObjectsFilter<T>) => { const [query, setQuery] = useState(""); const [displayQuery, setDisplayQuery] = useState(query); @@ -264,6 +402,15 @@ export const ObjectsFilter = < [criterion, setCriterion, setInputFocus] ); + const onSetModifier = useCallback( + (modifier: CriterionModifier) => { + let newCriterion: T = criterion.clone(); + newCriterion.modifier = modifier; + setCriterion(newCriterion); + }, + [criterion, setCriterion] + ); + const sortedSelected = useMemo(() => { const ret = criterion.value.items.slice(); ret.sort((a, b) => a.label.localeCompare(b.label)); @@ -288,6 +435,7 @@ export const ObjectsFilter = < query={displayQuery} onQueryChange={onQueryChange} modifier={criterion.modifier} + showModifierValues={!query} inputFocus={inputFocus} canExclude={canExclude} selected={sortedSelected} @@ -295,6 +443,8 @@ export const ObjectsFilter = < onSelect={onSelect} onUnselect={onUnselect} excluded={sortedExcluded} + onSetModifier={onSetModifier} + singleValue={singleValue} /> ); }; @@ -347,18 +497,18 @@ export const HierarchicalObjectsFilter = < return ( <Form> - {criterion.modifier !== CriterionModifier.Equals && ( - <Form.Group> - <Form.Check - id={criterionOptionTypeToIncludeID()} - checked={criterion.value.depth !== 0} - label={intl.formatMessage(criterionOptionTypeToIncludeUIString())} - onChange={() => - onDepthChanged(criterion.value.depth !== 0 ? 0 : -1) - } - /> - </Form.Group> - )} + <Form.Group> + <Form.Check + id={criterionOptionTypeToIncludeID()} + checked={ + criterion.modifier !== CriterionModifier.Equals && + criterion.value.depth !== 0 + } + label={intl.formatMessage(criterionOptionTypeToIncludeUIString())} + onChange={() => onDepthChanged(criterion.value.depth !== 0 ? 0 : -1)} + disabled={criterion.modifier === CriterionModifier.Equals} + /> + </Form.Group> {criterion.value.depth !== 0 && ( <Form.Group> diff --git a/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx b/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx index a99fdde3a54..50765847476 100644 --- a/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx @@ -45,6 +45,7 @@ const StudiosFilter: React.FC<IStudiosFilter> = ({ criterion={criterion} setCriterion={setCriterion} useResults={useStudioQuery} + singleValue /> ); }; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 9f9519b351f..f234e751126 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -303,6 +303,15 @@ input[type="range"].zoom-slider { padding-bottom: 0.15rem; padding-inline-start: 0; + .modifier-object { + font-style: italic; + + .selected-object-label, + .unselected-object-label { + opacity: 0.6; + } + } + .unselected-object { opacity: 0.8; } diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index b90a77d2dd3..6be0a654240 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -844,6 +844,12 @@ "not_matches_regex": "not matches regex", "not_null": "is not null" }, + "criterion_modifier_values": { + "any": "Any", + "any_of": "Any of", + "none": "None", + "only": "Only" + }, "custom": "Custom", "date": "Date", "date_format": "YYYY-MM-DD", From 89f539ee2412984d72044fbebaf0bd551199876b Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 29 Oct 2024 17:35:17 +1100 Subject: [PATCH 79/91] Upgrade gqlgenc and regenerate stash-box client (#5391) * Upgrade gqlgenc and regenerate stash-box client * Fix go version * Don't generate resolvers * Bump go version in compiler image. Bump freebsd version --- .github/workflows/build.yml | 2 +- .github/workflows/golangci-lint.yml | 2 +- docker/compiler/Dockerfile | 6 +- docker/compiler/Makefile | 2 +- go.mod | 32 +- go.sum | 88 +- gqlgen.yml | 3 - internal/api/server.go | 3 +- internal/manager/task_scan.go | 4 +- .../stashbox/graphql/generated_client.go | 1413 ++++++++++++----- .../stashbox/graphql/generated_models.go | 193 ++- pkg/scraper/stashbox/stash_box.go | 15 +- 12 files changed, 1194 insertions(+), 569 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f424ef5d518..8b6627395f5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ concurrency: cancel-in-progress: true env: - COMPILER_IMAGE: stashapp/compiler:9 + COMPILER_IMAGE: stashapp/compiler:10 jobs: build: diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index e29d56c7999..cbb3b021f62 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -9,7 +9,7 @@ on: pull_request: env: - COMPILER_IMAGE: stashapp/compiler:9 + COMPILER_IMAGE: stashapp/compiler:10 jobs: golangci: diff --git a/docker/compiler/Dockerfile b/docker/compiler/Dockerfile index d69cea3e34d..737f0ee10f6 100644 --- a/docker/compiler/Dockerfile +++ b/docker/compiler/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.22 +FROM golang:1.22.8 LABEL maintainer="https://discord.gg/2TsNFKt" @@ -26,9 +26,9 @@ RUN apt-get update && \ # FreeBSD cross-compilation setup # https://github.com/smartmontools/docker-build/blob/6b8c92560d17d325310ba02d9f5a4b250cb0764a/Dockerfile#L66 -ENV FREEBSD_VERSION 12.4 +ENV FREEBSD_VERSION 13.4 ENV FREEBSD_DOWNLOAD_URL http://ftp.plusline.de/FreeBSD/releases/amd64/${FREEBSD_VERSION}-RELEASE/base.txz -ENV FREEBSD_SHA 581c7edacfd2fca2bdf5791f667402d22fccd8a5e184635e0cac075564d57aa8 +ENV FREEBSD_SHA 8e13b0a93daba349b8d28ad246d7beb327659b2ef4fe44d89f447392daec5a7c RUN cd /tmp && \ curl -o base.txz $FREEBSD_DOWNLOAD_URL && \ diff --git a/docker/compiler/Makefile b/docker/compiler/Makefile index dbd9e16f89e..2411fdabb69 100644 --- a/docker/compiler/Makefile +++ b/docker/compiler/Makefile @@ -1,6 +1,6 @@ user=stashapp repo=compiler -version=9 +version=10 latest: docker build -t ${user}/${repo}:latest . diff --git a/go.mod b/go.mod index 7f7d6170332..ea1dbc61808 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ module github.com/stashapp/stash -go 1.22 +go 1.22.8 require ( - github.com/99designs/gqlgen v0.17.49 + github.com/99designs/gqlgen v0.17.55 github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552 - github.com/Yamashou/gqlgenc v0.0.6 + github.com/Yamashou/gqlgenc v0.25.3 github.com/anacrolix/dms v1.2.2 github.com/antchfx/htmlquery v1.3.0 github.com/asticode/go-astisub v0.25.1 @@ -47,27 +47,27 @@ require ( github.com/tidwall/gjson v1.16.0 github.com/vearutop/statigz v1.4.0 github.com/vektah/dataloaden v0.3.0 - github.com/vektah/gqlparser/v2 v2.5.16 + github.com/vektah/gqlparser/v2 v2.5.18 github.com/vektra/mockery/v2 v2.10.0 github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e github.com/zencoder/go-dash/v3 v3.0.2 - golang.org/x/crypto v0.24.0 + golang.org/x/crypto v0.28.0 golang.org/x/image v0.18.0 - golang.org/x/net v0.26.0 - golang.org/x/sys v0.21.0 - golang.org/x/term v0.21.0 - golang.org/x/text v0.16.0 + golang.org/x/net v0.30.0 + golang.org/x/sys v0.26.0 + golang.org/x/term v0.25.0 + golang.org/x/text v0.19.0 gopkg.in/guregu/null.v4 v4.0.0 gopkg.in/yaml.v2 v2.4.0 ) require ( - github.com/agnivade/levenshtein v1.1.1 // indirect + github.com/agnivade/levenshtein v1.2.0 // indirect github.com/antchfx/xpath v1.2.3 // indirect github.com/asticode/go-astikit v0.20.0 // indirect github.com/asticode/go-astits v1.8.0 // indirect github.com/chromedp/sysutil v1.0.0 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.7.0 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect @@ -108,12 +108,12 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - github.com/urfave/cli/v2 v2.27.2 // indirect - github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect + github.com/urfave/cli/v2 v2.27.5 // indirect + github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/mod v0.18.0 // indirect - golang.org/x/sync v0.7.0 // indirect - golang.org/x/tools v0.22.0 // indirect + golang.org/x/mod v0.21.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/tools v0.26.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 8c3b00d61f8..3af4fce7f61 100644 --- a/go.sum +++ b/go.sum @@ -51,26 +51,23 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/99designs/gqlgen v0.17.2/go.mod h1:K5fzLKwtph+FFgh9j7nFbRUdBKvTcGnsta51fsMTn3o= -github.com/99designs/gqlgen v0.17.49 h1:b3hNGexHd33fBSAd4NDT/c3NCcQzcAVkknhN9ym36YQ= -github.com/99designs/gqlgen v0.17.49/go.mod h1:tC8YFVZMed81x7UJ7ORUwXF4Kn6SXuucFqQBhN8+BU0= +github.com/99designs/gqlgen v0.17.55 h1:3vzrNWYyzSZjGDFo68e5j9sSauLxfKvLp+6ioRokVtM= +github.com/99designs/gqlgen v0.17.55/go.mod h1:3Bq768f8hgVPGZxL8aY9MaYmbxa6llPM/qu1IGH1EJo= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE= -github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= +github.com/PuerkitoBio/goquery v1.9.3 h1:mpJr/ikUA9/GNJB/DBZcGeFDXUtosHRyRrwh7KGdTG0= +github.com/PuerkitoBio/goquery v1.9.3/go.mod h1:1ndLHPdTz+DyQPICCWYlYQMPl0oXZj0G6D4LCYA6u4U= github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w= github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552 h1:eukVk+mGmbSZppLw8WJGpEUgMC570eb32y7FOsPW4Kc= github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552/go.mod h1:LKbO1i6L1lSlwWx4NHWVECxubHNKFz2YQoEMGXAFVy8= -github.com/Yamashou/gqlgenc v0.0.6 h1:wfMTtuVSrX2N1z5/ssecxx+E7l1fa0FOq5mwFW47oY4= -github.com/Yamashou/gqlgenc v0.0.6/go.mod h1:WOXjogecRGpD1WKgxnnyHJo0/Dxn44p/LNRoE6mtFQo= -github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM= -github.com/agnivade/levenshtein v1.1.0/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= -github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= -github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= +github.com/Yamashou/gqlgenc v0.25.3 h1:mVV8/Ho8EDZUQKQZbQqqXGNq8jc8aQfPpHhZOnTkMNE= +github.com/Yamashou/gqlgenc v0.25.3/go.mod h1:G0g1N81xpIklVdnyboW1zwOHcj/n4hNfhTwfN29Rjig= +github.com/agnivade/levenshtein v1.2.0 h1:U9L4IOT0Y3i0TIlUIDJ7rVUziKi/zPbrJGaFrtYH3SY= +github.com/agnivade/levenshtein v1.2.0/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -161,18 +158,17 @@ github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9MkoYwI= github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI= -github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= +github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= -github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g= -github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= @@ -344,7 +340,6 @@ github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyC github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= @@ -435,7 +430,6 @@ github.com/kermieisinthehouse/gosx-notifier v0.1.2 h1:KV0KBeKK2B24kIHY7iK0jgS64Q github.com/kermieisinthehouse/gosx-notifier v0.1.2/go.mod h1:xyWT07azFtUOcHl96qMVvKhvKzsMcS7rKTHQyv8WTho= github.com/kermieisinthehouse/systray v1.2.4 h1:pdH5vnl+KKjRrVCRU4g/2W1/0HVzuuJ6WXHlPPHYY6s= github.com/kermieisinthehouse/systray v1.2.4/go.mod h1:axh6C/jNuSyC0QGtidZJURc9h+h41HNoMySoLVrhVR4= -github.com/kevinmbeaulieu/eq-go v1.0.0/go.mod h1:G3S8ajA56gKBZm4UB9AOyoOS37JO3roToPzKNM8dtdM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/knadh/koanf v1.5.0 h1:q2TSd/3Pyc/5yP9ldIrSdIz26MCcyNQzW0pEAugLPNs= @@ -459,7 +453,6 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1 github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/logrusorgru/aurora/v3 v3.0.0/go.mod h1:vsR12bk5grlLvLXAYrBsb5Oc/N+LxAlxggSjiwMnCUc= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w= @@ -468,7 +461,6 @@ github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0V github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/matryer/moq v0.2.3/go.mod h1:9RtPYjTnH1bSBIkpvtHkFN7nbWAnO7oRpdJkEIn6UtE= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -505,7 +497,6 @@ github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eI github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.2.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= @@ -587,7 +578,6 @@ github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+ github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU= github.com/rs/zerolog v1.30.0 h1:SymVODrcRsaRaSInD9yQtKbtWqwsfoPcRff/oRXLj4c= github.com/rs/zerolog v1.30.0/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= @@ -599,12 +589,10 @@ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5P github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= github.com/sagikazarmark/crypt v0.4.0/go.mod h1:ALv2SRj7GxYV4HO9elxH9nS6M9gW+xDNxqmyJ6RfDFM= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f h1:tygelZueB1EtXkPI6mQ4o9DQ0+FKW41hTbunoXZCTqk= github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= @@ -664,24 +652,21 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= -github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= -github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI= -github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM= +github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= +github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= github.com/vearutop/statigz v1.4.0 h1:RQL0KG3j/uyA/PFpHeZ/L6l2ta920/MxlOAIGEOuwmU= github.com/vearutop/statigz v1.4.0/go.mod h1:LYTolBLiz9oJISwiVKnOQoIwhO1LWX1A7OECawGS8XE= github.com/vektah/dataloaden v0.3.0 h1:ZfVN2QD6swgvp+tDqdH/OIT/wu3Dhu0cus0k5gIZS84= github.com/vektah/dataloaden v0.3.0/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U= -github.com/vektah/gqlparser/v2 v2.4.0/go.mod h1:flJWIR04IMQPGz+BXLrORkrARBxv/rtyIAFvd/MceW0= -github.com/vektah/gqlparser/v2 v2.4.1/go.mod h1:flJWIR04IMQPGz+BXLrORkrARBxv/rtyIAFvd/MceW0= -github.com/vektah/gqlparser/v2 v2.5.16 h1:1gcmLTvs3JLKXckwCwlUagVn/IlV2bwqle0vJ0vy5p8= -github.com/vektah/gqlparser/v2 v2.5.16/go.mod h1:1lz1OeCqgQbQepsGxPVywrjdBHW2T08PUS3pJqepRww= +github.com/vektah/gqlparser/v2 v2.5.18 h1:zSND3GtutylAQ1JpWnTHcqtaRZjl+y3NROeW8vuNo6Y= +github.com/vektah/gqlparser/v2 v2.5.18/go.mod h1:6HLzf7JKv9Fi3APymudztFQNmLXR5qJeEo6BOFcXVfc= github.com/vektra/mockery/v2 v2.10.0 h1:MiiQWxwdq7/ET6dCXLaJzSGEN17k758H7JHS9kOdiks= github.com/vektra/mockery/v2 v2.10.0/go.mod h1:m/WO2UzWzqgVX3nvqpRQq70I4Z7jbSCRhdmkgtp+Ab4= github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e h1:GruPsb+44XvYAzuAgJW1d1WHqmcI73L2XSjsbx/eJZw= github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e/go.mod h1:wA8kQ8WFipMciY9WcWzqQgZordm/P7l8IZdvx1crwmc= -github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw= -github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -728,8 +713,8 @@ golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= -golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -770,10 +755,9 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= -golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= -golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= +golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -823,8 +807,8 @@ golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= -golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= -golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -854,8 +838,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -938,7 +922,6 @@ golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -947,13 +930,13 @@ golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= -golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= -golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= +golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -966,8 +949,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1013,7 +996,6 @@ golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200815165600-90abf76919f3/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= @@ -1030,11 +1012,9 @@ golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= -golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= -golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= -golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/gqlgen.yml b/gqlgen.yml index 9f22ccb49e2..bfa5654c1b0 100644 --- a/gqlgen.yml +++ b/gqlgen.yml @@ -7,9 +7,6 @@ exec: filename: internal/api/generated_exec.go model: filename: internal/api/generated_models.go -resolver: - filename: internal/api/resolver.go - type: Resolver struct_tag: gqlgen diff --git a/internal/api/server.go b/internal/api/server.go index 63a81da7c2e..bce8e6a07d1 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -27,6 +27,7 @@ import ( "github.com/go-chi/httplog" "github.com/gorilla/websocket" "github.com/vearutop/statigz" + "github.com/vektah/gqlparser/v2/ast" "github.com/stashapp/stash/internal/api/loaders" "github.com/stashapp/stash/internal/build" @@ -185,7 +186,7 @@ func Initialize() (*Server, error) { MaxUploadSize: cfg.GetMaxUploadSize(), }) - gqlSrv.SetQueryCache(gqlLru.New(1000)) + gqlSrv.SetQueryCache(gqlLru.New[*ast.QueryDocument](1000)) gqlSrv.Use(gqlExtension.Introspection{}) gqlSrv.SetErrorPresenter(gqlErrorHandler) diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index 48a06d09829..6f7f34b3c9d 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -123,7 +123,7 @@ type handlerRequiredFilter struct { GalleryFinder galleryFinder CaptionUpdater video.CaptionUpdater - FolderCache *lru.LRU + FolderCache *lru.LRU[bool] videoFileNamingAlgorithm models.HashAlgorithm } @@ -138,7 +138,7 @@ func newHandlerRequiredFilter(c *config.Config, repo models.Repository) *handler ImageFinder: repo.Image, GalleryFinder: repo.Gallery, CaptionUpdater: repo.File, - FolderCache: lru.New(processes * 2), + FolderCache: lru.New[bool](processes * 2), videoFileNamingAlgorithm: c.GetVideoFileNamingAlgorithm(), } } diff --git a/pkg/scraper/stashbox/graphql/generated_client.go b/pkg/scraper/stashbox/graphql/generated_client.go index b87d70343fa..9b9340239bb 100644 --- a/pkg/scraper/stashbox/graphql/generated_client.go +++ b/pkg/scraper/stashbox/graphql/generated_client.go @@ -6,240 +6,796 @@ import ( "context" "net/http" - "github.com/Yamashou/gqlgenc/client" + "github.com/Yamashou/gqlgenc/clientv2" ) type StashBoxGraphQLClient interface { - FindSceneByFingerprint(ctx context.Context, fingerprint FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindSceneByFingerprint, error) - FindScenesByFullFingerprints(ctx context.Context, fingerprints []*FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindScenesByFullFingerprints, error) - FindScenesBySceneFingerprints(ctx context.Context, fingerprints [][]*FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindScenesBySceneFingerprints, error) - SearchScene(ctx context.Context, term string, httpRequestOptions ...client.HTTPRequestOption) (*SearchScene, error) - SearchPerformer(ctx context.Context, term string, httpRequestOptions ...client.HTTPRequestOption) (*SearchPerformer, error) - FindPerformerByID(ctx context.Context, id string, httpRequestOptions ...client.HTTPRequestOption) (*FindPerformerByID, error) - FindSceneByID(ctx context.Context, id string, httpRequestOptions ...client.HTTPRequestOption) (*FindSceneByID, error) - FindStudio(ctx context.Context, id *string, name *string, httpRequestOptions ...client.HTTPRequestOption) (*FindStudio, error) - SubmitFingerprint(ctx context.Context, input FingerprintSubmission, httpRequestOptions ...client.HTTPRequestOption) (*SubmitFingerprint, error) - Me(ctx context.Context, httpRequestOptions ...client.HTTPRequestOption) (*Me, error) - SubmitSceneDraft(ctx context.Context, input SceneDraftInput, httpRequestOptions ...client.HTTPRequestOption) (*SubmitSceneDraft, error) - SubmitPerformerDraft(ctx context.Context, input PerformerDraftInput, httpRequestOptions ...client.HTTPRequestOption) (*SubmitPerformerDraft, error) + FindSceneByFingerprint(ctx context.Context, fingerprint FingerprintQueryInput, interceptors ...clientv2.RequestInterceptor) (*FindSceneByFingerprint, error) + FindScenesByFullFingerprints(ctx context.Context, fingerprints []*FingerprintQueryInput, interceptors ...clientv2.RequestInterceptor) (*FindScenesByFullFingerprints, error) + FindScenesBySceneFingerprints(ctx context.Context, fingerprints [][]*FingerprintQueryInput, interceptors ...clientv2.RequestInterceptor) (*FindScenesBySceneFingerprints, error) + SearchScene(ctx context.Context, term string, interceptors ...clientv2.RequestInterceptor) (*SearchScene, error) + SearchPerformer(ctx context.Context, term string, interceptors ...clientv2.RequestInterceptor) (*SearchPerformer, error) + FindPerformerByID(ctx context.Context, id string, interceptors ...clientv2.RequestInterceptor) (*FindPerformerByID, error) + FindSceneByID(ctx context.Context, id string, interceptors ...clientv2.RequestInterceptor) (*FindSceneByID, error) + FindStudio(ctx context.Context, id *string, name *string, interceptors ...clientv2.RequestInterceptor) (*FindStudio, error) + SubmitFingerprint(ctx context.Context, input FingerprintSubmission, interceptors ...clientv2.RequestInterceptor) (*SubmitFingerprint, error) + Me(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*Me, error) + SubmitSceneDraft(ctx context.Context, input SceneDraftInput, interceptors ...clientv2.RequestInterceptor) (*SubmitSceneDraft, error) + SubmitPerformerDraft(ctx context.Context, input PerformerDraftInput, interceptors ...clientv2.RequestInterceptor) (*SubmitPerformerDraft, error) } type Client struct { - Client *client.Client -} - -func NewClient(cli *http.Client, baseURL string, options ...client.HTTPRequestOption) StashBoxGraphQLClient { - return &Client{Client: client.NewClient(cli, baseURL, options...)} -} - -type Query struct { - FindPerformer *Performer "json:\"findPerformer\" graphql:\"findPerformer\"" - QueryPerformers QueryPerformersResultType "json:\"queryPerformers\" graphql:\"queryPerformers\"" - FindStudio *Studio "json:\"findStudio\" graphql:\"findStudio\"" - QueryStudios QueryStudiosResultType "json:\"queryStudios\" graphql:\"queryStudios\"" - FindTag *Tag "json:\"findTag\" graphql:\"findTag\"" - QueryTags QueryTagsResultType "json:\"queryTags\" graphql:\"queryTags\"" - FindTagCategory *TagCategory "json:\"findTagCategory\" graphql:\"findTagCategory\"" - QueryTagCategories QueryTagCategoriesResultType "json:\"queryTagCategories\" graphql:\"queryTagCategories\"" - FindScene *Scene "json:\"findScene\" graphql:\"findScene\"" - FindSceneByFingerprint []*Scene "json:\"findSceneByFingerprint\" graphql:\"findSceneByFingerprint\"" - FindScenesByFingerprints []*Scene "json:\"findScenesByFingerprints\" graphql:\"findScenesByFingerprints\"" - FindScenesByFullFingerprints []*Scene "json:\"findScenesByFullFingerprints\" graphql:\"findScenesByFullFingerprints\"" - FindScenesBySceneFingerprints [][]*Scene "json:\"findScenesBySceneFingerprints\" graphql:\"findScenesBySceneFingerprints\"" - QueryScenes QueryScenesResultType "json:\"queryScenes\" graphql:\"queryScenes\"" - FindSite *Site "json:\"findSite\" graphql:\"findSite\"" - QuerySites QuerySitesResultType "json:\"querySites\" graphql:\"querySites\"" - FindEdit *Edit "json:\"findEdit\" graphql:\"findEdit\"" - QueryEdits QueryEditsResultType "json:\"queryEdits\" graphql:\"queryEdits\"" - FindUser *User "json:\"findUser\" graphql:\"findUser\"" - QueryUsers QueryUsersResultType "json:\"queryUsers\" graphql:\"queryUsers\"" - Me *User "json:\"me\" graphql:\"me\"" - SearchPerformer []*Performer "json:\"searchPerformer\" graphql:\"searchPerformer\"" - SearchScene []*Scene "json:\"searchScene\" graphql:\"searchScene\"" - SearchTag []*Tag "json:\"searchTag\" graphql:\"searchTag\"" - FindDraft *Draft "json:\"findDraft\" graphql:\"findDraft\"" - FindDrafts []*Draft "json:\"findDrafts\" graphql:\"findDrafts\"" - QueryExistingScene QueryExistingSceneResult "json:\"queryExistingScene\" graphql:\"queryExistingScene\"" - Version Version "json:\"version\" graphql:\"version\"" - GetConfig StashBoxConfig "json:\"getConfig\" graphql:\"getConfig\"" -} -type Mutation struct { - SceneCreate *Scene "json:\"sceneCreate\" graphql:\"sceneCreate\"" - SceneUpdate *Scene "json:\"sceneUpdate\" graphql:\"sceneUpdate\"" - SceneDestroy bool "json:\"sceneDestroy\" graphql:\"sceneDestroy\"" - PerformerCreate *Performer "json:\"performerCreate\" graphql:\"performerCreate\"" - PerformerUpdate *Performer "json:\"performerUpdate\" graphql:\"performerUpdate\"" - PerformerDestroy bool "json:\"performerDestroy\" graphql:\"performerDestroy\"" - StudioCreate *Studio "json:\"studioCreate\" graphql:\"studioCreate\"" - StudioUpdate *Studio "json:\"studioUpdate\" graphql:\"studioUpdate\"" - StudioDestroy bool "json:\"studioDestroy\" graphql:\"studioDestroy\"" - TagCreate *Tag "json:\"tagCreate\" graphql:\"tagCreate\"" - TagUpdate *Tag "json:\"tagUpdate\" graphql:\"tagUpdate\"" - TagDestroy bool "json:\"tagDestroy\" graphql:\"tagDestroy\"" - UserCreate *User "json:\"userCreate\" graphql:\"userCreate\"" - UserUpdate *User "json:\"userUpdate\" graphql:\"userUpdate\"" - UserDestroy bool "json:\"userDestroy\" graphql:\"userDestroy\"" - ImageCreate *Image "json:\"imageCreate\" graphql:\"imageCreate\"" - ImageDestroy bool "json:\"imageDestroy\" graphql:\"imageDestroy\"" - NewUser *string "json:\"newUser\" graphql:\"newUser\"" - ActivateNewUser *User "json:\"activateNewUser\" graphql:\"activateNewUser\"" - GenerateInviteCode *string "json:\"generateInviteCode\" graphql:\"generateInviteCode\"" - RescindInviteCode bool "json:\"rescindInviteCode\" graphql:\"rescindInviteCode\"" - GrantInvite int "json:\"grantInvite\" graphql:\"grantInvite\"" - RevokeInvite int "json:\"revokeInvite\" graphql:\"revokeInvite\"" - TagCategoryCreate *TagCategory "json:\"tagCategoryCreate\" graphql:\"tagCategoryCreate\"" - TagCategoryUpdate *TagCategory "json:\"tagCategoryUpdate\" graphql:\"tagCategoryUpdate\"" - TagCategoryDestroy bool "json:\"tagCategoryDestroy\" graphql:\"tagCategoryDestroy\"" - SiteCreate *Site "json:\"siteCreate\" graphql:\"siteCreate\"" - SiteUpdate *Site "json:\"siteUpdate\" graphql:\"siteUpdate\"" - SiteDestroy bool "json:\"siteDestroy\" graphql:\"siteDestroy\"" - RegenerateAPIKey string "json:\"regenerateAPIKey\" graphql:\"regenerateAPIKey\"" - ResetPassword bool "json:\"resetPassword\" graphql:\"resetPassword\"" - ChangePassword bool "json:\"changePassword\" graphql:\"changePassword\"" - SceneEdit Edit "json:\"sceneEdit\" graphql:\"sceneEdit\"" - PerformerEdit Edit "json:\"performerEdit\" graphql:\"performerEdit\"" - StudioEdit Edit "json:\"studioEdit\" graphql:\"studioEdit\"" - TagEdit Edit "json:\"tagEdit\" graphql:\"tagEdit\"" - SceneEditUpdate Edit "json:\"sceneEditUpdate\" graphql:\"sceneEditUpdate\"" - PerformerEditUpdate Edit "json:\"performerEditUpdate\" graphql:\"performerEditUpdate\"" - StudioEditUpdate Edit "json:\"studioEditUpdate\" graphql:\"studioEditUpdate\"" - TagEditUpdate Edit "json:\"tagEditUpdate\" graphql:\"tagEditUpdate\"" - EditVote Edit "json:\"editVote\" graphql:\"editVote\"" - EditComment Edit "json:\"editComment\" graphql:\"editComment\"" - ApplyEdit Edit "json:\"applyEdit\" graphql:\"applyEdit\"" - CancelEdit Edit "json:\"cancelEdit\" graphql:\"cancelEdit\"" - SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\"" - SubmitSceneDraft DraftSubmissionStatus "json:\"submitSceneDraft\" graphql:\"submitSceneDraft\"" - SubmitPerformerDraft DraftSubmissionStatus "json:\"submitPerformerDraft\" graphql:\"submitPerformerDraft\"" - DestroyDraft bool "json:\"destroyDraft\" graphql:\"destroyDraft\"" - FavoritePerformer bool "json:\"favoritePerformer\" graphql:\"favoritePerformer\"" - FavoriteStudio bool "json:\"favoriteStudio\" graphql:\"favoriteStudio\"" + Client *clientv2.Client } + +func NewClient(cli *http.Client, baseURL string, options *clientv2.Options, interceptors ...clientv2.RequestInterceptor) StashBoxGraphQLClient { + return &Client{Client: clientv2.NewClient(cli, baseURL, options, interceptors...)} +} + type URLFragment struct { URL string "json:\"url\" graphql:\"url\"" Type string "json:\"type\" graphql:\"type\"" } + +func (t *URLFragment) GetURL() string { + if t == nil { + t = &URLFragment{} + } + return t.URL +} +func (t *URLFragment) GetType() string { + if t == nil { + t = &URLFragment{} + } + return t.Type +} + type ImageFragment struct { ID string "json:\"id\" graphql:\"id\"" URL string "json:\"url\" graphql:\"url\"" Width int "json:\"width\" graphql:\"width\"" Height int "json:\"height\" graphql:\"height\"" } + +func (t *ImageFragment) GetID() string { + if t == nil { + t = &ImageFragment{} + } + return t.ID +} +func (t *ImageFragment) GetURL() string { + if t == nil { + t = &ImageFragment{} + } + return t.URL +} +func (t *ImageFragment) GetWidth() int { + if t == nil { + t = &ImageFragment{} + } + return t.Width +} +func (t *ImageFragment) GetHeight() int { + if t == nil { + t = &ImageFragment{} + } + return t.Height +} + type StudioFragment struct { - Name string "json:\"name\" graphql:\"name\"" - ID string "json:\"id\" graphql:\"id\"" - Urls []*URLFragment "json:\"urls\" graphql:\"urls\"" - Parent *struct { - Name string "json:\"name\" graphql:\"name\"" - ID string "json:\"id\" graphql:\"id\"" - } "json:\"parent\" graphql:\"parent\"" - Images []*ImageFragment "json:\"images\" graphql:\"images\"" + Name string "json:\"name\" graphql:\"name\"" + ID string "json:\"id\" graphql:\"id\"" + Urls []*URLFragment "json:\"urls\" graphql:\"urls\"" + Parent *StudioFragment_Parent "json:\"parent,omitempty\" graphql:\"parent\"" + Images []*ImageFragment "json:\"images\" graphql:\"images\"" +} + +func (t *StudioFragment) GetName() string { + if t == nil { + t = &StudioFragment{} + } + return t.Name +} +func (t *StudioFragment) GetID() string { + if t == nil { + t = &StudioFragment{} + } + return t.ID +} +func (t *StudioFragment) GetUrls() []*URLFragment { + if t == nil { + t = &StudioFragment{} + } + return t.Urls +} +func (t *StudioFragment) GetParent() *StudioFragment_Parent { + if t == nil { + t = &StudioFragment{} + } + return t.Parent +} +func (t *StudioFragment) GetImages() []*ImageFragment { + if t == nil { + t = &StudioFragment{} + } + return t.Images } + type TagFragment struct { Name string "json:\"name\" graphql:\"name\"" ID string "json:\"id\" graphql:\"id\"" } + +func (t *TagFragment) GetName() string { + if t == nil { + t = &TagFragment{} + } + return t.Name +} +func (t *TagFragment) GetID() string { + if t == nil { + t = &TagFragment{} + } + return t.ID +} + type FuzzyDateFragment struct { Date string "json:\"date\" graphql:\"date\"" Accuracy DateAccuracyEnum "json:\"accuracy\" graphql:\"accuracy\"" } + +func (t *FuzzyDateFragment) GetDate() string { + if t == nil { + t = &FuzzyDateFragment{} + } + return t.Date +} +func (t *FuzzyDateFragment) GetAccuracy() *DateAccuracyEnum { + if t == nil { + t = &FuzzyDateFragment{} + } + return &t.Accuracy +} + type MeasurementsFragment struct { - BandSize *int "json:\"band_size\" graphql:\"band_size\"" - CupSize *string "json:\"cup_size\" graphql:\"cup_size\"" - Waist *int "json:\"waist\" graphql:\"waist\"" - Hip *int "json:\"hip\" graphql:\"hip\"" + BandSize *int "json:\"band_size,omitempty\" graphql:\"band_size\"" + CupSize *string "json:\"cup_size,omitempty\" graphql:\"cup_size\"" + Waist *int "json:\"waist,omitempty\" graphql:\"waist\"" + Hip *int "json:\"hip,omitempty\" graphql:\"hip\"" } + +func (t *MeasurementsFragment) GetBandSize() *int { + if t == nil { + t = &MeasurementsFragment{} + } + return t.BandSize +} +func (t *MeasurementsFragment) GetCupSize() *string { + if t == nil { + t = &MeasurementsFragment{} + } + return t.CupSize +} +func (t *MeasurementsFragment) GetWaist() *int { + if t == nil { + t = &MeasurementsFragment{} + } + return t.Waist +} +func (t *MeasurementsFragment) GetHip() *int { + if t == nil { + t = &MeasurementsFragment{} + } + return t.Hip +} + type BodyModificationFragment struct { Location string "json:\"location\" graphql:\"location\"" - Description *string "json:\"description\" graphql:\"description\"" + Description *string "json:\"description,omitempty\" graphql:\"description\"" +} + +func (t *BodyModificationFragment) GetLocation() string { + if t == nil { + t = &BodyModificationFragment{} + } + return t.Location +} +func (t *BodyModificationFragment) GetDescription() *string { + if t == nil { + t = &BodyModificationFragment{} + } + return t.Description } + type PerformerFragment struct { ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" - Disambiguation *string "json:\"disambiguation\" graphql:\"disambiguation\"" + Disambiguation *string "json:\"disambiguation,omitempty\" graphql:\"disambiguation\"" Aliases []string "json:\"aliases\" graphql:\"aliases\"" - Gender *GenderEnum "json:\"gender\" graphql:\"gender\"" + Gender *GenderEnum "json:\"gender,omitempty\" graphql:\"gender\"" MergedIds []string "json:\"merged_ids\" graphql:\"merged_ids\"" Urls []*URLFragment "json:\"urls\" graphql:\"urls\"" Images []*ImageFragment "json:\"images\" graphql:\"images\"" - Birthdate *FuzzyDateFragment "json:\"birthdate\" graphql:\"birthdate\"" - Ethnicity *EthnicityEnum "json:\"ethnicity\" graphql:\"ethnicity\"" - Country *string "json:\"country\" graphql:\"country\"" - EyeColor *EyeColorEnum "json:\"eye_color\" graphql:\"eye_color\"" - HairColor *HairColorEnum "json:\"hair_color\" graphql:\"hair_color\"" - Height *int "json:\"height\" graphql:\"height\"" - Measurements MeasurementsFragment "json:\"measurements\" graphql:\"measurements\"" - BreastType *BreastTypeEnum "json:\"breast_type\" graphql:\"breast_type\"" - CareerStartYear *int "json:\"career_start_year\" graphql:\"career_start_year\"" - CareerEndYear *int "json:\"career_end_year\" graphql:\"career_end_year\"" - Tattoos []*BodyModificationFragment "json:\"tattoos\" graphql:\"tattoos\"" - Piercings []*BodyModificationFragment "json:\"piercings\" graphql:\"piercings\"" + Birthdate *FuzzyDateFragment "json:\"birthdate,omitempty\" graphql:\"birthdate\"" + Ethnicity *EthnicityEnum "json:\"ethnicity,omitempty\" graphql:\"ethnicity\"" + Country *string "json:\"country,omitempty\" graphql:\"country\"" + EyeColor *EyeColorEnum "json:\"eye_color,omitempty\" graphql:\"eye_color\"" + HairColor *HairColorEnum "json:\"hair_color,omitempty\" graphql:\"hair_color\"" + Height *int "json:\"height,omitempty\" graphql:\"height\"" + Measurements *MeasurementsFragment "json:\"measurements\" graphql:\"measurements\"" + BreastType *BreastTypeEnum "json:\"breast_type,omitempty\" graphql:\"breast_type\"" + CareerStartYear *int "json:\"career_start_year,omitempty\" graphql:\"career_start_year\"" + CareerEndYear *int "json:\"career_end_year,omitempty\" graphql:\"career_end_year\"" + Tattoos []*BodyModificationFragment "json:\"tattoos,omitempty\" graphql:\"tattoos\"" + Piercings []*BodyModificationFragment "json:\"piercings,omitempty\" graphql:\"piercings\"" +} + +func (t *PerformerFragment) GetID() string { + if t == nil { + t = &PerformerFragment{} + } + return t.ID +} +func (t *PerformerFragment) GetName() string { + if t == nil { + t = &PerformerFragment{} + } + return t.Name +} +func (t *PerformerFragment) GetDisambiguation() *string { + if t == nil { + t = &PerformerFragment{} + } + return t.Disambiguation } +func (t *PerformerFragment) GetAliases() []string { + if t == nil { + t = &PerformerFragment{} + } + return t.Aliases +} +func (t *PerformerFragment) GetGender() *GenderEnum { + if t == nil { + t = &PerformerFragment{} + } + return t.Gender +} +func (t *PerformerFragment) GetMergedIds() []string { + if t == nil { + t = &PerformerFragment{} + } + return t.MergedIds +} +func (t *PerformerFragment) GetUrls() []*URLFragment { + if t == nil { + t = &PerformerFragment{} + } + return t.Urls +} +func (t *PerformerFragment) GetImages() []*ImageFragment { + if t == nil { + t = &PerformerFragment{} + } + return t.Images +} +func (t *PerformerFragment) GetBirthdate() *FuzzyDateFragment { + if t == nil { + t = &PerformerFragment{} + } + return t.Birthdate +} +func (t *PerformerFragment) GetEthnicity() *EthnicityEnum { + if t == nil { + t = &PerformerFragment{} + } + return t.Ethnicity +} +func (t *PerformerFragment) GetCountry() *string { + if t == nil { + t = &PerformerFragment{} + } + return t.Country +} +func (t *PerformerFragment) GetEyeColor() *EyeColorEnum { + if t == nil { + t = &PerformerFragment{} + } + return t.EyeColor +} +func (t *PerformerFragment) GetHairColor() *HairColorEnum { + if t == nil { + t = &PerformerFragment{} + } + return t.HairColor +} +func (t *PerformerFragment) GetHeight() *int { + if t == nil { + t = &PerformerFragment{} + } + return t.Height +} +func (t *PerformerFragment) GetMeasurements() *MeasurementsFragment { + if t == nil { + t = &PerformerFragment{} + } + return t.Measurements +} +func (t *PerformerFragment) GetBreastType() *BreastTypeEnum { + if t == nil { + t = &PerformerFragment{} + } + return t.BreastType +} +func (t *PerformerFragment) GetCareerStartYear() *int { + if t == nil { + t = &PerformerFragment{} + } + return t.CareerStartYear +} +func (t *PerformerFragment) GetCareerEndYear() *int { + if t == nil { + t = &PerformerFragment{} + } + return t.CareerEndYear +} +func (t *PerformerFragment) GetTattoos() []*BodyModificationFragment { + if t == nil { + t = &PerformerFragment{} + } + return t.Tattoos +} +func (t *PerformerFragment) GetPiercings() []*BodyModificationFragment { + if t == nil { + t = &PerformerFragment{} + } + return t.Piercings +} + type PerformerAppearanceFragment struct { - As *string "json:\"as\" graphql:\"as\"" - Performer PerformerFragment "json:\"performer\" graphql:\"performer\"" + As *string "json:\"as,omitempty\" graphql:\"as\"" + Performer *PerformerFragment "json:\"performer\" graphql:\"performer\"" } + +func (t *PerformerAppearanceFragment) GetAs() *string { + if t == nil { + t = &PerformerAppearanceFragment{} + } + return t.As +} +func (t *PerformerAppearanceFragment) GetPerformer() *PerformerFragment { + if t == nil { + t = &PerformerAppearanceFragment{} + } + return t.Performer +} + type FingerprintFragment struct { Algorithm FingerprintAlgorithm "json:\"algorithm\" graphql:\"algorithm\"" Hash string "json:\"hash\" graphql:\"hash\"" Duration int "json:\"duration\" graphql:\"duration\"" } + +func (t *FingerprintFragment) GetAlgorithm() *FingerprintAlgorithm { + if t == nil { + t = &FingerprintFragment{} + } + return &t.Algorithm +} +func (t *FingerprintFragment) GetHash() string { + if t == nil { + t = &FingerprintFragment{} + } + return t.Hash +} +func (t *FingerprintFragment) GetDuration() int { + if t == nil { + t = &FingerprintFragment{} + } + return t.Duration +} + type SceneFragment struct { ID string "json:\"id\" graphql:\"id\"" - Title *string "json:\"title\" graphql:\"title\"" - Code *string "json:\"code\" graphql:\"code\"" - Details *string "json:\"details\" graphql:\"details\"" - Director *string "json:\"director\" graphql:\"director\"" - Duration *int "json:\"duration\" graphql:\"duration\"" - Date *string "json:\"date\" graphql:\"date\"" + Title *string "json:\"title,omitempty\" graphql:\"title\"" + Code *string "json:\"code,omitempty\" graphql:\"code\"" + Details *string "json:\"details,omitempty\" graphql:\"details\"" + Director *string "json:\"director,omitempty\" graphql:\"director\"" + Duration *int "json:\"duration,omitempty\" graphql:\"duration\"" + Date *string "json:\"date,omitempty\" graphql:\"date\"" Urls []*URLFragment "json:\"urls\" graphql:\"urls\"" Images []*ImageFragment "json:\"images\" graphql:\"images\"" - Studio *StudioFragment "json:\"studio\" graphql:\"studio\"" + Studio *StudioFragment "json:\"studio,omitempty\" graphql:\"studio\"" Tags []*TagFragment "json:\"tags\" graphql:\"tags\"" Performers []*PerformerAppearanceFragment "json:\"performers\" graphql:\"performers\"" Fingerprints []*FingerprintFragment "json:\"fingerprints\" graphql:\"fingerprints\"" } + +func (t *SceneFragment) GetID() string { + if t == nil { + t = &SceneFragment{} + } + return t.ID +} +func (t *SceneFragment) GetTitle() *string { + if t == nil { + t = &SceneFragment{} + } + return t.Title +} +func (t *SceneFragment) GetCode() *string { + if t == nil { + t = &SceneFragment{} + } + return t.Code +} +func (t *SceneFragment) GetDetails() *string { + if t == nil { + t = &SceneFragment{} + } + return t.Details +} +func (t *SceneFragment) GetDirector() *string { + if t == nil { + t = &SceneFragment{} + } + return t.Director +} +func (t *SceneFragment) GetDuration() *int { + if t == nil { + t = &SceneFragment{} + } + return t.Duration +} +func (t *SceneFragment) GetDate() *string { + if t == nil { + t = &SceneFragment{} + } + return t.Date +} +func (t *SceneFragment) GetUrls() []*URLFragment { + if t == nil { + t = &SceneFragment{} + } + return t.Urls +} +func (t *SceneFragment) GetImages() []*ImageFragment { + if t == nil { + t = &SceneFragment{} + } + return t.Images +} +func (t *SceneFragment) GetStudio() *StudioFragment { + if t == nil { + t = &SceneFragment{} + } + return t.Studio +} +func (t *SceneFragment) GetTags() []*TagFragment { + if t == nil { + t = &SceneFragment{} + } + return t.Tags +} +func (t *SceneFragment) GetPerformers() []*PerformerAppearanceFragment { + if t == nil { + t = &SceneFragment{} + } + return t.Performers +} +func (t *SceneFragment) GetFingerprints() []*FingerprintFragment { + if t == nil { + t = &SceneFragment{} + } + return t.Fingerprints +} + +type StudioFragment_Parent struct { + Name string "json:\"name\" graphql:\"name\"" + ID string "json:\"id\" graphql:\"id\"" +} + +func (t *StudioFragment_Parent) GetName() string { + if t == nil { + t = &StudioFragment_Parent{} + } + return t.Name +} +func (t *StudioFragment_Parent) GetID() string { + if t == nil { + t = &StudioFragment_Parent{} + } + return t.ID +} + +type SceneFragment_Studio_StudioFragment_Parent struct { + Name string "json:\"name\" graphql:\"name\"" + ID string "json:\"id\" graphql:\"id\"" +} + +func (t *SceneFragment_Studio_StudioFragment_Parent) GetName() string { + if t == nil { + t = &SceneFragment_Studio_StudioFragment_Parent{} + } + return t.Name +} +func (t *SceneFragment_Studio_StudioFragment_Parent) GetID() string { + if t == nil { + t = &SceneFragment_Studio_StudioFragment_Parent{} + } + return t.ID +} + +type FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent struct { + Name string "json:\"name\" graphql:\"name\"" + ID string "json:\"id\" graphql:\"id\"" +} + +func (t *FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent) GetName() string { + if t == nil { + t = &FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent{} + } + return t.Name +} +func (t *FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent) GetID() string { + if t == nil { + t = &FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent{} + } + return t.ID +} + +type FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent struct { + Name string "json:\"name\" graphql:\"name\"" + ID string "json:\"id\" graphql:\"id\"" +} + +func (t *FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetName() string { + if t == nil { + t = &FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent{} + } + return t.Name +} +func (t *FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetID() string { + if t == nil { + t = &FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent{} + } + return t.ID +} + +type FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent struct { + Name string "json:\"name\" graphql:\"name\"" + ID string "json:\"id\" graphql:\"id\"" +} + +func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetName() string { + if t == nil { + t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent{} + } + return t.Name +} +func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetID() string { + if t == nil { + t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent{} + } + return t.ID +} + +type SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent struct { + Name string "json:\"name\" graphql:\"name\"" + ID string "json:\"id\" graphql:\"id\"" +} + +func (t *SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent) GetName() string { + if t == nil { + t = &SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent{} + } + return t.Name +} +func (t *SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent) GetID() string { + if t == nil { + t = &SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent{} + } + return t.ID +} + +type FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent struct { + Name string "json:\"name\" graphql:\"name\"" + ID string "json:\"id\" graphql:\"id\"" +} + +func (t *FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent) GetName() string { + if t == nil { + t = &FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent{} + } + return t.Name +} +func (t *FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent) GetID() string { + if t == nil { + t = &FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent{} + } + return t.ID +} + +type FindStudio_FindStudio_StudioFragment_Parent struct { + Name string "json:\"name\" graphql:\"name\"" + ID string "json:\"id\" graphql:\"id\"" +} + +func (t *FindStudio_FindStudio_StudioFragment_Parent) GetName() string { + if t == nil { + t = &FindStudio_FindStudio_StudioFragment_Parent{} + } + return t.Name +} +func (t *FindStudio_FindStudio_StudioFragment_Parent) GetID() string { + if t == nil { + t = &FindStudio_FindStudio_StudioFragment_Parent{} + } + return t.ID +} + +type Me_Me struct { + Name string "json:\"name\" graphql:\"name\"" +} + +func (t *Me_Me) GetName() string { + if t == nil { + t = &Me_Me{} + } + return t.Name +} + +type SubmitSceneDraft_SubmitSceneDraft struct { + ID *string "json:\"id,omitempty\" graphql:\"id\"" +} + +func (t *SubmitSceneDraft_SubmitSceneDraft) GetID() *string { + if t == nil { + t = &SubmitSceneDraft_SubmitSceneDraft{} + } + return t.ID +} + +type SubmitPerformerDraft_SubmitPerformerDraft struct { + ID *string "json:\"id,omitempty\" graphql:\"id\"" +} + +func (t *SubmitPerformerDraft_SubmitPerformerDraft) GetID() *string { + if t == nil { + t = &SubmitPerformerDraft_SubmitPerformerDraft{} + } + return t.ID +} + type FindSceneByFingerprint struct { FindSceneByFingerprint []*SceneFragment "json:\"findSceneByFingerprint\" graphql:\"findSceneByFingerprint\"" } + +func (t *FindSceneByFingerprint) GetFindSceneByFingerprint() []*SceneFragment { + if t == nil { + t = &FindSceneByFingerprint{} + } + return t.FindSceneByFingerprint +} + type FindScenesByFullFingerprints struct { FindScenesByFullFingerprints []*SceneFragment "json:\"findScenesByFullFingerprints\" graphql:\"findScenesByFullFingerprints\"" } + +func (t *FindScenesByFullFingerprints) GetFindScenesByFullFingerprints() []*SceneFragment { + if t == nil { + t = &FindScenesByFullFingerprints{} + } + return t.FindScenesByFullFingerprints +} + type FindScenesBySceneFingerprints struct { FindScenesBySceneFingerprints [][]*SceneFragment "json:\"findScenesBySceneFingerprints\" graphql:\"findScenesBySceneFingerprints\"" } + +func (t *FindScenesBySceneFingerprints) GetFindScenesBySceneFingerprints() [][]*SceneFragment { + if t == nil { + t = &FindScenesBySceneFingerprints{} + } + return t.FindScenesBySceneFingerprints +} + type SearchScene struct { SearchScene []*SceneFragment "json:\"searchScene\" graphql:\"searchScene\"" } + +func (t *SearchScene) GetSearchScene() []*SceneFragment { + if t == nil { + t = &SearchScene{} + } + return t.SearchScene +} + type SearchPerformer struct { SearchPerformer []*PerformerFragment "json:\"searchPerformer\" graphql:\"searchPerformer\"" } + +func (t *SearchPerformer) GetSearchPerformer() []*PerformerFragment { + if t == nil { + t = &SearchPerformer{} + } + return t.SearchPerformer +} + type FindPerformerByID struct { - FindPerformer *PerformerFragment "json:\"findPerformer\" graphql:\"findPerformer\"" + FindPerformer *PerformerFragment "json:\"findPerformer,omitempty\" graphql:\"findPerformer\"" } + +func (t *FindPerformerByID) GetFindPerformer() *PerformerFragment { + if t == nil { + t = &FindPerformerByID{} + } + return t.FindPerformer +} + type FindSceneByID struct { - FindScene *SceneFragment "json:\"findScene\" graphql:\"findScene\"" + FindScene *SceneFragment "json:\"findScene,omitempty\" graphql:\"findScene\"" +} + +func (t *FindSceneByID) GetFindScene() *SceneFragment { + if t == nil { + t = &FindSceneByID{} + } + return t.FindScene } + type FindStudio struct { - FindStudio *StudioFragment "json:\"findStudio\" graphql:\"findStudio\"" + FindStudio *StudioFragment "json:\"findStudio,omitempty\" graphql:\"findStudio\"" +} + +func (t *FindStudio) GetFindStudio() *StudioFragment { + if t == nil { + t = &FindStudio{} + } + return t.FindStudio } + type SubmitFingerprint struct { SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\"" } + +func (t *SubmitFingerprint) GetSubmitFingerprint() bool { + if t == nil { + t = &SubmitFingerprint{} + } + return t.SubmitFingerprint +} + type Me struct { - Me *struct { - Name string "json:\"name\" graphql:\"name\"" - } "json:\"me\" graphql:\"me\"" + Me *Me_Me "json:\"me,omitempty\" graphql:\"me\"" +} + +func (t *Me) GetMe() *Me_Me { + if t == nil { + t = &Me{} + } + return t.Me } + type SubmitSceneDraft struct { - SubmitSceneDraft struct { - ID *string "json:\"id\" graphql:\"id\"" - } "json:\"submitSceneDraft\" graphql:\"submitSceneDraft\"" + SubmitSceneDraft SubmitSceneDraft_SubmitSceneDraft "json:\"submitSceneDraft\" graphql:\"submitSceneDraft\"" } + +func (t *SubmitSceneDraft) GetSubmitSceneDraft() *SubmitSceneDraft_SubmitSceneDraft { + if t == nil { + t = &SubmitSceneDraft{} + } + return &t.SubmitSceneDraft +} + type SubmitPerformerDraft struct { - SubmitPerformerDraft struct { - ID *string "json:\"id\" graphql:\"id\"" - } "json:\"submitPerformerDraft\" graphql:\"submitPerformerDraft\"" + SubmitPerformerDraft SubmitPerformerDraft_SubmitPerformerDraft "json:\"submitPerformerDraft\" graphql:\"submitPerformerDraft\"" +} + +func (t *SubmitPerformerDraft) GetSubmitPerformerDraft() *SubmitPerformerDraft_SubmitPerformerDraft { + if t == nil { + t = &SubmitPerformerDraft{} + } + return &t.SubmitPerformerDraft } const FindSceneByFingerprintDocument = `query FindSceneByFingerprint ($fingerprint: FingerprintQueryInput!) { @@ -247,11 +803,6 @@ const FindSceneByFingerprintDocument = `query FindSceneByFingerprint ($fingerpri ... SceneFragment } } -fragment FingerprintFragment on Fingerprint { - algorithm - hash - duration -} fragment SceneFragment on Scene { id title @@ -279,28 +830,16 @@ fragment SceneFragment on Scene { ... FingerprintFragment } } +fragment URLFragment on URL { + url + type +} fragment ImageFragment on Image { id url width height } -fragment TagFragment on Tag { - name - id -} -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy -} -fragment BodyModificationFragment on BodyModification { - location - description -} -fragment URLFragment on URL { - url - type -} fragment StudioFragment on Studio { name id @@ -315,6 +854,10 @@ fragment StudioFragment on Studio { ... ImageFragment } } +fragment TagFragment on Tag { + name + id +} fragment PerformerAppearanceFragment on PerformerAppearance { as performer { @@ -355,21 +898,38 @@ fragment PerformerFragment on Performer { ... BodyModificationFragment } } +fragment FuzzyDateFragment on FuzzyDate { + date + accuracy +} fragment MeasurementsFragment on Measurements { band_size cup_size waist hip } +fragment BodyModificationFragment on BodyModification { + location + description +} +fragment FingerprintFragment on Fingerprint { + algorithm + hash + duration +} ` -func (c *Client) FindSceneByFingerprint(ctx context.Context, fingerprint FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindSceneByFingerprint, error) { - vars := map[string]interface{}{ +func (c *Client) FindSceneByFingerprint(ctx context.Context, fingerprint FingerprintQueryInput, interceptors ...clientv2.RequestInterceptor) (*FindSceneByFingerprint, error) { + vars := map[string]any{ "fingerprint": fingerprint, } var res FindSceneByFingerprint - if err := c.Client.Post(ctx, "FindSceneByFingerprint", FindSceneByFingerprintDocument, &res, vars, httpRequestOptions...); err != nil { + if err := c.Client.Post(ctx, "FindSceneByFingerprint", FindSceneByFingerprintDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + return nil, err } @@ -381,16 +941,36 @@ const FindScenesByFullFingerprintsDocument = `query FindScenesByFullFingerprints ... SceneFragment } } -fragment MeasurementsFragment on Measurements { - band_size - cup_size - waist - hip -} -fragment FingerprintFragment on Fingerprint { - algorithm - hash +fragment SceneFragment on Scene { + id + title + code + details + director duration + date + urls { + ... URLFragment + } + images { + ... ImageFragment + } + studio { + ... StudioFragment + } + tags { + ... TagFragment + } + performers { + ... PerformerAppearanceFragment + } + fingerprints { + ... FingerprintFragment + } +} +fragment URLFragment on URL { + url + type } fragment ImageFragment on Image { id @@ -398,6 +978,20 @@ fragment ImageFragment on Image { width height } +fragment StudioFragment on Studio { + name + id + urls { + ... URLFragment + } + parent { + name + id + } + images { + ... ImageFragment + } +} fragment TagFragment on Tag { name id @@ -446,64 +1040,34 @@ fragment FuzzyDateFragment on FuzzyDate { date accuracy } +fragment MeasurementsFragment on Measurements { + band_size + cup_size + waist + hip +} fragment BodyModificationFragment on BodyModification { location description } -fragment SceneFragment on Scene { - id - title - code - details - director +fragment FingerprintFragment on Fingerprint { + algorithm + hash duration - date - urls { - ... URLFragment - } - images { - ... ImageFragment - } - studio { - ... StudioFragment - } - tags { - ... TagFragment - } - performers { - ... PerformerAppearanceFragment - } - fingerprints { - ... FingerprintFragment - } -} -fragment URLFragment on URL { - url - type -} -fragment StudioFragment on Studio { - name - id - urls { - ... URLFragment - } - parent { - name - id - } - images { - ... ImageFragment - } } ` -func (c *Client) FindScenesByFullFingerprints(ctx context.Context, fingerprints []*FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindScenesByFullFingerprints, error) { - vars := map[string]interface{}{ +func (c *Client) FindScenesByFullFingerprints(ctx context.Context, fingerprints []*FingerprintQueryInput, interceptors ...clientv2.RequestInterceptor) (*FindScenesByFullFingerprints, error) { + vars := map[string]any{ "fingerprints": fingerprints, } var res FindScenesByFullFingerprints - if err := c.Client.Post(ctx, "FindScenesByFullFingerprints", FindScenesByFullFingerprintsDocument, &res, vars, httpRequestOptions...); err != nil { + if err := c.Client.Post(ctx, "FindScenesByFullFingerprints", FindScenesByFullFingerprintsDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + return nil, err } @@ -515,41 +1079,43 @@ const FindScenesBySceneFingerprintsDocument = `query FindScenesBySceneFingerprin ... SceneFragment } } -fragment ImageFragment on Image { - id - url - width - height -} -fragment TagFragment on Tag { - name +fragment SceneFragment on Scene { id -} -fragment PerformerAppearanceFragment on PerformerAppearance { - as - performer { - ... PerformerFragment - } -} -fragment MeasurementsFragment on Measurements { - band_size - cup_size - waist - hip -} -fragment BodyModificationFragment on BodyModification { - location - description -} -fragment FingerprintFragment on Fingerprint { - algorithm - hash + title + code + details + director duration + date + urls { + ... URLFragment + } + images { + ... ImageFragment + } + studio { + ... StudioFragment + } + tags { + ... TagFragment + } + performers { + ... PerformerAppearanceFragment + } + fingerprints { + ... FingerprintFragment + } } fragment URLFragment on URL { url type } +fragment ImageFragment on Image { + id + url + width + height +} fragment StudioFragment on Studio { name id @@ -564,6 +1130,16 @@ fragment StudioFragment on Studio { ... ImageFragment } } +fragment TagFragment on Tag { + name + id +} +fragment PerformerAppearanceFragment on PerformerAppearance { + as + performer { + ... PerformerFragment + } +} fragment PerformerFragment on Performer { id name @@ -602,42 +1178,34 @@ fragment FuzzyDateFragment on FuzzyDate { date accuracy } -fragment SceneFragment on Scene { - id - title - code - details - director +fragment MeasurementsFragment on Measurements { + band_size + cup_size + waist + hip +} +fragment BodyModificationFragment on BodyModification { + location + description +} +fragment FingerprintFragment on Fingerprint { + algorithm + hash duration - date - urls { - ... URLFragment - } - images { - ... ImageFragment - } - studio { - ... StudioFragment - } - tags { - ... TagFragment - } - performers { - ... PerformerAppearanceFragment - } - fingerprints { - ... FingerprintFragment - } } ` -func (c *Client) FindScenesBySceneFingerprints(ctx context.Context, fingerprints [][]*FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindScenesBySceneFingerprints, error) { - vars := map[string]interface{}{ +func (c *Client) FindScenesBySceneFingerprints(ctx context.Context, fingerprints [][]*FingerprintQueryInput, interceptors ...clientv2.RequestInterceptor) (*FindScenesBySceneFingerprints, error) { + vars := map[string]any{ "fingerprints": fingerprints, } var res FindScenesBySceneFingerprints - if err := c.Client.Post(ctx, "FindScenesBySceneFingerprints", FindScenesBySceneFingerprintsDocument, &res, vars, httpRequestOptions...); err != nil { + if err := c.Client.Post(ctx, "FindScenesBySceneFingerprints", FindScenesBySceneFingerprintsDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + return nil, err } @@ -676,27 +1244,6 @@ fragment SceneFragment on Scene { ... FingerprintFragment } } -fragment PerformerAppearanceFragment on PerformerAppearance { - as - performer { - ... PerformerFragment - } -} -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy -} -fragment MeasurementsFragment on Measurements { - band_size - cup_size - waist - hip -} -fragment FingerprintFragment on Fingerprint { - algorithm - hash - duration -} fragment URLFragment on URL { url type @@ -725,6 +1272,12 @@ fragment TagFragment on Tag { name id } +fragment PerformerAppearanceFragment on PerformerAppearance { + as + performer { + ... PerformerFragment + } +} fragment PerformerFragment on Performer { id name @@ -759,19 +1312,38 @@ fragment PerformerFragment on Performer { ... BodyModificationFragment } } +fragment FuzzyDateFragment on FuzzyDate { + date + accuracy +} +fragment MeasurementsFragment on Measurements { + band_size + cup_size + waist + hip +} fragment BodyModificationFragment on BodyModification { location description } +fragment FingerprintFragment on Fingerprint { + algorithm + hash + duration +} ` -func (c *Client) SearchScene(ctx context.Context, term string, httpRequestOptions ...client.HTTPRequestOption) (*SearchScene, error) { - vars := map[string]interface{}{ +func (c *Client) SearchScene(ctx context.Context, term string, interceptors ...clientv2.RequestInterceptor) (*SearchScene, error) { + vars := map[string]any{ "term": term, } var res SearchScene - if err := c.Client.Post(ctx, "SearchScene", SearchSceneDocument, &res, vars, httpRequestOptions...); err != nil { + if err := c.Client.Post(ctx, "SearchScene", SearchSceneDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + return nil, err } @@ -843,13 +1415,17 @@ fragment BodyModificationFragment on BodyModification { } ` -func (c *Client) SearchPerformer(ctx context.Context, term string, httpRequestOptions ...client.HTTPRequestOption) (*SearchPerformer, error) { - vars := map[string]interface{}{ +func (c *Client) SearchPerformer(ctx context.Context, term string, interceptors ...clientv2.RequestInterceptor) (*SearchPerformer, error) { + vars := map[string]any{ "term": term, } var res SearchPerformer - if err := c.Client.Post(ctx, "SearchPerformer", SearchPerformerDocument, &res, vars, httpRequestOptions...); err != nil { + if err := c.Client.Post(ctx, "SearchPerformer", SearchPerformerDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + return nil, err } @@ -861,26 +1437,6 @@ const FindPerformerByIDDocument = `query FindPerformerByID ($id: ID!) { ... PerformerFragment } } -fragment ImageFragment on Image { - id - url - width - height -} -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy -} -fragment MeasurementsFragment on Measurements { - band_size - cup_size - waist - hip -} -fragment BodyModificationFragment on BodyModification { - location - description -} fragment PerformerFragment on Performer { id name @@ -919,15 +1475,39 @@ fragment URLFragment on URL { url type } +fragment ImageFragment on Image { + id + url + width + height +} +fragment FuzzyDateFragment on FuzzyDate { + date + accuracy +} +fragment MeasurementsFragment on Measurements { + band_size + cup_size + waist + hip +} +fragment BodyModificationFragment on BodyModification { + location + description +} ` -func (c *Client) FindPerformerByID(ctx context.Context, id string, httpRequestOptions ...client.HTTPRequestOption) (*FindPerformerByID, error) { - vars := map[string]interface{}{ +func (c *Client) FindPerformerByID(ctx context.Context, id string, interceptors ...clientv2.RequestInterceptor) (*FindPerformerByID, error) { + vars := map[string]any{ "id": id, } var res FindPerformerByID - if err := c.Client.Post(ctx, "FindPerformerByID", FindPerformerByIDDocument, &res, vars, httpRequestOptions...); err != nil { + if err := c.Client.Post(ctx, "FindPerformerByID", FindPerformerByIDDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + return nil, err } @@ -939,6 +1519,43 @@ const FindSceneByIDDocument = `query FindSceneByID ($id: ID!) { ... SceneFragment } } +fragment SceneFragment on Scene { + id + title + code + details + director + duration + date + urls { + ... URLFragment + } + images { + ... ImageFragment + } + studio { + ... StudioFragment + } + tags { + ... TagFragment + } + performers { + ... PerformerAppearanceFragment + } + fingerprints { + ... FingerprintFragment + } +} +fragment URLFragment on URL { + url + type +} +fragment ImageFragment on Image { + id + url + width + height +} fragment StudioFragment on Studio { name id @@ -963,25 +1580,6 @@ fragment PerformerAppearanceFragment on PerformerAppearance { ... PerformerFragment } } -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy -} -fragment FingerprintFragment on Fingerprint { - algorithm - hash - duration -} -fragment ImageFragment on Image { - id - url - width - height -} -fragment URLFragment on URL { - url - type -} fragment PerformerFragment on Performer { id name @@ -1016,6 +1614,10 @@ fragment PerformerFragment on Performer { ... BodyModificationFragment } } +fragment FuzzyDateFragment on FuzzyDate { + date + accuracy +} fragment MeasurementsFragment on Measurements { band_size cup_size @@ -1026,42 +1628,24 @@ fragment BodyModificationFragment on BodyModification { location description } -fragment SceneFragment on Scene { - id - title - code - details - director +fragment FingerprintFragment on Fingerprint { + algorithm + hash duration - date - urls { - ... URLFragment - } - images { - ... ImageFragment - } - studio { - ... StudioFragment - } - tags { - ... TagFragment - } - performers { - ... PerformerAppearanceFragment - } - fingerprints { - ... FingerprintFragment - } } ` -func (c *Client) FindSceneByID(ctx context.Context, id string, httpRequestOptions ...client.HTTPRequestOption) (*FindSceneByID, error) { - vars := map[string]interface{}{ +func (c *Client) FindSceneByID(ctx context.Context, id string, interceptors ...clientv2.RequestInterceptor) (*FindSceneByID, error) { + vars := map[string]any{ "id": id, } var res FindSceneByID - if err := c.Client.Post(ctx, "FindSceneByID", FindSceneByIDDocument, &res, vars, httpRequestOptions...); err != nil { + if err := c.Client.Post(ctx, "FindSceneByID", FindSceneByIDDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + return nil, err } @@ -1099,14 +1683,18 @@ fragment ImageFragment on Image { } ` -func (c *Client) FindStudio(ctx context.Context, id *string, name *string, httpRequestOptions ...client.HTTPRequestOption) (*FindStudio, error) { - vars := map[string]interface{}{ +func (c *Client) FindStudio(ctx context.Context, id *string, name *string, interceptors ...clientv2.RequestInterceptor) (*FindStudio, error) { + vars := map[string]any{ "id": id, "name": name, } var res FindStudio - if err := c.Client.Post(ctx, "FindStudio", FindStudioDocument, &res, vars, httpRequestOptions...); err != nil { + if err := c.Client.Post(ctx, "FindStudio", FindStudioDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + return nil, err } @@ -1118,13 +1706,17 @@ const SubmitFingerprintDocument = `mutation SubmitFingerprint ($input: Fingerpri } ` -func (c *Client) SubmitFingerprint(ctx context.Context, input FingerprintSubmission, httpRequestOptions ...client.HTTPRequestOption) (*SubmitFingerprint, error) { - vars := map[string]interface{}{ +func (c *Client) SubmitFingerprint(ctx context.Context, input FingerprintSubmission, interceptors ...clientv2.RequestInterceptor) (*SubmitFingerprint, error) { + vars := map[string]any{ "input": input, } var res SubmitFingerprint - if err := c.Client.Post(ctx, "SubmitFingerprint", SubmitFingerprintDocument, &res, vars, httpRequestOptions...); err != nil { + if err := c.Client.Post(ctx, "SubmitFingerprint", SubmitFingerprintDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + return nil, err } @@ -1138,11 +1730,15 @@ const MeDocument = `query Me { } ` -func (c *Client) Me(ctx context.Context, httpRequestOptions ...client.HTTPRequestOption) (*Me, error) { - vars := map[string]interface{}{} +func (c *Client) Me(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*Me, error) { + vars := map[string]any{} var res Me - if err := c.Client.Post(ctx, "Me", MeDocument, &res, vars, httpRequestOptions...); err != nil { + if err := c.Client.Post(ctx, "Me", MeDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + return nil, err } @@ -1156,13 +1752,17 @@ const SubmitSceneDraftDocument = `mutation SubmitSceneDraft ($input: SceneDraftI } ` -func (c *Client) SubmitSceneDraft(ctx context.Context, input SceneDraftInput, httpRequestOptions ...client.HTTPRequestOption) (*SubmitSceneDraft, error) { - vars := map[string]interface{}{ +func (c *Client) SubmitSceneDraft(ctx context.Context, input SceneDraftInput, interceptors ...clientv2.RequestInterceptor) (*SubmitSceneDraft, error) { + vars := map[string]any{ "input": input, } var res SubmitSceneDraft - if err := c.Client.Post(ctx, "SubmitSceneDraft", SubmitSceneDraftDocument, &res, vars, httpRequestOptions...); err != nil { + if err := c.Client.Post(ctx, "SubmitSceneDraft", SubmitSceneDraftDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + return nil, err } @@ -1176,15 +1776,34 @@ const SubmitPerformerDraftDocument = `mutation SubmitPerformerDraft ($input: Per } ` -func (c *Client) SubmitPerformerDraft(ctx context.Context, input PerformerDraftInput, httpRequestOptions ...client.HTTPRequestOption) (*SubmitPerformerDraft, error) { - vars := map[string]interface{}{ +func (c *Client) SubmitPerformerDraft(ctx context.Context, input PerformerDraftInput, interceptors ...clientv2.RequestInterceptor) (*SubmitPerformerDraft, error) { + vars := map[string]any{ "input": input, } var res SubmitPerformerDraft - if err := c.Client.Post(ctx, "SubmitPerformerDraft", SubmitPerformerDraftDocument, &res, vars, httpRequestOptions...); err != nil { + if err := c.Client.Post(ctx, "SubmitPerformerDraft", SubmitPerformerDraftDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + return nil, err } return &res, nil } + +var DocumentOperationNames = map[string]string{ + FindSceneByFingerprintDocument: "FindSceneByFingerprint", + FindScenesByFullFingerprintsDocument: "FindScenesByFullFingerprints", + FindScenesBySceneFingerprintsDocument: "FindScenesBySceneFingerprints", + SearchSceneDocument: "SearchScene", + SearchPerformerDocument: "SearchPerformer", + FindPerformerByIDDocument: "FindPerformerByID", + FindSceneByIDDocument: "FindSceneByID", + FindStudioDocument: "FindStudio", + SubmitFingerprintDocument: "SubmitFingerprint", + MeDocument: "Me", + SubmitSceneDraftDocument: "SubmitSceneDraft", + SubmitPerformerDraftDocument: "SubmitPerformerDraft", +} diff --git a/pkg/scraper/stashbox/graphql/generated_models.go b/pkg/scraper/stashbox/graphql/generated_models.go index 87f99db47a9..39022f6eaf7 100644 --- a/pkg/scraper/stashbox/graphql/generated_models.go +++ b/pkg/scraper/stashbox/graphql/generated_models.go @@ -80,7 +80,7 @@ type Draft struct { ID string `json:"id"` Created time.Time `json:"created"` Expires time.Time `json:"expires"` - Data DraftData `json:"data,omitempty"` + Data DraftData `json:"data"` } type DraftEntity struct { @@ -88,10 +88,12 @@ type DraftEntity struct { ID *string `json:"id,omitempty"` } -func (DraftEntity) IsSceneDraftStudio() {} -func (DraftEntity) IsSceneDraftTag() {} func (DraftEntity) IsSceneDraftPerformer() {} +func (DraftEntity) IsSceneDraftStudio() {} + +func (DraftEntity) IsSceneDraftTag() {} + type DraftEntityInput struct { Name string `json:"name"` ID *string `json:"id,omitempty"` @@ -114,7 +116,7 @@ type Edit struct { Target EditTarget `json:"target,omitempty"` TargetType TargetTypeEnum `json:"target_type"` // Objects to merge with the target. Only applicable to merges - MergeSources []EditTarget `json:"merge_sources,omitempty"` + MergeSources []EditTarget `json:"merge_sources"` Operation OperationEnum `json:"operation"` Bot bool `json:"bot"` Details EditDetails `json:"details,omitempty"` @@ -122,8 +124,8 @@ type Edit struct { OldDetails EditDetails `json:"old_details,omitempty"` // Entity specific options Options *PerformerEditOptions `json:"options,omitempty"` - Comments []*EditComment `json:"comments,omitempty"` - Votes []*EditVote `json:"votes,omitempty"` + Comments []*EditComment `json:"comments"` + Votes []*EditVote `json:"votes"` // = Accepted - Rejected VoteCount int `json:"vote_count"` // Is the edit considered destructive. @@ -179,11 +181,13 @@ type EditQueryInput struct { // Filter by user voted status Voted *UserVotedFilterEnum `json:"voted,omitempty"` // Filter to bot edits only - IsBot *bool `json:"is_bot,omitempty"` - Page int `json:"page"` - PerPage int `json:"per_page"` - Direction SortDirectionEnum `json:"direction"` - Sort EditSortEnum `json:"sort"` + IsBot *bool `json:"is_bot,omitempty"` + // Filter out user's own edits + IncludeUserSubmitted *bool `json:"include_user_submitted,omitempty"` + Page int `json:"page"` + PerPage int `json:"per_page"` + Direction SortDirectionEnum `json:"direction"` + Sort EditSortEnum `json:"sort"` } type EditVote struct { @@ -237,7 +241,7 @@ type FingerprintQueryInput struct { type FingerprintSubmission struct { SceneID string `json:"scene_id"` - Fingerprint *FingerprintInput `json:"fingerprint,omitempty"` + Fingerprint *FingerprintInput `json:"fingerprint"` Unmatch *bool `json:"unmatch,omitempty"` } @@ -246,6 +250,12 @@ type FuzzyDate struct { Accuracy DateAccuracyEnum `json:"accuracy"` } +type GenerateInviteCodeInput struct { + Keys *int `json:"keys,omitempty"` + Uses *int `json:"uses,omitempty"` + TTL *int `json:"ttl,omitempty"` +} + type GrantInviteInput struct { UserID string `json:"user_id"` Amount int `json:"amount"` @@ -257,7 +267,7 @@ type HairColorCriterionInput struct { } type IDCriterionInput struct { - Value []string `json:"value,omitempty"` + Value []string `json:"value"` Modifier CriterionModifier `json:"modifier"` } @@ -287,6 +297,12 @@ type IntCriterionInput struct { Modifier CriterionModifier `json:"modifier"` } +type InviteKey struct { + ID string `json:"id"` + Uses *int `json:"uses,omitempty"` + Expires *time.Time `json:"expires,omitempty"` +} + type Measurements struct { CupSize *string `json:"cup_size,omitempty"` BandSize *int `json:"band_size,omitempty"` @@ -300,10 +316,13 @@ type MultiIDCriterionInput struct { } type MultiStringCriterionInput struct { - Value []string `json:"value,omitempty"` + Value []string `json:"value"` Modifier CriterionModifier `json:"modifier"` } +type Mutation struct { +} + type NewUserInput struct { Email string `json:"email"` InviteKey *string `json:"invite_key,omitempty"` @@ -313,9 +332,9 @@ type Performer struct { ID string `json:"id"` Name string `json:"name"` Disambiguation *string `json:"disambiguation,omitempty"` - Aliases []string `json:"aliases,omitempty"` + Aliases []string `json:"aliases"` Gender *GenderEnum `json:"gender,omitempty"` - Urls []*URL `json:"urls,omitempty"` + Urls []*URL `json:"urls"` Birthdate *FuzzyDate `json:"birthdate,omitempty"` BirthDate *string `json:"birth_date,omitempty"` Age *int `json:"age,omitempty"` @@ -325,7 +344,7 @@ type Performer struct { HairColor *HairColorEnum `json:"hair_color,omitempty"` // Height in cm Height *int `json:"height,omitempty"` - Measurements *Measurements `json:"measurements,omitempty"` + Measurements *Measurements `json:"measurements"` CupSize *string `json:"cup_size,omitempty"` BandSize *int `json:"band_size,omitempty"` WaistSize *int `json:"waist_size,omitempty"` @@ -335,23 +354,24 @@ type Performer struct { CareerEndYear *int `json:"career_end_year,omitempty"` Tattoos []*BodyModification `json:"tattoos,omitempty"` Piercings []*BodyModification `json:"piercings,omitempty"` - Images []*Image `json:"images,omitempty"` + Images []*Image `json:"images"` Deleted bool `json:"deleted"` - Edits []*Edit `json:"edits,omitempty"` + Edits []*Edit `json:"edits"` SceneCount int `json:"scene_count"` - Scenes []*Scene `json:"scenes,omitempty"` - MergedIds []string `json:"merged_ids,omitempty"` - Studios []*PerformerStudio `json:"studios,omitempty"` + Scenes []*Scene `json:"scenes"` + MergedIds []string `json:"merged_ids"` + Studios []*PerformerStudio `json:"studios"` IsFavorite bool `json:"is_favorite"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` } -func (Performer) IsEditTarget() {} +func (Performer) IsEditTarget() {} + func (Performer) IsSceneDraftPerformer() {} type PerformerAppearance struct { - Performer *Performer `json:"performer,omitempty"` + Performer *Performer `json:"performer"` // Performing as alias As *string `json:"as,omitempty"` } @@ -466,11 +486,11 @@ type PerformerEdit struct { AddedImages []*Image `json:"added_images,omitempty"` RemovedImages []*Image `json:"removed_images,omitempty"` DraftID *string `json:"draft_id,omitempty"` - Aliases []string `json:"aliases,omitempty"` - Urls []*URL `json:"urls,omitempty"` - Images []*Image `json:"images,omitempty"` - Tattoos []*BodyModification `json:"tattoos,omitempty"` - Piercings []*BodyModification `json:"piercings,omitempty"` + Aliases []string `json:"aliases"` + Urls []*URL `json:"urls"` + Images []*Image `json:"images"` + Tattoos []*BodyModification `json:"tattoos"` + Piercings []*BodyModification `json:"piercings"` } func (PerformerEdit) IsEditDetails() {} @@ -501,7 +521,7 @@ type PerformerEditDetailsInput struct { } type PerformerEditInput struct { - Edit *EditInput `json:"edit,omitempty"` + Edit *EditInput `json:"edit"` // Not required for destroy type Details *PerformerEditDetailsInput `json:"details,omitempty"` // Controls aliases modification for merges and name modifications @@ -572,7 +592,7 @@ type PerformerScenesInput struct { } type PerformerStudio struct { - Studio *Studio `json:"studio,omitempty"` + Studio *Studio `json:"studio"` SceneCount int `json:"scene_count"` } @@ -601,55 +621,59 @@ type PerformerUpdateInput struct { ImageIds []string `json:"image_ids,omitempty"` } +// The query root for this schema +type Query struct { +} + type QueryEditsResultType struct { Count int `json:"count"` - Edits []*Edit `json:"edits,omitempty"` + Edits []*Edit `json:"edits"` } type QueryExistingSceneInput struct { Title *string `json:"title,omitempty"` StudioID *string `json:"studio_id,omitempty"` - Fingerprints []*FingerprintInput `json:"fingerprints,omitempty"` + Fingerprints []*FingerprintInput `json:"fingerprints"` } type QueryExistingSceneResult struct { - Edits []*Edit `json:"edits,omitempty"` - Scenes []*Scene `json:"scenes,omitempty"` + Edits []*Edit `json:"edits"` + Scenes []*Scene `json:"scenes"` } type QueryPerformersResultType struct { Count int `json:"count"` - Performers []*Performer `json:"performers,omitempty"` + Performers []*Performer `json:"performers"` } type QueryScenesResultType struct { Count int `json:"count"` - Scenes []*Scene `json:"scenes,omitempty"` + Scenes []*Scene `json:"scenes"` } type QuerySitesResultType struct { Count int `json:"count"` - Sites []*Site `json:"sites,omitempty"` + Sites []*Site `json:"sites"` } type QueryStudiosResultType struct { Count int `json:"count"` - Studios []*Studio `json:"studios,omitempty"` + Studios []*Studio `json:"studios"` } type QueryTagCategoriesResultType struct { Count int `json:"count"` - TagCategories []*TagCategory `json:"tag_categories,omitempty"` + TagCategories []*TagCategory `json:"tag_categories"` } type QueryTagsResultType struct { Count int `json:"count"` - Tags []*Tag `json:"tags,omitempty"` + Tags []*Tag `json:"tags"` } type QueryUsersResultType struct { Count int `json:"count"` - Users []*User `json:"users,omitempty"` + Users []*User `json:"users"` } type ResetPasswordInput struct { @@ -662,7 +686,7 @@ type RevokeInviteInput struct { } type RoleCriterionInput struct { - Value []RoleEnum `json:"value,omitempty"` + Value []RoleEnum `json:"value"` Modifier CriterionModifier `json:"modifier"` } @@ -672,17 +696,17 @@ type Scene struct { Details *string `json:"details,omitempty"` Date *string `json:"date,omitempty"` ReleaseDate *string `json:"release_date,omitempty"` - Urls []*URL `json:"urls,omitempty"` + Urls []*URL `json:"urls"` Studio *Studio `json:"studio,omitempty"` - Tags []*Tag `json:"tags,omitempty"` - Images []*Image `json:"images,omitempty"` - Performers []*PerformerAppearance `json:"performers,omitempty"` - Fingerprints []*Fingerprint `json:"fingerprints,omitempty"` + Tags []*Tag `json:"tags"` + Images []*Image `json:"images"` + Performers []*PerformerAppearance `json:"performers"` + Fingerprints []*Fingerprint `json:"fingerprints"` Duration *int `json:"duration,omitempty"` Director *string `json:"director,omitempty"` Code *string `json:"code,omitempty"` Deleted bool `json:"deleted"` - Edits []*Edit `json:"edits,omitempty"` + Edits []*Edit `json:"edits"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` } @@ -698,7 +722,7 @@ type SceneCreateInput struct { Performers []*PerformerAppearanceInput `json:"performers,omitempty"` TagIds []string `json:"tag_ids,omitempty"` ImageIds []string `json:"image_ids,omitempty"` - Fingerprints []*FingerprintEditInput `json:"fingerprints,omitempty"` + Fingerprints []*FingerprintEditInput `json:"fingerprints"` Duration *int `json:"duration,omitempty"` Director *string `json:"director,omitempty"` Code *string `json:"code,omitempty"` @@ -717,10 +741,10 @@ type SceneDraft struct { URL *URL `json:"url,omitempty"` Date *string `json:"date,omitempty"` Studio SceneDraftStudio `json:"studio,omitempty"` - Performers []SceneDraftPerformer `json:"performers,omitempty"` + Performers []SceneDraftPerformer `json:"performers"` Tags []SceneDraftTag `json:"tags,omitempty"` Image *Image `json:"image,omitempty"` - Fingerprints []*DraftFingerprint `json:"fingerprints,omitempty"` + Fingerprints []*DraftFingerprint `json:"fingerprints"` } func (SceneDraft) IsDraftData() {} @@ -745,11 +769,11 @@ type SceneEdit struct { Director *string `json:"director,omitempty"` Code *string `json:"code,omitempty"` DraftID *string `json:"draft_id,omitempty"` - Urls []*URL `json:"urls,omitempty"` - Performers []*PerformerAppearance `json:"performers,omitempty"` - Tags []*Tag `json:"tags,omitempty"` - Images []*Image `json:"images,omitempty"` - Fingerprints []*Fingerprint `json:"fingerprints,omitempty"` + Urls []*URL `json:"urls"` + Performers []*PerformerAppearance `json:"performers"` + Tags []*Tag `json:"tags"` + Images []*Image `json:"images"` + Fingerprints []*Fingerprint `json:"fingerprints"` } func (SceneEdit) IsEditDetails() {} @@ -771,7 +795,7 @@ type SceneEditDetailsInput struct { } type SceneEditInput struct { - Edit *EditInput `json:"edit,omitempty"` + Edit *EditInput `json:"edit"` // Not required for destroy type Details *SceneEditDetailsInput `json:"details,omitempty"` } @@ -829,7 +853,7 @@ type Site struct { Description *string `json:"description,omitempty"` URL *string `json:"url,omitempty"` Regex *string `json:"regex,omitempty"` - ValidTypes []ValidSiteTypeEnum `json:"valid_types,omitempty"` + ValidTypes []ValidSiteTypeEnum `json:"valid_types"` Icon string `json:"icon"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` @@ -840,7 +864,7 @@ type SiteCreateInput struct { Description *string `json:"description,omitempty"` URL *string `json:"url,omitempty"` Regex *string `json:"regex,omitempty"` - ValidTypes []ValidSiteTypeEnum `json:"valid_types,omitempty"` + ValidTypes []ValidSiteTypeEnum `json:"valid_types"` } type SiteDestroyInput struct { @@ -853,7 +877,7 @@ type SiteUpdateInput struct { Description *string `json:"description,omitempty"` URL *string `json:"url,omitempty"` Regex *string `json:"regex,omitempty"` - ValidTypes []ValidSiteTypeEnum `json:"valid_types,omitempty"` + ValidTypes []ValidSiteTypeEnum `json:"valid_types"` } type StashBoxConfig struct { @@ -865,6 +889,7 @@ type StashBoxConfig struct { VotingPeriod int `json:"voting_period"` MinDestructiveVotingPeriod int `json:"min_destructive_voting_period"` VoteCronInterval string `json:"vote_cron_interval"` + GuidelinesURL string `json:"guidelines_url"` } type StringCriterionInput struct { @@ -875,19 +900,20 @@ type StringCriterionInput struct { type Studio struct { ID string `json:"id"` Name string `json:"name"` - Urls []*URL `json:"urls,omitempty"` + Urls []*URL `json:"urls"` Parent *Studio `json:"parent,omitempty"` - ChildStudios []*Studio `json:"child_studios,omitempty"` - Images []*Image `json:"images,omitempty"` + ChildStudios []*Studio `json:"child_studios"` + Images []*Image `json:"images"` Deleted bool `json:"deleted"` IsFavorite bool `json:"is_favorite"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` - Performers *QueryPerformersResultType `json:"performers,omitempty"` + Performers *QueryPerformersResultType `json:"performers"` } +func (Studio) IsEditTarget() {} + func (Studio) IsSceneDraftStudio() {} -func (Studio) IsEditTarget() {} type StudioCreateInput struct { Name string `json:"name"` @@ -908,8 +934,8 @@ type StudioEdit struct { Parent *Studio `json:"parent,omitempty"` AddedImages []*Image `json:"added_images,omitempty"` RemovedImages []*Image `json:"removed_images,omitempty"` - Images []*Image `json:"images,omitempty"` - Urls []*URL `json:"urls,omitempty"` + Images []*Image `json:"images"` + Urls []*URL `json:"urls"` } func (StudioEdit) IsEditDetails() {} @@ -922,7 +948,7 @@ type StudioEditDetailsInput struct { } type StudioEditInput struct { - Edit *EditInput `json:"edit,omitempty"` + Edit *EditInput `json:"edit"` // Not required for destroy type Details *StudioEditDetailsInput `json:"details,omitempty"` } @@ -956,15 +982,16 @@ type Tag struct { ID string `json:"id"` Name string `json:"name"` Description *string `json:"description,omitempty"` - Aliases []string `json:"aliases,omitempty"` + Aliases []string `json:"aliases"` Deleted bool `json:"deleted"` - Edits []*Edit `json:"edits,omitempty"` + Edits []*Edit `json:"edits"` Category *TagCategory `json:"category,omitempty"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` } -func (Tag) IsEditTarget() {} +func (Tag) IsEditTarget() {} + func (Tag) IsSceneDraftTag() {} type TagCategory struct { @@ -1008,7 +1035,7 @@ type TagEdit struct { AddedAliases []string `json:"added_aliases,omitempty"` RemovedAliases []string `json:"removed_aliases,omitempty"` Category *TagCategory `json:"category,omitempty"` - Aliases []string `json:"aliases,omitempty"` + Aliases []string `json:"aliases"` } func (TagEdit) IsEditDetails() {} @@ -1021,7 +1048,7 @@ type TagEditDetailsInput struct { } type TagEditInput struct { - Edit *EditInput `json:"edit,omitempty"` + Edit *EditInput `json:"edit"` // Not required for destroy type Details *TagEditDetailsInput `json:"details,omitempty"` } @@ -1034,7 +1061,6 @@ type TagQueryInput struct { // Filter to search name - assumes like query unless quoted Name *string `json:"name,omitempty"` // Filter to category ID - IsFavorite *bool `json:"is_favorite,omitempty"` CategoryID *string `json:"category_id,omitempty"` Page int `json:"page"` PerPage int `json:"per_page"` @@ -1053,7 +1079,7 @@ type TagUpdateInput struct { type URL struct { URL string `json:"url"` Type string `json:"type"` - Site *Site `json:"site,omitempty"` + Site *Site `json:"site"` } type URLInput struct { @@ -1071,14 +1097,15 @@ type User struct { // Should not be visible to other users APIKey *string `json:"api_key,omitempty"` // Vote counts by type - VoteCount *UserVoteCount `json:"vote_count,omitempty"` + VoteCount *UserVoteCount `json:"vote_count"` // Edit counts by status - EditCount *UserEditCount `json:"edit_count,omitempty"` + EditCount *UserEditCount `json:"edit_count"` // Calls to the API from this user over a configurable time period - APICalls int `json:"api_calls"` - InvitedBy *User `json:"invited_by,omitempty"` - InviteTokens *int `json:"invite_tokens,omitempty"` - ActiveInviteCodes []string `json:"active_invite_codes,omitempty"` + APICalls int `json:"api_calls"` + InvitedBy *User `json:"invited_by,omitempty"` + InviteTokens *int `json:"invite_tokens,omitempty"` + ActiveInviteCodes []string `json:"active_invite_codes,omitempty"` + InviteCodes []*InviteKey `json:"invite_codes,omitempty"` } type UserChangePasswordInput struct { @@ -1092,7 +1119,7 @@ type UserCreateInput struct { Name string `json:"name"` // Password in plain text Password string `json:"password"` - Roles []RoleEnum `json:"roles,omitempty"` + Roles []RoleEnum `json:"roles"` Email string `json:"email"` InvitedByID *string `json:"invited_by_id,omitempty"` } diff --git a/pkg/scraper/stashbox/stash_box.go b/pkg/scraper/stashbox/stash_box.go index 0b0cf68d67e..44fd59b6979 100644 --- a/pkg/scraper/stashbox/stash_box.go +++ b/pkg/scraper/stashbox/stash_box.go @@ -13,7 +13,7 @@ import ( "strconv" "strings" - "github.com/Yamashou/gqlgenc/client" + "github.com/Yamashou/gqlgenc/clientv2" "github.com/Yamashou/gqlgenc/graphqljson" "github.com/gofrs/uuid/v5" "golang.org/x/text/cases" @@ -89,12 +89,13 @@ type Client struct { // NewClient returns a new instance of a stash-box client. func NewClient(box models.StashBox, repo Repository) *Client { - authHeader := func(req *http.Request) { + authHeader := func(ctx context.Context, req *http.Request, gqlInfo *clientv2.GQLRequestInfo, res interface{}, next clientv2.RequestInterceptorFunc) error { req.Header.Set("ApiKey", box.APIKey) + return next(ctx, req, gqlInfo, res) } client := &graphql.Client{ - Client: client.NewClient(http.DefaultClient, box.Endpoint, authHeader), + Client: clientv2.NewClient(http.DefaultClient, box.Endpoint, nil, authHeader), } return &Client{ @@ -627,7 +628,7 @@ func performerFragmentToScrapedPerformer(p graphql.PerformerFragment) *models.Sc Name: &p.Name, Disambiguation: p.Disambiguation, Country: p.Country, - Measurements: formatMeasurements(p.Measurements), + Measurements: formatMeasurements(*p.Measurements), CareerLength: formatCareerLength(p.CareerStartYear, p.CareerEndYear), Tattoos: formatBodyModifications(p.Tattoos), Piercings: formatBodyModifications(p.Piercings), @@ -805,7 +806,7 @@ func (c Client) sceneFragmentToScrapedScene(ctx context.Context, s *graphql.Scen } for _, p := range s.Performers { - sp := performerFragmentToScrapedPerformer(p.Performer) + sp := performerFragmentToScrapedPerformer(*p.Performer) err := match.ScrapedPerformer(ctx, pqb, sp, &c.box.Endpoint) if err != nil { @@ -1281,7 +1282,7 @@ func (c *Client) submitDraft(ctx context.Context, query string, input interface{ "input": input, } - r := &client.Request{ + r := &clientv2.Request{ Query: query, Variables: vars, OperationName: "", @@ -1341,7 +1342,7 @@ func (c *Client) submitDraft(ctx context.Context, query string, input interface{ if len(respGQL.Errors) > 0 { // try to parse standard graphql error - errors := &client.GqlErrorList{} + errors := &clientv2.GqlErrorList{} if e := json.Unmarshal(responseBytes, errors); e != nil { return fmt.Errorf("failed to parse graphql errors. Response content %s - %w ", string(responseBytes), e) } From b1d5dc2a0e5333dee5240e868801ba62ac0dbbaf Mon Sep 17 00:00:00 2001 From: InfiniteStash <117855276+InfiniteStash@users.noreply.github.com> Date: Thu, 31 Oct 2024 01:55:58 +0100 Subject: [PATCH 80/91] Fix scraping stash-box performers with null birthdates (#5428) --- graphql/stash-box/query.graphql | 9 +-- .../stashbox/graphql/generated_client.go | 80 +++---------------- pkg/scraper/stashbox/stash_box.go | 22 ++++- 3 files changed, 30 insertions(+), 81 deletions(-) diff --git a/graphql/stash-box/query.graphql b/graphql/stash-box/query.graphql index 75dbc9797f0..a8a6b8f9cd7 100644 --- a/graphql/stash-box/query.graphql +++ b/graphql/stash-box/query.graphql @@ -30,11 +30,6 @@ fragment TagFragment on Tag { id } -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy -} - fragment MeasurementsFragment on Measurements { band_size cup_size @@ -60,9 +55,7 @@ fragment PerformerFragment on Performer { images { ...ImageFragment } - birthdate { - ...FuzzyDateFragment - } + birth_date ethnicity country eye_color diff --git a/pkg/scraper/stashbox/graphql/generated_client.go b/pkg/scraper/stashbox/graphql/generated_client.go index 9b9340239bb..b6363dc564c 100644 --- a/pkg/scraper/stashbox/graphql/generated_client.go +++ b/pkg/scraper/stashbox/graphql/generated_client.go @@ -139,24 +139,6 @@ func (t *TagFragment) GetID() string { return t.ID } -type FuzzyDateFragment struct { - Date string "json:\"date\" graphql:\"date\"" - Accuracy DateAccuracyEnum "json:\"accuracy\" graphql:\"accuracy\"" -} - -func (t *FuzzyDateFragment) GetDate() string { - if t == nil { - t = &FuzzyDateFragment{} - } - return t.Date -} -func (t *FuzzyDateFragment) GetAccuracy() *DateAccuracyEnum { - if t == nil { - t = &FuzzyDateFragment{} - } - return &t.Accuracy -} - type MeasurementsFragment struct { BandSize *int "json:\"band_size,omitempty\" graphql:\"band_size\"" CupSize *string "json:\"cup_size,omitempty\" graphql:\"cup_size\"" @@ -216,7 +198,7 @@ type PerformerFragment struct { MergedIds []string "json:\"merged_ids\" graphql:\"merged_ids\"" Urls []*URLFragment "json:\"urls\" graphql:\"urls\"" Images []*ImageFragment "json:\"images\" graphql:\"images\"" - Birthdate *FuzzyDateFragment "json:\"birthdate,omitempty\" graphql:\"birthdate\"" + BirthDate *string "json:\"birth_date,omitempty\" graphql:\"birth_date\"" Ethnicity *EthnicityEnum "json:\"ethnicity,omitempty\" graphql:\"ethnicity\"" Country *string "json:\"country,omitempty\" graphql:\"country\"" EyeColor *EyeColorEnum "json:\"eye_color,omitempty\" graphql:\"eye_color\"" @@ -278,11 +260,11 @@ func (t *PerformerFragment) GetImages() []*ImageFragment { } return t.Images } -func (t *PerformerFragment) GetBirthdate() *FuzzyDateFragment { +func (t *PerformerFragment) GetBirthDate() *string { if t == nil { t = &PerformerFragment{} } - return t.Birthdate + return t.BirthDate } func (t *PerformerFragment) GetEthnicity() *EthnicityEnum { if t == nil { @@ -877,9 +859,7 @@ fragment PerformerFragment on Performer { images { ... ImageFragment } - birthdate { - ... FuzzyDateFragment - } + birth_date ethnicity country eye_color @@ -898,10 +878,6 @@ fragment PerformerFragment on Performer { ... BodyModificationFragment } } -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy -} fragment MeasurementsFragment on Measurements { band_size cup_size @@ -1015,9 +991,7 @@ fragment PerformerFragment on Performer { images { ... ImageFragment } - birthdate { - ... FuzzyDateFragment - } + birth_date ethnicity country eye_color @@ -1036,10 +1010,6 @@ fragment PerformerFragment on Performer { ... BodyModificationFragment } } -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy -} fragment MeasurementsFragment on Measurements { band_size cup_size @@ -1153,9 +1123,7 @@ fragment PerformerFragment on Performer { images { ... ImageFragment } - birthdate { - ... FuzzyDateFragment - } + birth_date ethnicity country eye_color @@ -1174,10 +1142,6 @@ fragment PerformerFragment on Performer { ... BodyModificationFragment } } -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy -} fragment MeasurementsFragment on Measurements { band_size cup_size @@ -1291,9 +1255,7 @@ fragment PerformerFragment on Performer { images { ... ImageFragment } - birthdate { - ... FuzzyDateFragment - } + birth_date ethnicity country eye_color @@ -1312,10 +1274,6 @@ fragment PerformerFragment on Performer { ... BodyModificationFragment } } -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy -} fragment MeasurementsFragment on Measurements { band_size cup_size @@ -1368,9 +1326,7 @@ fragment PerformerFragment on Performer { images { ... ImageFragment } - birthdate { - ... FuzzyDateFragment - } + birth_date ethnicity country eye_color @@ -1399,10 +1355,6 @@ fragment ImageFragment on Image { width height } -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy -} fragment MeasurementsFragment on Measurements { band_size cup_size @@ -1450,9 +1402,7 @@ fragment PerformerFragment on Performer { images { ... ImageFragment } - birthdate { - ... FuzzyDateFragment - } + birth_date ethnicity country eye_color @@ -1481,10 +1431,6 @@ fragment ImageFragment on Image { width height } -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy -} fragment MeasurementsFragment on Measurements { band_size cup_size @@ -1593,9 +1539,7 @@ fragment PerformerFragment on Performer { images { ... ImageFragment } - birthdate { - ... FuzzyDateFragment - } + birth_date ethnicity country eye_color @@ -1614,10 +1558,6 @@ fragment PerformerFragment on Performer { ... BodyModificationFragment } } -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy -} fragment MeasurementsFragment on Measurements { band_size cup_size diff --git a/pkg/scraper/stashbox/stash_box.go b/pkg/scraper/stashbox/stash_box.go index 44fd59b6979..670ec95980b 100644 --- a/pkg/scraper/stashbox/stash_box.go +++ b/pkg/scraper/stashbox/stash_box.go @@ -648,9 +648,8 @@ func performerFragmentToScrapedPerformer(p graphql.PerformerFragment) *models.Sc sp.Height = &hs } - if p.Birthdate != nil { - b := p.Birthdate.Date - sp.Birthdate = &b + if p.BirthDate != nil { + sp.Birthdate = padFuzzyDate(p.BirthDate) } if p.Gender != nil { @@ -1356,3 +1355,20 @@ func (c *Client) submitDraft(ctx context.Context, query string, input interface{ return err } + +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 +} From 180a0fa8dd7a79dce95b54f497a00b3eba684183 Mon Sep 17 00:00:00 2001 From: Ian McKenzie <13459320+ikmckenz@users.noreply.github.com> Date: Wed, 30 Oct 2024 21:56:16 -0700 Subject: [PATCH 81/91] Add updated_at field to stash_id's (#5259) * Add updated_at field to stash_id's * Only set updated at on stash ids when actually updating in identify --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- gqlgen.yml | 3 - graphql/schema/types/stash-box.graphql | 2 + internal/api/changeset_translator.go | 4 +- internal/api/resolver_mutation_performer.go | 2 +- internal/api/resolver_mutation_scene.go | 2 +- internal/api/resolver_mutation_studio.go | 2 +- internal/identify/identify.go | 21 ++-- internal/identify/scene.go | 24 +++-- internal/identify/scene_test.go | 96 +++++++++++++++---- pkg/models/model_scene.go | 6 +- pkg/models/model_scraped_item.go | 23 +++-- pkg/models/model_scraped_item_test.go | 18 +++- pkg/models/performer.go | 32 +++---- pkg/models/scene.go | 16 ++-- pkg/models/stash_ids.go | 85 +++++++++++++++- pkg/models/studio.go | 32 +++---- pkg/scene/update_test.go | 14 ++- pkg/sqlite/database.go | 2 +- .../migrations/69_stash_id_updated_at.up.sql | 3 + pkg/sqlite/repository.go | 31 +++--- pkg/sqlite/table.go | 16 ++-- ui/v2.5/graphql/data/performer-slim.graphql | 1 + ui/v2.5/graphql/data/performer.graphql | 1 + ui/v2.5/graphql/data/scene-slim.graphql | 1 + ui/v2.5/graphql/data/scene.graphql | 1 + ui/v2.5/graphql/data/studio-slim.graphql | 1 + ui/v2.5/graphql/data/studio.graphql | 1 + .../PerformerDetails/PerformerEditPanel.tsx | 7 +- .../Scenes/SceneDetails/SceneEditPanel.tsx | 2 + ui/v2.5/src/components/Shared/StashID.tsx | 2 +- .../src/components/Tagger/PerformerModal.tsx | 1 + ui/v2.5/src/components/Tagger/context.tsx | 4 + .../Tagger/scenes/StashSearchResult.tsx | 2 + .../components/Tagger/scenes/StudioModal.tsx | 3 + ui/v2.5/src/utils/stashIds.ts | 7 +- 35 files changed, 336 insertions(+), 132 deletions(-) create mode 100644 pkg/sqlite/migrations/69_stash_id_updated_at.up.sql diff --git a/gqlgen.yml b/gqlgen.yml index bfa5654c1b0..dc101f03c3e 100644 --- a/gqlgen.yml +++ b/gqlgen.yml @@ -129,9 +129,6 @@ models: model: github.com/stashapp/stash/internal/identify.FieldStrategy ScraperSource: model: github.com/stashapp/stash/pkg/scraper.Source - # rebind inputs to types - StashIDInput: - model: github.com/stashapp/stash/pkg/models.StashID IdentifySourceInput: model: github.com/stashapp/stash/internal/identify.Source IdentifyFieldOptionsInput: diff --git a/graphql/schema/types/stash-box.graphql b/graphql/schema/types/stash-box.graphql index 71ea757f443..d1da8c74a76 100644 --- a/graphql/schema/types/stash-box.graphql +++ b/graphql/schema/types/stash-box.graphql @@ -13,11 +13,13 @@ input StashBoxInput { type StashID { endpoint: String! stash_id: String! + updated_at: Time! } input StashIDInput { endpoint: String! stash_id: String! + updated_at: Time } input StashBoxFingerprintSubmissionInput { diff --git a/internal/api/changeset_translator.go b/internal/api/changeset_translator.go index 1170088aac9..5c81c12cb09 100644 --- a/internal/api/changeset_translator.go +++ b/internal/api/changeset_translator.go @@ -335,13 +335,13 @@ func (t changesetTranslator) updateStringsBulk(value *BulkUpdateStrings, field s } } -func (t changesetTranslator) updateStashIDs(value []models.StashID, field string) *models.UpdateStashIDs { +func (t changesetTranslator) updateStashIDs(value models.StashIDInputs, field string) *models.UpdateStashIDs { if !t.hasField(field) { return nil } return &models.UpdateStashIDs{ - StashIDs: value, + StashIDs: value.ToStashIDs(), Mode: models.RelationshipUpdateModeSet, } } diff --git a/internal/api/resolver_mutation_performer.go b/internal/api/resolver_mutation_performer.go index 7263cc70966..87f0883ed24 100644 --- a/internal/api/resolver_mutation_performer.go +++ b/internal/api/resolver_mutation_performer.go @@ -58,7 +58,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per newPerformer.Height = input.HeightCm newPerformer.Weight = input.Weight newPerformer.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag) - newPerformer.StashIDs = models.NewRelatedStashIDs(input.StashIds) + newPerformer.StashIDs = models.NewRelatedStashIDs(models.StashIDInputs(input.StashIds).ToStashIDs()) newPerformer.URLs = models.NewRelatedStrings([]string{}) if input.URL != nil { diff --git a/internal/api/resolver_mutation_scene.go b/internal/api/resolver_mutation_scene.go index ca99dafc150..b0c6ac8b5aa 100644 --- a/internal/api/resolver_mutation_scene.go +++ b/internal/api/resolver_mutation_scene.go @@ -50,7 +50,7 @@ func (r *mutationResolver) SceneCreate(ctx context.Context, input models.SceneCr newScene.Director = translator.string(input.Director) newScene.Rating = input.Rating100 newScene.Organized = translator.bool(input.Organized) - newScene.StashIDs = models.NewRelatedStashIDs(input.StashIds) + newScene.StashIDs = models.NewRelatedStashIDs(models.StashIDInputs(input.StashIds).ToStashIDs()) newScene.Date, err = translator.datePtr(input.Date) if err != nil { diff --git a/internal/api/resolver_mutation_studio.go b/internal/api/resolver_mutation_studio.go index a33e5d9b676..727951755e9 100644 --- a/internal/api/resolver_mutation_studio.go +++ b/internal/api/resolver_mutation_studio.go @@ -39,7 +39,7 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio newStudio.Details = translator.string(input.Details) newStudio.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag) newStudio.Aliases = models.NewRelatedStrings(input.Aliases) - newStudio.StashIDs = models.NewRelatedStashIDs(input.StashIds) + newStudio.StashIDs = models.NewRelatedStashIDs(models.StashIDInputs(input.StashIds).ToStashIDs()) var err error diff --git a/internal/identify/identify.go b/internal/identify/identify.go index dca1a68d71b..70d9322274a 100644 --- a/internal/identify/identify.go +++ b/internal/identify/identify.go @@ -245,7 +245,18 @@ func (t *SceneIdentifier) getSceneUpdater(ctx context.Context, s *models.Scene, } } - stashIDs, err := rel.stashIDs(ctx) + // SetCoverImage defaults to true if unset + if options.SetCoverImage == nil || *options.SetCoverImage { + ret.CoverImage, err = rel.cover(ctx) + if err != nil { + return nil, err + } + } + + // if anything changed, also update the updated at time on the applicable stash id + changed := !ret.IsEmpty() + + stashIDs, err := rel.stashIDs(ctx, changed) if err != nil { return nil, err } @@ -256,14 +267,6 @@ func (t *SceneIdentifier) getSceneUpdater(ctx context.Context, s *models.Scene, } } - // SetCoverImage defaults to true if unset - if options.SetCoverImage == nil || *options.SetCoverImage { - ret.CoverImage, err = rel.cover(ctx) - if err != nil { - return nil, err - } - } - return ret, nil } diff --git a/internal/identify/scene.go b/internal/identify/scene.go index 05f1ba90076..847a140c5ae 100644 --- a/internal/identify/scene.go +++ b/internal/identify/scene.go @@ -7,6 +7,7 @@ import ( "fmt" "strconv" "strings" + "time" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" @@ -182,7 +183,13 @@ func (g sceneRelationships) tags(ctx context.Context) ([]int, error) { return tagIDs, nil } -func (g sceneRelationships) stashIDs(ctx context.Context) ([]models.StashID, error) { +// stashIDs returns the updated stash IDs for the scene +// returns nil if not applicable or no changes were made +// if setUpdateTime is true, then the updated_at field will be set to the current time +// for the applicable matching stash ID +func (g sceneRelationships) stashIDs(ctx context.Context, setUpdateTime bool) ([]models.StashID, error) { + updateTime := time.Now() + remoteSiteID := g.result.result.RemoteSiteID fieldStrategy := g.fieldOptions["stash_ids"] target := g.scene @@ -199,7 +206,7 @@ func (g sceneRelationships) stashIDs(ctx context.Context) ([]models.StashID, err strategy = fieldStrategy.Strategy } - var stashIDs []models.StashID + var stashIDs models.StashIDs originalStashIDs := target.StashIDs.List() if strategy == FieldStrategyMerge { @@ -208,15 +215,17 @@ func (g sceneRelationships) stashIDs(ctx context.Context) ([]models.StashID, err stashIDs = append(stashIDs, originalStashIDs...) } + // find and update the stash id if it exists for i, stashID := range stashIDs { if endpoint == stashID.Endpoint { // if stashID is the same, then don't set - if stashID.StashID == *remoteSiteID { + if !setUpdateTime && stashID.StashID == *remoteSiteID { return nil, nil } // replace the stash id and return stashID.StashID = *remoteSiteID + stashID.UpdatedAt = updateTime stashIDs[i] = stashID return stashIDs, nil } @@ -224,11 +233,14 @@ func (g sceneRelationships) stashIDs(ctx context.Context) ([]models.StashID, err // not found, create new entry stashIDs = append(stashIDs, models.StashID{ - StashID: *remoteSiteID, - Endpoint: endpoint, + StashID: *remoteSiteID, + Endpoint: endpoint, + UpdatedAt: updateTime, }) - if sliceutil.SliceSame(originalStashIDs, stashIDs) { + // don't return if nothing was changed + // if we're setting update time, then we always return + if !setUpdateTime && stashIDs.HasSameStashIDs(originalStashIDs) { return nil, nil } diff --git a/internal/identify/scene_test.go b/internal/identify/scene_test.go index 272ca43cb1d..7587eee7e27 100644 --- a/internal/identify/scene_test.go +++ b/internal/identify/scene_test.go @@ -5,6 +5,7 @@ import ( "reflect" "strconv" "testing" + "time" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" @@ -548,8 +549,9 @@ func Test_sceneRelationships_stashIDs(t *testing.T) { ID: sceneWithStashID, StashIDs: models.NewRelatedStashIDs([]models.StashID{ { - StashID: remoteSiteID, - Endpoint: existingEndpoint, + StashID: remoteSiteID, + Endpoint: existingEndpoint, + UpdatedAt: time.Time{}, }, }), } @@ -561,14 +563,17 @@ func Test_sceneRelationships_stashIDs(t *testing.T) { fieldOptions: make(map[string]*FieldOptions), } + setTime := time.Now() + tests := []struct { - name string - scene *models.Scene - fieldOptions *FieldOptions - endpoint string - remoteSiteID *string - want []models.StashID - wantErr bool + name string + scene *models.Scene + fieldOptions *FieldOptions + endpoint string + remoteSiteID *string + setUpdateTime bool + want []models.StashID + wantErr bool }{ { "ignore", @@ -578,6 +583,7 @@ func Test_sceneRelationships_stashIDs(t *testing.T) { }, newEndpoint, &remoteSiteID, + false, nil, false, }, @@ -587,6 +593,7 @@ func Test_sceneRelationships_stashIDs(t *testing.T) { defaultOptions, "", &remoteSiteID, + false, nil, false, }, @@ -596,6 +603,7 @@ func Test_sceneRelationships_stashIDs(t *testing.T) { defaultOptions, newEndpoint, nil, + false, nil, false, }, @@ -605,19 +613,38 @@ func Test_sceneRelationships_stashIDs(t *testing.T) { defaultOptions, existingEndpoint, &remoteSiteID, + false, nil, false, }, + { + "merge existing set update time", + sceneWithStashIDs, + defaultOptions, + existingEndpoint, + &remoteSiteID, + true, + []models.StashID{ + { + StashID: remoteSiteID, + Endpoint: existingEndpoint, + UpdatedAt: setTime, + }, + }, + false, + }, { "merge existing new value", sceneWithStashIDs, defaultOptions, existingEndpoint, &newRemoteSiteID, + false, []models.StashID{ { - StashID: newRemoteSiteID, - Endpoint: existingEndpoint, + StashID: newRemoteSiteID, + Endpoint: existingEndpoint, + UpdatedAt: setTime, }, }, false, @@ -628,14 +655,17 @@ func Test_sceneRelationships_stashIDs(t *testing.T) { defaultOptions, newEndpoint, &newRemoteSiteID, + false, []models.StashID{ { - StashID: remoteSiteID, - Endpoint: existingEndpoint, + StashID: remoteSiteID, + Endpoint: existingEndpoint, + UpdatedAt: time.Time{}, }, { - StashID: newRemoteSiteID, - Endpoint: newEndpoint, + StashID: newRemoteSiteID, + Endpoint: newEndpoint, + UpdatedAt: setTime, }, }, false, @@ -648,10 +678,12 @@ func Test_sceneRelationships_stashIDs(t *testing.T) { }, newEndpoint, &newRemoteSiteID, + false, []models.StashID{ { - StashID: newRemoteSiteID, - Endpoint: newEndpoint, + StashID: newRemoteSiteID, + Endpoint: newEndpoint, + UpdatedAt: setTime, }, }, false, @@ -664,9 +696,28 @@ func Test_sceneRelationships_stashIDs(t *testing.T) { }, existingEndpoint, &remoteSiteID, + false, nil, false, }, + { + "overwrite same set update time", + sceneWithStashIDs, + &FieldOptions{ + Strategy: FieldStrategyOverwrite, + }, + existingEndpoint, + &remoteSiteID, + true, + []models.StashID{ + { + StashID: remoteSiteID, + Endpoint: existingEndpoint, + UpdatedAt: setTime, + }, + }, + false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -681,11 +732,20 @@ func Test_sceneRelationships_stashIDs(t *testing.T) { }, } - got, err := tr.stashIDs(testCtx) + got, err := tr.stashIDs(testCtx, tt.setUpdateTime) + if (err != nil) != tt.wantErr { t.Errorf("sceneRelationships.stashIDs() error = %v, wantErr %v", err, tt.wantErr) return } + + // massage updatedAt times to be consistent for comparison + for i := range got { + if !got[i].UpdatedAt.IsZero() { + got[i].UpdatedAt = setTime + } + } + if !reflect.DeepEqual(got, tt.want) { t.Errorf("sceneRelationships.stashIDs() = %+v, want %+v", got, tt.want) } diff --git a/pkg/models/model_scene.go b/pkg/models/model_scene.go index 3f26a8cb6d8..cf04993882c 100644 --- a/pkg/models/model_scene.go +++ b/pkg/models/model_scene.go @@ -192,9 +192,9 @@ func (s ScenePartial) UpdateInput(id int) SceneUpdateInput { dateStr = &v } - var stashIDs []StashID + var stashIDs StashIDs if s.StashIDs != nil { - stashIDs = s.StashIDs.StashIDs + stashIDs = StashIDs(s.StashIDs.StashIDs) } ret := SceneUpdateInput{ @@ -212,7 +212,7 @@ func (s ScenePartial) UpdateInput(id int) SceneUpdateInput { PerformerIds: s.PerformerIDs.IDStrings(), Movies: s.GroupIDs.SceneMovieInputs(), TagIds: s.TagIDs.IDStrings(), - StashIds: stashIDs, + StashIds: stashIDs.ToStashIDInputs(), } return ret diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 35f781109cb..43e3e985b3b 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -3,6 +3,7 @@ package models import ( "context" "strconv" + "time" "github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/utils" @@ -29,8 +30,9 @@ func (s *ScrapedStudio) ToStudio(endpoint string, excluded map[string]bool) *Stu if s.RemoteSiteID != nil && endpoint != "" { ret.StashIDs = NewRelatedStashIDs([]StashID{ { - Endpoint: endpoint, - StashID: *s.RemoteSiteID, + Endpoint: endpoint, + StashID: *s.RemoteSiteID, + UpdatedAt: time.Now(), }, }) } @@ -65,6 +67,7 @@ func (s *ScrapedStudio) GetImage(ctx context.Context, excluded map[string]bool) func (s *ScrapedStudio) ToPartial(id string, endpoint string, excluded map[string]bool, existingStashIDs []StashID) StudioPartial { ret := NewStudioPartial() ret.ID, _ = strconv.Atoi(id) + currentTime := time.Now() if s.Name != "" && !excluded["name"] { ret.Name = NewOptionalString(s.Name) @@ -90,8 +93,9 @@ func (s *ScrapedStudio) ToPartial(id string, endpoint string, excluded map[strin Mode: RelationshipUpdateModeSet, } ret.StashIDs.Set(StashID{ - Endpoint: endpoint, - StashID: *s.RemoteSiteID, + Endpoint: endpoint, + StashID: *s.RemoteSiteID, + UpdatedAt: currentTime, }) } @@ -137,6 +141,7 @@ func (ScrapedPerformer) IsScrapedContent() {} func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool) *Performer { ret := NewPerformer() + currentTime := time.Now() ret.Name = *p.Name if p.Aliases != nil && !excluded["aliases"] { @@ -244,8 +249,9 @@ func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool if p.RemoteSiteID != nil && endpoint != "" { ret.StashIDs = NewRelatedStashIDs([]StashID{ { - Endpoint: endpoint, - StashID: *p.RemoteSiteID, + Endpoint: endpoint, + StashID: *p.RemoteSiteID, + UpdatedAt: currentTime, }, }) } @@ -375,8 +381,9 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, Mode: RelationshipUpdateModeSet, } ret.StashIDs.Set(StashID{ - Endpoint: endpoint, - StashID: *p.RemoteSiteID, + Endpoint: endpoint, + StashID: *p.RemoteSiteID, + UpdatedAt: time.Now(), }) } diff --git a/pkg/models/model_scraped_item_test.go b/pkg/models/model_scraped_item_test.go index 87ce2ad57dc..1e8edccb410 100644 --- a/pkg/models/model_scraped_item_test.go +++ b/pkg/models/model_scraped_item_test.go @@ -87,6 +87,11 @@ func Test_scrapedToStudioInput(t *testing.T) { got.CreatedAt = time.Time{} got.UpdatedAt = time.Time{} + if got.StashIDs.Loaded() && len(got.StashIDs.List()) > 0 { + for stid := range got.StashIDs.List() { + got.StashIDs.List()[stid].UpdatedAt = time.Time{} + } + } assert.Equal(t, tt.want, got) }) } @@ -243,6 +248,12 @@ func Test_scrapedToPerformerInput(t *testing.T) { got.CreatedAt = time.Time{} got.UpdatedAt = time.Time{} + + if got.StashIDs.Loaded() && len(got.StashIDs.List()) > 0 { + for stid := range got.StashIDs.List() { + got.StashIDs.List()[stid].UpdatedAt = time.Time{} + } + } assert.Equal(t, tt.want, got) }) } @@ -263,7 +274,7 @@ func TestScrapedStudio_ToPartial(t *testing.T) { images = []string{image} existingEndpoint = "existingEndpoint" - existingStashID = StashID{"existingStashID", existingEndpoint} + existingStashID = StashID{"existingStashID", existingEndpoint, time.Time{}} existingStashIDs = []StashID{existingStashID} ) @@ -362,6 +373,11 @@ func TestScrapedStudio_ToPartial(t *testing.T) { // unset updatedAt - we don't need to compare it got.UpdatedAt = OptionalTime{} + if got.StashIDs != nil && len(got.StashIDs.StashIDs) > 0 { + for stid := range got.StashIDs.StashIDs { + got.StashIDs.StashIDs[stid].UpdatedAt = time.Time{} + } + } assert.Equal(t, tt.want, got) }) diff --git a/pkg/models/performer.go b/pkg/models/performer.go index b14f60044be..47394996d3f 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -226,14 +226,14 @@ type PerformerCreateInput struct { Favorite *bool `json:"favorite"` TagIds []string `json:"tag_ids"` // This should be a URL or a base64 encoded data URL - Image *string `json:"image"` - StashIds []StashID `json:"stash_ids"` - Rating100 *int `json:"rating100"` - Details *string `json:"details"` - DeathDate *string `json:"death_date"` - HairColor *string `json:"hair_color"` - Weight *int `json:"weight"` - IgnoreAutoTag *bool `json:"ignore_auto_tag"` + Image *string `json:"image"` + StashIds []StashIDInput `json:"stash_ids"` + Rating100 *int `json:"rating100"` + Details *string `json:"details"` + DeathDate *string `json:"death_date"` + HairColor *string `json:"hair_color"` + Weight *int `json:"weight"` + IgnoreAutoTag *bool `json:"ignore_auto_tag"` } type PerformerUpdateInput struct { @@ -263,12 +263,12 @@ type PerformerUpdateInput struct { Favorite *bool `json:"favorite"` TagIds []string `json:"tag_ids"` // This should be a URL or a base64 encoded data URL - Image *string `json:"image"` - StashIds []StashID `json:"stash_ids"` - Rating100 *int `json:"rating100"` - Details *string `json:"details"` - DeathDate *string `json:"death_date"` - HairColor *string `json:"hair_color"` - Weight *int `json:"weight"` - IgnoreAutoTag *bool `json:"ignore_auto_tag"` + Image *string `json:"image"` + StashIds []StashIDInput `json:"stash_ids"` + Rating100 *int `json:"rating100"` + Details *string `json:"details"` + DeathDate *string `json:"death_date"` + HairColor *string `json:"hair_color"` + Weight *int `json:"weight"` + IgnoreAutoTag *bool `json:"ignore_auto_tag"` } diff --git a/pkg/models/scene.go b/pkg/models/scene.go index 48317240276..c7be343d98c 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -163,8 +163,8 @@ type SceneCreateInput struct { Groups []SceneGroupInput `json:"groups"` TagIds []string `json:"tag_ids"` // This should be a URL or a base64 encoded data URL - CoverImage *string `json:"cover_image"` - StashIds []StashID `json:"stash_ids"` + CoverImage *string `json:"cover_image"` + StashIds []StashIDInput `json:"stash_ids"` // The first id will be assigned as primary. // Files will be reassigned from existing scenes if applicable. // Files must not already be primary for another scene. @@ -191,12 +191,12 @@ type SceneUpdateInput struct { Groups []SceneGroupInput `json:"groups"` TagIds []string `json:"tag_ids"` // This should be a URL or a base64 encoded data URL - CoverImage *string `json:"cover_image"` - StashIds []StashID `json:"stash_ids"` - ResumeTime *float64 `json:"resume_time"` - PlayDuration *float64 `json:"play_duration"` - PlayCount *int `json:"play_count"` - PrimaryFileID *string `json:"primary_file_id"` + CoverImage *string `json:"cover_image"` + StashIds []StashIDInput `json:"stash_ids"` + ResumeTime *float64 `json:"resume_time"` + PlayDuration *float64 `json:"play_duration"` + PlayCount *int `json:"play_count"` + PrimaryFileID *string `json:"primary_file_id"` } type SceneDestroyInput struct { diff --git a/pkg/models/stash_ids.go b/pkg/models/stash_ids.go index fcc2bdec0c2..7751c2ef01c 100644 --- a/pkg/models/stash_ids.go +++ b/pkg/models/stash_ids.go @@ -1,8 +1,89 @@ package models +import ( + "slices" + "time" +) + type StashID struct { - StashID string `db:"stash_id" json:"stash_id"` - Endpoint string `db:"endpoint" json:"endpoint"` + StashID string `db:"stash_id" json:"stash_id"` + Endpoint string `db:"endpoint" json:"endpoint"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (s StashID) ToStashIDInput() StashIDInput { + t := s.UpdatedAt + return StashIDInput{ + StashID: s.StashID, + Endpoint: s.Endpoint, + UpdatedAt: &t, + } +} + +type StashIDs []StashID + +func (s StashIDs) ToStashIDInputs() StashIDInputs { + if s == nil { + return nil + } + + ret := make(StashIDInputs, len(s)) + for i, v := range s { + ret[i] = v.ToStashIDInput() + } + return ret +} + +// HasSameStashIDs returns true if the two lists of StashIDs are the same, ignoring order and updated at time. +func (s StashIDs) HasSameStashIDs(other StashIDs) bool { + if len(s) != len(other) { + return false + } + + for _, v := range s { + if !slices.ContainsFunc(other, func(o StashID) bool { + return o.StashID == v.StashID && o.Endpoint == v.Endpoint + }) { + return false + } + } + + return true +} + +type StashIDInput struct { + StashID string `db:"stash_id" json:"stash_id"` + Endpoint string `db:"endpoint" json:"endpoint"` + UpdatedAt *time.Time `db:"updated_at" json:"updated_at"` +} + +func (s StashIDInput) ToStashID() StashID { + ret := StashID{ + StashID: s.StashID, + Endpoint: s.Endpoint, + } + if s.UpdatedAt != nil { + ret.UpdatedAt = *s.UpdatedAt + } else { + // default to now if not provided + ret.UpdatedAt = time.Now() + } + + return ret +} + +type StashIDInputs []StashIDInput + +func (s StashIDInputs) ToStashIDs() StashIDs { + if s == nil { + return nil + } + + ret := make(StashIDs, len(s)) + for i, v := range s { + ret[i] = v.ToStashID() + } + return ret } type UpdateStashIDs struct { diff --git a/pkg/models/studio.go b/pkg/models/studio.go index d5575b7ad3b..03ea8a84dcd 100644 --- a/pkg/models/studio.go +++ b/pkg/models/studio.go @@ -51,14 +51,14 @@ type StudioCreateInput struct { URL *string `json:"url"` ParentID *string `json:"parent_id"` // This should be a URL or a base64 encoded data URL - Image *string `json:"image"` - StashIds []StashID `json:"stash_ids"` - Rating100 *int `json:"rating100"` - Favorite *bool `json:"favorite"` - Details *string `json:"details"` - Aliases []string `json:"aliases"` - TagIds []string `json:"tag_ids"` - IgnoreAutoTag *bool `json:"ignore_auto_tag"` + Image *string `json:"image"` + StashIds []StashIDInput `json:"stash_ids"` + Rating100 *int `json:"rating100"` + Favorite *bool `json:"favorite"` + Details *string `json:"details"` + Aliases []string `json:"aliases"` + TagIds []string `json:"tag_ids"` + IgnoreAutoTag *bool `json:"ignore_auto_tag"` } type StudioUpdateInput struct { @@ -67,12 +67,12 @@ type StudioUpdateInput struct { URL *string `json:"url"` ParentID *string `json:"parent_id"` // This should be a URL or a base64 encoded data URL - Image *string `json:"image"` - StashIds []StashID `json:"stash_ids"` - Rating100 *int `json:"rating100"` - Favorite *bool `json:"favorite"` - Details *string `json:"details"` - Aliases []string `json:"aliases"` - TagIds []string `json:"tag_ids"` - IgnoreAutoTag *bool `json:"ignore_auto_tag"` + Image *string `json:"image"` + StashIds []StashIDInput `json:"stash_ids"` + Rating100 *int `json:"rating100"` + Favorite *bool `json:"favorite"` + Details *string `json:"details"` + Aliases []string `json:"aliases"` + TagIds []string `json:"tag_ids"` + IgnoreAutoTag *bool `json:"ignore_auto_tag"` } diff --git a/pkg/scene/update_test.go b/pkg/scene/update_test.go index 96ebb491f66..f72c9640394 100644 --- a/pkg/scene/update_test.go +++ b/pkg/scene/update_test.go @@ -4,6 +4,7 @@ import ( "errors" "strconv" "testing" + "time" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" @@ -236,16 +237,19 @@ func TestUpdateSet_UpdateInput(t *testing.T) { tagIDStrs := intslice.IntSliceToStringSlice(tagIDs) stashID := "stashID" endpoint := "endpoint" + updatedAt := time.Now() stashIDs := []models.StashID{ { - StashID: stashID, - Endpoint: endpoint, + StashID: stashID, + Endpoint: endpoint, + UpdatedAt: updatedAt, }, } - stashIDInputs := []models.StashID{ + stashIDInputs := []models.StashIDInput{ { - StashID: stashID, - Endpoint: endpoint, + StashID: stashID, + Endpoint: endpoint, + UpdatedAt: &updatedAt, }, } diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 0510d7baf26..965c44ef9f4 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 68 +var appSchemaVersion uint = 69 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/69_stash_id_updated_at.up.sql b/pkg/sqlite/migrations/69_stash_id_updated_at.up.sql new file mode 100644 index 00000000000..1ffb280bd48 --- /dev/null +++ b/pkg/sqlite/migrations/69_stash_id_updated_at.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE `performer_stash_ids` ADD COLUMN `updated_at` datetime not null default '1970-01-01T00:00:00Z'; +ALTER TABLE `scene_stash_ids` ADD COLUMN `updated_at` datetime not null default '1970-01-01T00:00:00Z'; +ALTER TABLE `studio_stash_ids` ADD COLUMN `updated_at` datetime not null default '1970-01-01T00:00:00Z'; diff --git a/pkg/sqlite/repository.go b/pkg/sqlite/repository.go index 8eb87b9aff1..2035b11c2fc 100644 --- a/pkg/sqlite/repository.go +++ b/pkg/sqlite/repository.go @@ -14,11 +14,6 @@ import ( const idColumn = "id" -type objectList interface { - Append(o interface{}) - New() interface{} -} - type repository struct { tableName string idColumn string @@ -124,17 +119,6 @@ func (r *repository) queryFunc(ctx context.Context, query string, args []interfa return nil } -func (r *repository) query(ctx context.Context, query string, args []interface{}, out objectList) error { - return r.queryFunc(ctx, query, args, false, func(rows *sqlx.Rows) error { - object := out.New() - if err := rows.StructScan(object); err != nil { - return err - } - out.Append(object) - return nil - }) -} - func (r *repository) queryStruct(ctx context.Context, query string, args []interface{}, out interface{}) error { if err := r.queryFunc(ctx, query, args, true, func(rows *sqlx.Rows) error { if err := rows.StructScan(out); err != nil { @@ -421,7 +405,7 @@ type stashIDRepository struct { type stashIDs []models.StashID func (s *stashIDs) Append(o interface{}) { - *s = append(*s, *o.(*models.StashID)) + *s = append(*s, o.(models.StashID)) } func (s *stashIDs) New() interface{} { @@ -429,10 +413,17 @@ func (s *stashIDs) New() interface{} { } func (r *stashIDRepository) get(ctx context.Context, id int) ([]models.StashID, error) { - query := fmt.Sprintf("SELECT stash_id, endpoint from %s WHERE %s = ?", r.tableName, r.idColumn) + query := fmt.Sprintf("SELECT stash_id, endpoint, updated_at from %s WHERE %s = ?", r.tableName, r.idColumn) var ret stashIDs - err := r.query(ctx, query, []interface{}{id}, &ret) - return []models.StashID(ret), err + err := r.queryFunc(ctx, query, []interface{}{id}, false, func(rows *sqlx.Rows) error { + var v stashIDRow + if err := rows.StructScan(&v); err != nil { + return err + } + ret.Append(v.resolve()) + return nil + }) + return ret, err } type filesRepository struct { diff --git a/pkg/sqlite/table.go b/pkg/sqlite/table.go index 80d6b718a7f..e374f0790e3 100644 --- a/pkg/sqlite/table.go +++ b/pkg/sqlite/table.go @@ -275,19 +275,21 @@ type stashIDTable struct { } type stashIDRow struct { - StashID null.String `db:"stash_id"` - Endpoint null.String `db:"endpoint"` + StashID null.String `db:"stash_id"` + Endpoint null.String `db:"endpoint"` + UpdatedAt Timestamp `db:"updated_at"` } func (r *stashIDRow) resolve() models.StashID { return models.StashID{ - StashID: r.StashID.String, - Endpoint: r.Endpoint.String, + StashID: r.StashID.String, + Endpoint: r.Endpoint.String, + UpdatedAt: r.UpdatedAt.Timestamp, } } func (t *stashIDTable) get(ctx context.Context, id int) ([]models.StashID, error) { - q := dialect.Select("endpoint", "stash_id").From(t.table.table).Where(t.idColumn.Eq(id)) + q := dialect.Select("endpoint", "stash_id", "updated_at").From(t.table.table).Where(t.idColumn.Eq(id)) const single = false var ret []models.StashID @@ -308,8 +310,8 @@ func (t *stashIDTable) get(ctx context.Context, id int) ([]models.StashID, error } func (t *stashIDTable) insertJoin(ctx context.Context, id int, v models.StashID) (sql.Result, error) { - q := dialect.Insert(t.table.table).Cols(t.idColumn.GetCol(), "endpoint", "stash_id").Vals( - goqu.Vals{id, v.Endpoint, v.StashID}, + var q = dialect.Insert(t.table.table).Cols(t.idColumn.GetCol(), "endpoint", "stash_id", "updated_at").Vals( + goqu.Vals{id, v.Endpoint, v.StashID, v.UpdatedAt}, ) ret, err := exec(ctx, q) if err != nil { diff --git a/ui/v2.5/graphql/data/performer-slim.graphql b/ui/v2.5/graphql/data/performer-slim.graphql index 1a4b9833bc8..56a30842ddb 100644 --- a/ui/v2.5/graphql/data/performer-slim.graphql +++ b/ui/v2.5/graphql/data/performer-slim.graphql @@ -27,6 +27,7 @@ fragment SlimPerformerData on Performer { stash_ids { endpoint stash_id + updated_at } rating100 death_date diff --git a/ui/v2.5/graphql/data/performer.graphql b/ui/v2.5/graphql/data/performer.graphql index 144382a4522..0aa60ce21bb 100644 --- a/ui/v2.5/graphql/data/performer.graphql +++ b/ui/v2.5/graphql/data/performer.graphql @@ -34,6 +34,7 @@ fragment PerformerData on Performer { stash_ids { stash_id endpoint + updated_at } rating100 details diff --git a/ui/v2.5/graphql/data/scene-slim.graphql b/ui/v2.5/graphql/data/scene-slim.graphql index 7e2a4ffad2d..d5899a24764 100644 --- a/ui/v2.5/graphql/data/scene-slim.graphql +++ b/ui/v2.5/graphql/data/scene-slim.graphql @@ -84,5 +84,6 @@ fragment SlimSceneData on Scene { stash_ids { endpoint stash_id + updated_at } } diff --git a/ui/v2.5/graphql/data/scene.graphql b/ui/v2.5/graphql/data/scene.graphql index ef58922295a..e4a6e5cc69f 100644 --- a/ui/v2.5/graphql/data/scene.graphql +++ b/ui/v2.5/graphql/data/scene.graphql @@ -71,6 +71,7 @@ fragment SceneData on Scene { stash_ids { endpoint stash_id + updated_at } sceneStreams { diff --git a/ui/v2.5/graphql/data/studio-slim.graphql b/ui/v2.5/graphql/data/studio-slim.graphql index 406a2ffa70a..cf101bd047c 100644 --- a/ui/v2.5/graphql/data/studio-slim.graphql +++ b/ui/v2.5/graphql/data/studio-slim.graphql @@ -5,6 +5,7 @@ fragment SlimStudioData on Studio { stash_ids { endpoint stash_id + updated_at } parent_studio { id diff --git a/ui/v2.5/graphql/data/studio.graphql b/ui/v2.5/graphql/data/studio.graphql index feb35136fed..25e77675549 100644 --- a/ui/v2.5/graphql/data/studio.graphql +++ b/ui/v2.5/graphql/data/studio.graphql @@ -28,6 +28,7 @@ fragment StudioData on Studio { stash_ids { stash_id endpoint + updated_at } details rating100 diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index e7d7a8b41e3..2adcb601e1e 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -282,7 +282,10 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({ formik.setFieldValue("penis_length", state.penis_length); } - const remoteSiteID = state.remote_site_id; + updateStashIDs(state.remote_site_id); + } + + function updateStashIDs(remoteSiteID: string | null | undefined) { if (remoteSiteID && (scraper as IStashBox).endpoint) { const newIDs = formik.values.stash_ids?.filter( @@ -291,6 +294,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({ newIDs?.push({ endpoint: (scraper as IStashBox).endpoint, stash_id: remoteSiteID, + updated_at: new Date().toISOString(), }); formik.setFieldValue("stash_ids", newIDs); } @@ -438,6 +442,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({ setScraper(undefined); } else { setScrapedPerformer(result); + updateStashIDs(performerResult.remote_site_id); } } diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index 2eef3de1fb4..fb91cd44925 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -508,6 +508,7 @@ export const SceneEditPanel: React.FC<IProps> = ({ return { endpoint, stash_id: updatedScene.remote_site_id, + updated_at: new Date().toISOString(), }; } @@ -521,6 +522,7 @@ export const SceneEditPanel: React.FC<IProps> = ({ formik.values.stash_ids.concat({ endpoint, stash_id: updatedScene.remote_site_id, + updated_at: new Date().toISOString(), }) ); } diff --git a/ui/v2.5/src/components/Shared/StashID.tsx b/ui/v2.5/src/components/Shared/StashID.tsx index 14bcef6882c..00bddf58edf 100644 --- a/ui/v2.5/src/components/Shared/StashID.tsx +++ b/ui/v2.5/src/components/Shared/StashID.tsx @@ -7,7 +7,7 @@ import { ExternalLink } from "./ExternalLink"; export type LinkType = "performers" | "scenes" | "studios"; export const StashIDPill: React.FC<{ - stashID: StashId; + stashID: Pick<StashId, "endpoint" | "stash_id">; linkType: LinkType; }> = ({ stashID, linkType }) => { const { configuration } = React.useContext(ConfigurationContext); diff --git a/ui/v2.5/src/components/Tagger/PerformerModal.tsx b/ui/v2.5/src/components/Tagger/PerformerModal.tsx index 37a2009aa76..d4c8bba1636 100755 --- a/ui/v2.5/src/components/Tagger/PerformerModal.tsx +++ b/ui/v2.5/src/components/Tagger/PerformerModal.tsx @@ -272,6 +272,7 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({ { endpoint, stash_id: remoteSiteID, + updated_at: new Date().toISOString(), }, ]; } diff --git a/ui/v2.5/src/components/Tagger/context.tsx b/ui/v2.5/src/components/Tagger/context.tsx index dc35208c5f2..9b1e996de24 100644 --- a/ui/v2.5/src/components/Tagger/context.tsx +++ b/ui/v2.5/src/components/Tagger/context.tsx @@ -613,12 +613,14 @@ export const TaggerContext: React.FC = ({ children }) => { return { endpoint: e.endpoint, stash_id: e.stash_id, + updated_at: e.updated_at, }; }); stashIDs.push({ stash_id: performer.remote_site_id, endpoint: currentSource?.sourceInput.stash_box_endpoint, + updated_at: new Date().toISOString(), }); await updatePerformer({ @@ -770,12 +772,14 @@ export const TaggerContext: React.FC = ({ children }) => { return { endpoint: e.endpoint, stash_id: e.stash_id, + updated_at: e.updated_at, }; }); stashIDs.push({ stash_id: studio.remote_site_id, endpoint: currentSource?.sourceInput.stash_box_endpoint, + updated_at: new Date().toISOString(), }); await updateStudio({ diff --git a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx index cc8f6a132e6..4be285907b6 100755 --- a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx @@ -413,6 +413,7 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({ return { endpoint: s.endpoint, stash_id: s.stash_id, + updated_at: s.updated_at, }; }) .filter( @@ -421,6 +422,7 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({ { endpoint: currentSource.sourceInput.stash_box_endpoint, stash_id: scene.remote_site_id, + updated_at: new Date().toISOString(), }, ]; } else { diff --git a/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx b/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx index b57c796ab28..249e34e7401 100644 --- a/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx @@ -198,11 +198,13 @@ const StudioModal: React.FC<IStudioModalProps> = ({ // stashid handling code const remoteSiteID = studio.remote_site_id; + const timeNow = new Date().toISOString(); if (remoteSiteID && endpoint) { studioData.stash_ids = [ { endpoint, stash_id: remoteSiteID, + updated_at: timeNow, }, ]; } @@ -230,6 +232,7 @@ const StudioModal: React.FC<IStudioModalProps> = ({ { endpoint, stash_id: parentRemoteSiteID, + updated_at: timeNow, }, ]; } diff --git a/ui/v2.5/src/utils/stashIds.ts b/ui/v2.5/src/utils/stashIds.ts index 3240b2a0f56..289ce9c9d70 100644 --- a/ui/v2.5/src/utils/stashIds.ts +++ b/ui/v2.5/src/utils/stashIds.ts @@ -1,5 +1,8 @@ -export const getStashIDs = (ids?: { stash_id: string; endpoint: string }[]) => - (ids ?? []).map(({ stash_id, endpoint }) => ({ +export const getStashIDs = ( + ids?: { stash_id: string; endpoint: string; updated_at: string }[] +) => + (ids ?? []).map(({ stash_id, endpoint, updated_at }) => ({ stash_id, endpoint, + updated_at, })); From 0d40056f8c6b11cec1b9acd11a9fdac4e9fb13f6 Mon Sep 17 00:00:00 2001 From: MinasukiHikimuna <121475844+MinasukiHikimuna@users.noreply.github.com> Date: Sat, 2 Nov 2024 02:55:48 +0200 Subject: [PATCH 82/91] Markers can have end time (#5311) * Markers can have end time Other metadata sources such as ThePornDB and timestamp.trade support end times for markers but Stash did not yet support saving those. This is a first step which only allows end time to be set either via API or via UI. Other aspects of Stash such as video player timeline are not yet updated to take end time into account. - User can set end time when creating or editing markers in the UI or in the API. - End time cannot be before start time. This is validated in the backend and for better UX also in the frontend. - End time is shown in scene details view or markers wall view if present. - GraphQL API does not require end_seconds. --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/schema/types/scene-marker.graphql | 9 ++++ internal/api/resolver_mutation_scene.go | 37 ++++++++++++++- pkg/models/model_scene_marker.go | 2 + pkg/sqlite/database.go | 2 +- pkg/sqlite/migrations/70_markers_end.up.sql | 1 + pkg/sqlite/scene_marker.go | 21 ++++++--- ui/v2.5/graphql/data/scene-marker.graphql | 1 + .../graphql/mutations/scene-marker.graphql | 4 ++ .../Scenes/SceneDetails/PrimaryTags.tsx | 7 ++- .../Scenes/SceneDetails/SceneMarkerForm.tsx | 46 +++++++++++++++++++ ui/v2.5/src/components/Wall/WallItem.tsx | 5 +- ui/v2.5/src/locales/en-GB.json | 2 + ui/v2.5/src/utils/text.ts | 8 ++++ 13 files changed, 134 insertions(+), 11 deletions(-) create mode 100644 pkg/sqlite/migrations/70_markers_end.up.sql diff --git a/graphql/schema/types/scene-marker.graphql b/graphql/schema/types/scene-marker.graphql index 8b995c9d507..6d144121374 100644 --- a/graphql/schema/types/scene-marker.graphql +++ b/graphql/schema/types/scene-marker.graphql @@ -2,7 +2,10 @@ type SceneMarker { id: ID! scene: Scene! title: String! + "The required start time of the marker (in seconds). Supports decimals." seconds: Float! + "The optional end time of the marker (in seconds). Supports decimals." + end_seconds: Float primary_tag: Tag! tags: [Tag!]! created_at: Time! @@ -18,7 +21,10 @@ type SceneMarker { input SceneMarkerCreateInput { title: String! + "The required start time of the marker (in seconds). Supports decimals." seconds: Float! + "The optional end time of the marker (in seconds). Supports decimals." + end_seconds: Float scene_id: ID! primary_tag_id: ID! tag_ids: [ID!] @@ -27,7 +33,10 @@ input SceneMarkerCreateInput { input SceneMarkerUpdateInput { id: ID! title: String + "The start time of the marker (in seconds). Supports decimals." seconds: Float + "The end time of the marker (in seconds). Supports decimals." + end_seconds: Float scene_id: ID primary_tag_id: ID tag_ids: [ID!] diff --git a/internal/api/resolver_mutation_scene.go b/internal/api/resolver_mutation_scene.go index b0c6ac8b5aa..101cc8ba5e5 100644 --- a/internal/api/resolver_mutation_scene.go +++ b/internal/api/resolver_mutation_scene.go @@ -655,6 +655,13 @@ func (r *mutationResolver) SceneMarkerCreate(ctx context.Context, input SceneMar newMarker.PrimaryTagID = primaryTagID newMarker.SceneID = sceneID + if input.EndSeconds != nil { + if err := validateSceneMarkerEndSeconds(newMarker.Seconds, *input.EndSeconds); err != nil { + return nil, err + } + newMarker.EndSeconds = input.EndSeconds + } + tagIDs, err := stringslice.StringSliceToIntSlice(input.TagIds) if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) @@ -680,6 +687,13 @@ func (r *mutationResolver) SceneMarkerCreate(ctx context.Context, input SceneMar return r.getSceneMarker(ctx, newMarker.ID) } +func validateSceneMarkerEndSeconds(seconds, endSeconds float64) error { + if endSeconds < seconds { + return fmt.Errorf("end_seconds (%f) must be greater than or equal to seconds (%f)", endSeconds, seconds) + } + return nil +} + func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMarkerUpdateInput) (*models.SceneMarker, error) { markerID, err := strconv.Atoi(input.ID) if err != nil { @@ -695,6 +709,7 @@ func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMar updatedMarker.Title = translator.optionalString(input.Title, "title") updatedMarker.Seconds = translator.optionalFloat64(input.Seconds, "seconds") + updatedMarker.EndSeconds = translator.optionalFloat64(input.EndSeconds, "end_seconds") updatedMarker.SceneID, err = translator.optionalIntFromString(input.SceneID, "scene_id") if err != nil { return nil, fmt.Errorf("converting scene id: %w", err) @@ -735,6 +750,26 @@ func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMar return fmt.Errorf("scene marker with id %d not found", markerID) } + // Validate end_seconds + shouldValidateEndSeconds := (updatedMarker.Seconds.Set || updatedMarker.EndSeconds.Set) && !updatedMarker.EndSeconds.Null + if shouldValidateEndSeconds { + seconds := existingMarker.Seconds + if updatedMarker.Seconds.Set { + seconds = updatedMarker.Seconds.Value + } + + endSeconds := existingMarker.EndSeconds + if updatedMarker.EndSeconds.Set { + endSeconds = &updatedMarker.EndSeconds.Value + } + + if endSeconds != nil { + if err := validateSceneMarkerEndSeconds(seconds, *endSeconds); err != nil { + return err + } + } + } + newMarker, err := qb.UpdatePartial(ctx, markerID, updatedMarker) if err != nil { return err @@ -749,7 +784,7 @@ func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMar } // remove the marker preview if the scene changed or if the timestamp was changed - if existingMarker.SceneID != newMarker.SceneID || existingMarker.Seconds != newMarker.Seconds { + if existingMarker.SceneID != newMarker.SceneID || existingMarker.Seconds != newMarker.Seconds || existingMarker.EndSeconds != newMarker.EndSeconds { seconds := int(existingMarker.Seconds) if err := fileDeleter.MarkMarkerFiles(existingScene, seconds); err != nil { return err diff --git a/pkg/models/model_scene_marker.go b/pkg/models/model_scene_marker.go index df77afecd77..77860331533 100644 --- a/pkg/models/model_scene_marker.go +++ b/pkg/models/model_scene_marker.go @@ -8,6 +8,7 @@ type SceneMarker struct { ID int `json:"id"` Title string `json:"title"` Seconds float64 `json:"seconds"` + EndSeconds *float64 `json:"end_seconds"` PrimaryTagID int `json:"primary_tag_id"` SceneID int `json:"scene_id"` CreatedAt time.Time `json:"created_at"` @@ -27,6 +28,7 @@ func NewSceneMarker() SceneMarker { type SceneMarkerPartial struct { Title OptionalString Seconds OptionalFloat64 + EndSeconds OptionalFloat64 PrimaryTagID OptionalInt SceneID OptionalInt CreatedAt OptionalTime diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 965c44ef9f4..d2c0a8191e5 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 69 +var appSchemaVersion uint = 70 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/70_markers_end.up.sql b/pkg/sqlite/migrations/70_markers_end.up.sql new file mode 100644 index 00000000000..05469953ace --- /dev/null +++ b/pkg/sqlite/migrations/70_markers_end.up.sql @@ -0,0 +1 @@ +ALTER TABLE scene_markers ADD COLUMN end_seconds FLOAT; \ No newline at end of file diff --git a/pkg/sqlite/scene_marker.go b/pkg/sqlite/scene_marker.go index 4af4d6b4bae..8b2306eab4b 100644 --- a/pkg/sqlite/scene_marker.go +++ b/pkg/sqlite/scene_marker.go @@ -10,6 +10,7 @@ import ( "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" "github.com/jmoiron/sqlx" + "gopkg.in/guregu/null.v4" "github.com/stashapp/stash/pkg/models" ) @@ -24,19 +25,23 @@ GROUP BY scene_markers.id ` type sceneMarkerRow struct { - ID int `db:"id" goqu:"skipinsert"` - Title string `db:"title"` // TODO: make db schema (and gql schema) nullable - Seconds float64 `db:"seconds"` - PrimaryTagID int `db:"primary_tag_id"` - SceneID int `db:"scene_id"` - CreatedAt Timestamp `db:"created_at"` - UpdatedAt Timestamp `db:"updated_at"` + ID int `db:"id" goqu:"skipinsert"` + Title string `db:"title"` // TODO: make db schema (and gql schema) nullable + Seconds float64 `db:"seconds"` + PrimaryTagID int `db:"primary_tag_id"` + SceneID int `db:"scene_id"` + CreatedAt Timestamp `db:"created_at"` + UpdatedAt Timestamp `db:"updated_at"` + EndSeconds null.Float `db:"end_seconds"` } func (r *sceneMarkerRow) fromSceneMarker(o models.SceneMarker) { r.ID = o.ID r.Title = o.Title r.Seconds = o.Seconds + if o.EndSeconds != nil { + r.EndSeconds = null.FloatFrom(*o.EndSeconds) + } r.PrimaryTagID = o.PrimaryTagID r.SceneID = o.SceneID r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} @@ -48,6 +53,7 @@ func (r *sceneMarkerRow) resolve() *models.SceneMarker { ID: r.ID, Title: r.Title, Seconds: r.Seconds, + EndSeconds: r.EndSeconds.Ptr(), PrimaryTagID: r.PrimaryTagID, SceneID: r.SceneID, CreatedAt: r.CreatedAt.Timestamp, @@ -69,6 +75,7 @@ func (r *sceneMarkerRowRecord) fromPartial(o models.SceneMarkerPartial) { r.set("title", o.Title.Value) } r.setFloat64("seconds", o.Seconds) + r.setNullFloat64("end_seconds", o.EndSeconds) r.setInt("primary_tag_id", o.PrimaryTagID) r.setInt("scene_id", o.SceneID) r.setTimestamp("created_at", o.CreatedAt) diff --git a/ui/v2.5/graphql/data/scene-marker.graphql b/ui/v2.5/graphql/data/scene-marker.graphql index 9fd0c7d3ded..e2ebfc4df34 100644 --- a/ui/v2.5/graphql/data/scene-marker.graphql +++ b/ui/v2.5/graphql/data/scene-marker.graphql @@ -2,6 +2,7 @@ fragment SceneMarkerData on SceneMarker { id title seconds + end_seconds stream preview screenshot diff --git a/ui/v2.5/graphql/mutations/scene-marker.graphql b/ui/v2.5/graphql/mutations/scene-marker.graphql index fb4c9744434..3b1de35c7b2 100644 --- a/ui/v2.5/graphql/mutations/scene-marker.graphql +++ b/ui/v2.5/graphql/mutations/scene-marker.graphql @@ -1,6 +1,7 @@ mutation SceneMarkerCreate( $title: String! $seconds: Float! + $end_seconds: Float $scene_id: ID! $primary_tag_id: ID! $tag_ids: [ID!] = [] @@ -9,6 +10,7 @@ mutation SceneMarkerCreate( input: { title: $title seconds: $seconds + end_seconds: $end_seconds scene_id: $scene_id primary_tag_id: $primary_tag_id tag_ids: $tag_ids @@ -22,6 +24,7 @@ mutation SceneMarkerUpdate( $id: ID! $title: String! $seconds: Float! + $end_seconds: Float $scene_id: ID! $primary_tag_id: ID! $tag_ids: [ID!] = [] @@ -31,6 +34,7 @@ mutation SceneMarkerUpdate( id: $id title: $title seconds: $seconds + end_seconds: $end_seconds scene_id: $scene_id primary_tag_id: $primary_tag_id tag_ids: $tag_ids diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx index 9694ca9ed29..11c805ec6b5 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx @@ -52,7 +52,12 @@ export const PrimaryTags: React.FC<IPrimaryTags> = ({ <FormattedMessage id="actions.edit" /> </Button> </div> - <div>{TextUtils.secondsToTimestamp(marker.seconds)}</div> + <div> + {TextUtils.formatTimestampRange( + marker.seconds, + marker.end_seconds ?? undefined + )} + </div> <div className="card-section centered">{tags}</div> </div> ); diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx index 7452bdd198b..03fcb3b483f 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx @@ -44,6 +44,18 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({ const schema = yup.object({ title: yup.string().ensure(), seconds: yup.number().min(0).required(), + end_seconds: yup + .number() + .min(0) + .nullable() + .defined() + .test( + "is-greater-than-seconds", + intl.formatMessage({ id: "end_time_before_start_time" }), + function (value) { + return value === null || value >= this.parent.seconds; + } + ), primary_tag_id: yup.string().required(), tag_ids: yup.array(yup.string().required()).defined(), }); @@ -53,6 +65,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({ () => ({ title: marker?.title ?? "", seconds: marker?.seconds ?? Math.round(getPlayerPosition() ?? 0), + end_seconds: marker?.end_seconds ?? null, primary_tag_id: marker?.primary_tag.id ?? "", tag_ids: marker?.tags.map((tag) => tag.id) ?? [], }), @@ -103,6 +116,8 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({ variables: { scene_id: sceneID, ...input, + // undefined means setting to null, not omitting the field + end_seconds: input.end_seconds ?? null, }, }); } else { @@ -111,6 +126,8 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({ id: marker.id, scene_id: sceneID, ...input, + // undefined means setting to null, not omitting the field + end_seconds: input.end_seconds ?? null, }, }); } @@ -205,6 +222,34 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({ return renderField("seconds", title, control); } + function renderEndTimeField() { + const { error } = formik.getFieldMeta("end_seconds"); + + const title = intl.formatMessage({ id: "time_end" }); + const control = ( + <> + <DurationInput + value={formik.values.end_seconds} + setValue={(v) => formik.setFieldValue("end_seconds", v ?? null)} + onReset={() => + formik.setFieldValue( + "end_seconds", + Math.round(getPlayerPosition() ?? 0) + ) + } + error={error} + /> + {formik.touched.end_seconds && formik.errors.end_seconds && ( + <Form.Control.Feedback type="invalid"> + {formik.errors.end_seconds} + </Form.Control.Feedback> + )} + </> + ); + + return renderField("end_seconds", title, control); + } + function renderTagsField() { const title = intl.formatMessage({ id: "tags" }); const control = ( @@ -225,6 +270,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({ {renderTitleField()} {renderPrimaryTagField()} {renderTimeField()} + {renderEndTimeField()} {renderTagsField()} </div> <div className="buttons-container px-3"> diff --git a/ui/v2.5/src/components/Wall/WallItem.tsx b/ui/v2.5/src/components/Wall/WallItem.tsx index 427c060cc3e..5811b75437e 100644 --- a/ui/v2.5/src/components/Wall/WallItem.tsx +++ b/ui/v2.5/src/components/Wall/WallItem.tsx @@ -183,7 +183,10 @@ export const WallItem = <T extends WallItemType>({ case "sceneMarker": const sceneMarker = data as GQL.SceneMarkerDataFragment; const newTitle = markerTitle(sceneMarker); - const seconds = TextUtils.secondsToTimestamp(sceneMarker.seconds); + const seconds = TextUtils.formatTimestampRange( + sceneMarker.seconds, + sceneMarker.end_seconds ?? undefined + ); if (newTitle) { return `${newTitle} - ${seconds}`; } else { diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 6be0a654240..edad1c8e7cf 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1457,6 +1457,7 @@ "tags": "Tags", "tattoos": "Tattoos", "time": "Time", + "time_end": "End Time", "title": "Title", "toast": { "added_entity": "Added {count, plural, one {{singularEntity}} other {{pluralEntity}}}", @@ -1488,6 +1489,7 @@ "validation": { "blank": "${path} must not be blank", "date_invalid_form": "${path} must be in YYYY-MM-DD form", + "end_time_before_start_time": "End time must be greater than or equal to start time", "required": "${path} is a required field", "unique": "${path} must be unique" }, diff --git a/ui/v2.5/src/utils/text.ts b/ui/v2.5/src/utils/text.ts index 627822f21c2..da7f7e0241d 100644 --- a/ui/v2.5/src/utils/text.ts +++ b/ui/v2.5/src/utils/text.ts @@ -184,6 +184,13 @@ const secondsToTimestamp = (seconds: number) => { } }; +const formatTimestampRange = (start: number, end: number | undefined) => { + if (end === undefined) { + return secondsToTimestamp(start); + } + return `${secondsToTimestamp(start)}-${secondsToTimestamp(end)}`; +}; + const timestampToSeconds = (v: string | null | undefined) => { if (!v) { return null; @@ -470,6 +477,7 @@ const TextUtils = { formatFileSizeUnit, fileSizeFractionalDigits, secondsToTimestamp, + formatTimestampRange, timestampToSeconds, fileNameFromPath, stringToDate, From e8125d08db53cb4c1f14969ed0d01513803446ff Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Sat, 2 Nov 2024 14:59:54 +1100 Subject: [PATCH 83/91] Sub second marker timestamp precision (#5431) * Allow DurationInput to accept/format timestamps with milliseconds * Get current frame at sub-second precision --- .../components/ScenePlayer/ScenePlayer.tsx | 8 +++- .../Scenes/SceneDetails/SceneMarkerForm.tsx | 2 +- .../src/components/Shared/DurationInput.tsx | 23 +++++++---- ui/v2.5/src/utils/text.ts | 41 +++++++++++++++---- 4 files changed, 55 insertions(+), 19 deletions(-) diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index 6858e2cd15b..24453043bd8 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -297,9 +297,13 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({ sendSetTimestamp((value: number) => { const player = getPlayer(); if (player && value >= 0) { - player.play()?.then(() => { + if (player.hasStarted() && player.paused()) { player.currentTime(value); - }); + } else { + player.play()?.then(() => { + player.currentTime(value); + }); + } } }); }, [sendSetTimestamp, getPlayer]); diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx index 03fcb3b483f..baaf028dcce 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx @@ -213,7 +213,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({ value={formik.values.seconds} setValue={(v) => formik.setFieldValue("seconds", v)} onReset={() => - formik.setFieldValue("seconds", Math.round(getPlayerPosition() ?? 0)) + formik.setFieldValue("seconds", getPlayerPosition() ?? 0) } error={error} /> diff --git a/ui/v2.5/src/components/Shared/DurationInput.tsx b/ui/v2.5/src/components/Shared/DurationInput.tsx index 7c7fde43172..5bd58b1e5d0 100644 --- a/ui/v2.5/src/components/Shared/DurationInput.tsx +++ b/ui/v2.5/src/components/Shared/DurationInput.tsx @@ -3,7 +3,7 @@ import { faChevronUp, faClock, } from "@fortawesome/free-solid-svg-icons"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { Button, ButtonGroup, InputGroup, Form } from "react-bootstrap"; import { Icon } from "./Icon"; import TextUtils from "src/utils/text"; @@ -19,6 +19,8 @@ interface IProps { allowNegative?: boolean; } +const includeMS = true; + export const DurationInput: React.FC<IProps> = ({ disabled, value, @@ -96,17 +98,20 @@ export const DurationInput: React.FC<IProps> = ({ } } - let inputValue = ""; - if (tmpValue !== undefined) { - inputValue = tmpValue; - } else if (value !== null && value !== undefined) { - inputValue = TextUtils.secondsToTimestamp(value); - } + const inputValue = useMemo(() => { + if (tmpValue !== undefined) { + return tmpValue; + } else if (value !== null && value !== undefined) { + return TextUtils.secondsToTimestamp(value, includeMS); + } + }, [value, tmpValue]); + + const format = "hh:mm:ss.ms"; if (placeholder) { - placeholder = `${placeholder} (hh:mm:ss)`; + placeholder = `${placeholder} (${format})`; } else { - placeholder = "hh:mm:ss"; + placeholder = format; } return ( diff --git a/ui/v2.5/src/utils/text.ts b/ui/v2.5/src/utils/text.ts index da7f7e0241d..246dcb0e4ac 100644 --- a/ui/v2.5/src/utils/text.ts +++ b/ui/v2.5/src/utils/text.ts @@ -151,16 +151,20 @@ const fileSizeFractionalDigits = (unit: Unit) => { return 0; }; -// Converts seconds to a hh:mm:ss or mm:ss timestamp. +// Converts seconds to a [hh:]mm:ss[.ffff] where hh is only shown if hours is non-zero, +// and ffff is shown only if frameRate is set, and the seconds includes a fractional component. // A negative input will result in a -hh:mm:ss or -mm:ss output. -// Fractional inputs are truncated. -const secondsToTimestamp = (seconds: number) => { +const secondsToTimestamp = (secondsInput: number, includeMS?: boolean) => { let neg = false; - if (seconds < 0) { + if (secondsInput < 0) { neg = true; - seconds = -seconds; + secondsInput = -secondsInput; } - seconds = Math.trunc(seconds); + + const fracSeconds = secondsInput % 1; + const ms = Math.round(fracSeconds * 1000); + + let seconds = Math.trunc(secondsInput); const s = seconds % 60; seconds = (seconds - s) / 60; @@ -177,6 +181,11 @@ const secondsToTimestamp = (seconds: number) => { ret = String(m).padStart(2, "0") + ":" + ret; ret = String(h) + ":" + ret; } + + if (includeMS && ms > 0) { + ret += "." + ms.toString().padStart(3, "0"); + } + if (neg) { return "-" + ret; } else { @@ -202,6 +211,24 @@ const timestampToSeconds = (v: string | null | undefined) => { return null; } + let secondsPart = splits[splits.length - 1]; + let msFrac = 0; + if (secondsPart.includes(".")) { + const secondsParts = secondsPart.split("."); + if (secondsParts.length !== 2) { + return null; + } + + secondsPart = secondsParts[0]; + + const msPart = parseInt(secondsParts[1], 10); + if (Number.isNaN(msPart)) { + return null; + } + + msFrac = msPart / 1000; + } + let seconds = 0; let factor = 1; while (splits.length > 0) { @@ -219,7 +246,7 @@ const timestampToSeconds = (v: string | null | undefined) => { factor *= 60; } - return seconds; + return seconds + msFrac; }; const fileNameFromPath = (path: string) => { From 527c282b92e715b17267fc926aeffb2dd96d3ce6 Mon Sep 17 00:00:00 2001 From: MinasukiHikimuna <121475844+MinasukiHikimuna@users.noreply.github.com> Date: Sun, 3 Nov 2024 06:02:52 +0200 Subject: [PATCH 84/91] Setting marker end time with clock button uses full precision (#5437) --- .../src/components/Scenes/SceneDetails/SceneMarkerForm.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx index baaf028dcce..da4d679cc20 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx @@ -232,10 +232,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({ value={formik.values.end_seconds} setValue={(v) => formik.setFieldValue("end_seconds", v ?? null)} onReset={() => - formik.setFieldValue( - "end_seconds", - Math.round(getPlayerPosition() ?? 0) - ) + formik.setFieldValue("end_seconds", getPlayerPosition() ?? 0) } error={error} /> From a100f8ffc8d694721cad8263de1ab1ef556ca369 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 7 Nov 2024 13:16:28 +1100 Subject: [PATCH 85/91] Refactor setup wizard to fix text input (#5459) --- ui/v2.5/src/components/Setup/Setup.tsx | 1640 ++++++++++++---------- ui/v2.5/src/components/Setup/styles.scss | 7 + ui/v2.5/src/locales/en-GB.json | 4 +- 3 files changed, 898 insertions(+), 753 deletions(-) diff --git a/ui/v2.5/src/components/Setup/Setup.tsx b/ui/v2.5/src/components/Setup/Setup.tsx index 9168907949e..1f8ec4614a4 100644 --- a/ui/v2.5/src/components/Setup/Setup.tsx +++ b/ui/v2.5/src/components/Setup/Setup.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useContext } from "react"; +import React, { useState, useContext, useCallback } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { Alert, @@ -29,45 +29,55 @@ import { import { releaseNotes } from "src/docs/en/ReleaseNotes"; import { ExternalLink } from "../Shared/ExternalLink"; -export const Setup: React.FC = () => { - const { configuration, loading: configLoading } = - useContext(ConfigurationContext); - const [saveUI] = useConfigureUI(); +interface ISetupContextState { + configuration: GQL.ConfigDataFragment; + systemStatus: GQL.SystemStatusQuery; - const [step, setStep] = useState(0); - const [setupInWorkDir, setSetupInWorkDir] = useState(false); - const [stashes, setStashes] = useState<GQL.StashConfig[]>([]); - const [showStashAlert, setShowStashAlert] = useState(false); - const [databaseFile, setDatabaseFile] = useState(""); - const [generatedLocation, setGeneratedLocation] = useState(""); - const [cacheLocation, setCacheLocation] = useState(""); - const [storeBlobsInDatabase, setStoreBlobsInDatabase] = useState(false); - const [blobsLocation, setBlobsLocation] = useState(""); - const [loading, setLoading] = useState(false); - const [setupError, setSetupError] = useState<string>(); - const [downloadFFmpeg, setDownloadFFmpeg] = useState(true); + setupState: Partial<GQL.SetupInput>; + setupError: string | undefined; - const intl = useIntl(); - const history = useHistory(); + pathJoin: (...paths: string[]) => string; + pathDir(path: string): string; - const [showGeneratedSelectDialog, setShowGeneratedSelectDialog] = - useState(false); - const [showCacheSelectDialog, setShowCacheSelectDialog] = useState(false); - const [showBlobsDialog, setShowBlobsDialog] = useState(false); + homeDir: string; + windows: boolean; + macApp: boolean; + homeDirPath: string; + pwd: string; + workingDir: string; +} - const { data: systemStatus, loading: statusLoading } = useSystemStatus(); - const status = systemStatus?.systemStatus; +const SetupStateContext = React.createContext<ISetupContextState | null>(null); - const [mutateDownloadFFMpeg] = GQL.useDownloadFfMpegMutation(); +const useSetupContext = () => { + const context = React.useContext(SetupStateContext); + + if (context === null) { + throw new Error("useSettings must be used within a SettingsContext"); + } + + return context; +}; + +const SetupContext: React.FC<{ + setupState: Partial<GQL.SetupInput>; + setupError: string | undefined; + systemStatus: GQL.SystemStatusQuery; + configuration: GQL.ConfigDataFragment; +}> = ({ setupState, setupError, systemStatus, configuration, children }) => { + const status = systemStatus?.systemStatus; const windows = status?.os === "windows"; const pathSep = windows ? "\\" : "/"; const homeDir = windows ? "%USERPROFILE%" : "$HOME"; const pwd = windows ? "%CD%" : "$PWD"; - function pathJoin(...paths: string[]) { - return paths.join(pathSep); - } + const pathJoin = useCallback( + (...paths: string[]) => { + return paths.join(pathSep); + }, + [pathSep] + ); // simply returns everything preceding the last path separator function pathDir(path: string) { @@ -83,528 +93,877 @@ export const Setup: React.FC = () => { // so in this situation disallow setting up in the working directory. const macApp = status?.os === "darwin" && workingDir === "/"; - const fallbackStashDir = pathJoin(homeDir, ".stash"); - const fallbackConfigPath = pathJoin(fallbackStashDir, "config.yml"); + const homeDirPath = pathJoin(status?.homeDir ?? homeDir, ".stash"); + + const state: ISetupContextState = { + systemStatus, + configuration, + windows, + macApp, + pathJoin, + pathDir, + homeDir, + homeDirPath, + pwd, + workingDir, + setupState, + setupError, + }; - const overrideConfig = status?.configPath; - const overrideGenerated = configuration?.general.generatedPath; - const overrideCache = configuration?.general.cachePath; - const overrideBlobs = configuration?.general.blobsPath; - const overrideDatabase = configuration?.general.databasePath; + return ( + <SetupStateContext.Provider value={state}> + {children} + </SetupStateContext.Provider> + ); +}; - useEffect(() => { - if (configuration) { - const configStashes = configuration.general.stashes; - if (configStashes.length > 0) { - setStashes( - configStashes.map((s) => { - const { __typename, ...withoutTypename } = s; - return withoutTypename; - }) - ); - } - } - }, [configuration]); +interface IWizardStep { + next: (input?: Partial<GQL.SetupInput>) => void; + goBack: () => void; +} - const discordLink = ( - <ExternalLink href="https://discord.gg/2TsNFKt">Discord</ExternalLink> - ); - const githubLink = ( - <ExternalLink href="https://github.com/stashapp/stash/issues"> - <FormattedMessage id="setup.github_repository" /> - </ExternalLink> - ); +const WelcomeSpecificConfig: React.FC<IWizardStep> = ({ next }) => { + const { systemStatus } = useSetupContext(); + const status = systemStatus?.systemStatus; + const overrideConfig = status?.configPath; - function onConfigLocationChosen(inWorkDir: boolean) { - setSetupInWorkDir(inWorkDir); - next(); + function onNext() { + next({ configLocation: overrideConfig! }); } - function goBack(n?: number) { - let dec = n; - if (!dec) { - dec = 1; - } - setStep(Math.max(0, step - dec)); - } + return ( + <> + <section> + <h2 className="mb-5"> + <FormattedMessage id="setup.welcome_to_stash" /> + </h2> + <p className="lead text-center"> + <FormattedMessage id="setup.welcome_specific_config.unable_to_locate_specified_config" /> + </p> + <p> + <FormattedMessage + id="setup.welcome_specific_config.config_path" + values={{ + path: overrideConfig, + code: (chunks: string) => <code>{chunks}</code>, + }} + /> + </p> + <p> + <FormattedMessage id="setup.welcome_specific_config.next_step" /> + </p> + </section> + + <section className="mt-5"> + <div className="d-flex justify-content-center"> + <Button variant="primary mx-2 p-5" onClick={() => onNext()}> + <FormattedMessage id="actions.next_action" /> + </Button> + </div> + </section> + </> + ); +}; - function next() { - setStep(step + 1); - } +const DefaultWelcomeStep: React.FC<IWizardStep> = ({ next }) => { + const { pathJoin, homeDir, macApp, homeDirPath, pwd, workingDir } = + useSetupContext(); - function confirmPaths() { - if (stashes.length > 0) { - next(); - return; - } + const fallbackStashDir = pathJoin(homeDir, ".stash"); + const fallbackConfigPath = pathJoin(fallbackStashDir, "config.yml"); - setShowStashAlert(true); + function onConfigLocationChosen(inWorkingDir: boolean) { + const configLocation = inWorkingDir ? "config.yml" : ""; + next({ configLocation }); } - function maybeRenderStashAlert() { - if (!showStashAlert) { - return; - } - - return ( - <ModalComponent - show - icon={faExclamationTriangle} - accept={{ - text: intl.formatMessage({ id: "actions.confirm" }), - variant: "danger", - onClick: () => { - setShowStashAlert(false); - next(); - }, - }} - cancel={{ onClick: () => setShowStashAlert(false) }} - > + return ( + <> + <section> + <h2 className="mb-5"> + <FormattedMessage id="setup.welcome_to_stash" /> + </h2> + <p className="lead text-center"> + <FormattedMessage id="setup.welcome.unable_to_locate_config" /> + </p> <p> - <FormattedMessage id="setup.paths.stash_alert" /> + <FormattedMessage + id="setup.welcome.config_path_logic_explained" + values={{ + code: (chunks: string) => <code>{chunks}</code>, + fallback_path: fallbackConfigPath, + }} + /> </p> - </ModalComponent> - ); - } - - const WelcomeSpecificConfig = () => { - return ( - <> - <section> - <h2 className="mb-5"> - <FormattedMessage id="setup.welcome_to_stash" /> - </h2> - <p className="lead text-center"> - <FormattedMessage id="setup.welcome_specific_config.unable_to_locate_specified_config" /> - </p> - <p> - <FormattedMessage - id="setup.welcome_specific_config.config_path" - values={{ - path: overrideConfig, - code: (chunks: string) => <code>{chunks}</code>, - }} - /> - </p> - <p> - <FormattedMessage id="setup.welcome_specific_config.next_step" /> - </p> - </section> - - <section className="mt-5"> - <div className="d-flex justify-content-center"> - <Button variant="primary mx-2 p-5" onClick={() => next()}> - <FormattedMessage id="actions.next_action" /> - </Button> - </div> - </section> - </> - ); - }; + <Alert variant="info text-center"> + <FormattedMessage + id="setup.welcome.unexpected_explained" + values={{ + code: (chunks: string) => <code>{chunks}</code>, + }} + /> + </Alert> + <p> + <FormattedMessage id="setup.welcome.next_step" /> + </p> + </section> - function DefaultWelcomeStep() { - const homeDirPath = pathJoin(status?.homeDir ?? homeDir, ".stash"); + <section className="mt-5"> + <h3 className="text-center mb-5"> + <FormattedMessage id="setup.welcome.store_stash_config" /> + </h3> - return ( - <> - <section> - <h2 className="mb-5"> - <FormattedMessage id="setup.welcome_to_stash" /> - </h2> - <p className="lead text-center"> - <FormattedMessage id="setup.welcome.unable_to_locate_config" /> - </p> - <p> - <FormattedMessage - id="setup.welcome.config_path_logic_explained" - values={{ - code: (chunks: string) => <code>{chunks}</code>, - fallback_path: fallbackConfigPath, - }} - /> - </p> - <Alert variant="info text-center"> + <div className="d-flex justify-content-center"> + <Button + variant="secondary mx-2 p-5" + onClick={() => onConfigLocationChosen(false)} + > <FormattedMessage - id="setup.welcome.unexpected_explained" + id="setup.welcome.in_current_stash_directory" values={{ code: (chunks: string) => <code>{chunks}</code>, + path: fallbackStashDir, }} /> - </Alert> - <p> - <FormattedMessage id="setup.welcome.next_step" /> - </p> - </section> - - <section className="mt-5"> - <h3 className="text-center mb-5"> - <FormattedMessage id="setup.welcome.store_stash_config" /> - </h3> - - <div className="d-flex justify-content-center"> - <Button - variant="secondary mx-2 p-5" - onClick={() => onConfigLocationChosen(false)} - > - <FormattedMessage - id="setup.welcome.in_current_stash_directory" - values={{ - code: (chunks: string) => <code>{chunks}</code>, - path: fallbackStashDir, - }} - /> - <br /> - <code>{homeDirPath}</code> - </Button> - <Button - variant="secondary mx-2 p-5" - onClick={() => onConfigLocationChosen(true)} - disabled={macApp} - > - {macApp ? ( - <> - <FormattedMessage - id="setup.welcome.in_the_current_working_directory_disabled" - values={{ - code: (chunks: string) => <code>{chunks}</code>, - path: pwd, - }} - /> - <br /> - <b> - <FormattedMessage - id="setup.welcome.in_the_current_working_directory_disabled_macos" - values={{ - code: (chunks: string) => <code>{chunks}</code>, - br: () => <br />, - }} - /> - </b> - </> - ) : ( - <> + <br /> + <code>{homeDirPath}</code> + </Button> + <Button + variant="secondary mx-2 p-5" + onClick={() => onConfigLocationChosen(true)} + disabled={macApp} + > + {macApp ? ( + <> + <FormattedMessage + id="setup.welcome.in_the_current_working_directory_disabled" + values={{ + code: (chunks: string) => <code>{chunks}</code>, + path: pwd, + }} + /> + <br /> + <b> <FormattedMessage - id="setup.welcome.in_the_current_working_directory" + id="setup.welcome.in_the_current_working_directory_disabled_macos" values={{ code: (chunks: string) => <code>{chunks}</code>, - path: pwd, + br: () => <br />, }} /> - <br /> - <code>{workingDir}</code> - </> - )} - </Button> - </div> - </section> - </> - ); - } + </b> + </> + ) : ( + <> + <FormattedMessage + id="setup.welcome.in_the_current_working_directory" + values={{ + code: (chunks: string) => <code>{chunks}</code>, + path: pwd, + }} + /> + <br /> + <code>{workingDir}</code> + </> + )} + </Button> + </div> + </section> + </> + ); +}; - function onGeneratedSelectClosed(d?: string) { - if (d) { - setGeneratedLocation(d); - } +const WelcomeStep: React.FC<IWizardStep> = (props) => { + const { systemStatus } = useSetupContext(); + const status = systemStatus?.systemStatus; + const overrideConfig = status?.configPath; - setShowGeneratedSelectDialog(false); - } + return overrideConfig ? ( + <WelcomeSpecificConfig {...props} /> + ) : ( + <DefaultWelcomeStep {...props} /> + ); +}; - function maybeRenderGeneratedSelectDialog() { - if (!showGeneratedSelectDialog) { - return; - } +const StashAlert: React.FC<{ close: (confirm: boolean) => void }> = ({ + close, +}) => { + const intl = useIntl(); - return <FolderSelectDialog onClose={onGeneratedSelectClosed} />; - } + return ( + <ModalComponent + show + icon={faExclamationTriangle} + accept={{ + text: intl.formatMessage({ id: "actions.confirm" }), + variant: "danger", + onClick: () => close(true), + }} + cancel={{ onClick: () => close(false) }} + > + <p> + <FormattedMessage id="setup.paths.stash_alert" /> + </p> + </ModalComponent> + ); +}; - function onBlobsClosed(d?: string) { - if (d) { - setBlobsLocation(d); - } +const DatabaseSection: React.FC<{ + databaseFile: string; + setDatabaseFile: React.Dispatch<React.SetStateAction<string>>; +}> = ({ databaseFile, setDatabaseFile }) => { + const intl = useIntl(); - setShowBlobsDialog(false); - } + return ( + <Form.Group id="database"> + <h3> + <FormattedMessage id="setup.paths.where_can_stash_store_its_database" /> + </h3> + <p> + <FormattedMessage + id="setup.paths.where_can_stash_store_its_database_description" + values={{ + code: (chunks: string) => <code>{chunks}</code>, + }} + /> + <br /> + <FormattedMessage + id="setup.paths.where_can_stash_store_its_database_warning" + values={{ + strong: (chunks: string) => <strong>{chunks}</strong>, + }} + /> + </p> + <Form.Control + className="text-input" + defaultValue={databaseFile} + placeholder={intl.formatMessage({ + id: "setup.paths.database_filename_empty_for_default", + })} + onChange={(e) => setDatabaseFile(e.currentTarget.value)} + /> + </Form.Group> + ); +}; - function maybeRenderBlobsSelectDialog() { - if (!showBlobsDialog) { - return; +const DirectorySelector: React.FC<{ + value: string; + setValue: React.Dispatch<React.SetStateAction<string>>; + placeholder: string; + disabled?: boolean; +}> = ({ value, setValue, placeholder, disabled = false }) => { + const [showSelectDialog, setShowSelectDialog] = useState(false); + + function onSelectClosed(dir?: string) { + if (dir) { + setValue(dir); } - - return <FolderSelectDialog onClose={onBlobsClosed} />; + setShowSelectDialog(false); } - function maybeRenderDatabase() { - if (overrideDatabase) return; - - return ( - <Form.Group id="database"> - <h3> - <FormattedMessage id="setup.paths.where_can_stash_store_its_database" /> - </h3> - <p> - <FormattedMessage - id="setup.paths.where_can_stash_store_its_database_description" - values={{ - code: (chunks: string) => <code>{chunks}</code>, - }} - /> - <br /> - <FormattedMessage - id="setup.paths.where_can_stash_store_its_database_warning" - values={{ - strong: (chunks: string) => <strong>{chunks}</strong>, - }} - /> - </p> + return ( + <> + {showSelectDialog ? ( + <FolderSelectDialog onClose={onSelectClosed} /> + ) : null} + <InputGroup> <Form.Control className="text-input" - defaultValue={databaseFile} + value={disabled ? "" : value} + placeholder={placeholder} + onChange={(e) => setValue(e.currentTarget.value)} + disabled={disabled} + /> + <InputGroup.Append> + <Button + variant="secondary" + className="text-input" + onClick={() => setShowSelectDialog(true)} + disabled={disabled} + > + <Icon icon={faEllipsisH} /> + </Button> + </InputGroup.Append> + </InputGroup> + </> + ); +}; + +const GeneratedSection: React.FC<{ + generatedLocation: string; + setGeneratedLocation: React.Dispatch<React.SetStateAction<string>>; +}> = ({ generatedLocation, setGeneratedLocation }) => { + const intl = useIntl(); + + return ( + <Form.Group id="generated"> + <h3> + <FormattedMessage id="setup.paths.where_can_stash_store_its_generated_content" /> + </h3> + <p> + <FormattedMessage + id="setup.paths.where_can_stash_store_its_generated_content_description" + values={{ + code: (chunks: string) => <code>{chunks}</code>, + }} + /> + </p> + <DirectorySelector + value={generatedLocation} + setValue={setGeneratedLocation} + placeholder={intl.formatMessage({ + id: "setup.paths.path_to_generated_directory_empty_for_default", + })} + /> + </Form.Group> + ); +}; + +const CacheSection: React.FC<{ + cacheLocation: string; + setCacheLocation: React.Dispatch<React.SetStateAction<string>>; +}> = ({ cacheLocation, setCacheLocation }) => { + const intl = useIntl(); + + return ( + <Form.Group id="cache"> + <h3> + <FormattedMessage id="setup.paths.where_can_stash_store_cache_files" /> + </h3> + <p> + <FormattedMessage + id="setup.paths.where_can_stash_store_cache_files_description" + values={{ + code: (chunks: string) => <code>{chunks}</code>, + }} + /> + </p> + <DirectorySelector + value={cacheLocation} + setValue={setCacheLocation} + placeholder={intl.formatMessage({ + id: "setup.paths.path_to_cache_directory_empty_for_default", + })} + /> + </Form.Group> + ); +}; + +const BlobsSection: React.FC<{ + blobsLocation: string; + setBlobsLocation: React.Dispatch<React.SetStateAction<string>>; + storeBlobsInDatabase: boolean; + setStoreBlobsInDatabase: React.Dispatch<React.SetStateAction<boolean>>; +}> = ({ + blobsLocation, + setBlobsLocation, + storeBlobsInDatabase, + setStoreBlobsInDatabase, +}) => { + const intl = useIntl(); + + return ( + <Form.Group id="blobs"> + <h3> + <FormattedMessage id="setup.paths.where_can_stash_store_blobs" /> + </h3> + <p> + <FormattedMessage + id="setup.paths.where_can_stash_store_blobs_description" + values={{ + code: (chunks: string) => <code>{chunks}</code>, + }} + /> + </p> + <p> + <FormattedMessage + id="setup.paths.where_can_stash_store_blobs_description_addendum" + values={{ + code: (chunks: string) => <code>{chunks}</code>, + strong: (chunks: string) => <strong>{chunks}</strong>, + }} + /> + </p> + + <div> + <Form.Check + id="store-blobs-in-database" + checked={storeBlobsInDatabase} + label={intl.formatMessage({ + id: "setup.paths.store_blobs_in_database", + })} + onChange={() => setStoreBlobsInDatabase(!storeBlobsInDatabase)} + /> + </div> + + <div> + <DirectorySelector + value={blobsLocation} + setValue={setBlobsLocation} placeholder={intl.formatMessage({ - id: "setup.paths.database_filename_empty_for_default", + id: "setup.paths.path_to_blobs_directory_empty_for_default", })} - onChange={(e) => setDatabaseFile(e.currentTarget.value)} + disabled={storeBlobsInDatabase} /> - </Form.Group> - ); + </div> + </Form.Group> + ); +}; + +const SetPathsStep: React.FC<IWizardStep> = ({ goBack, next }) => { + const { configuration } = useSetupContext(); + + const [showStashAlert, setShowStashAlert] = useState(false); + + const [stashes, setStashes] = useState<GQL.StashConfig[]>([]); + const [databaseFile, setDatabaseFile] = useState(""); + const [generatedLocation, setGeneratedLocation] = useState(""); + const [cacheLocation, setCacheLocation] = useState(""); + const [storeBlobsInDatabase, setStoreBlobsInDatabase] = useState(false); + const [blobsLocation, setBlobsLocation] = useState(""); + + const overrideDatabase = configuration?.general.databasePath; + const overrideGenerated = configuration?.general.generatedPath; + const overrideCache = configuration?.general.cachePath; + const overrideBlobs = configuration?.general.blobsPath; + + function preNext() { + if (stashes.length === 0) { + setShowStashAlert(true); + } else { + onNext(); + } } - function maybeRenderGenerated() { - if (overrideGenerated) return; + function onNext() { + const input: Partial<GQL.SetupInput> = { + stashes, + databaseFile, + generatedLocation, + cacheLocation, + blobsLocation: storeBlobsInDatabase ? "" : blobsLocation, + storeBlobsInDatabase, + }; + next(input); + } - return ( - <Form.Group id="generated"> - <h3> - <FormattedMessage id="setup.paths.where_can_stash_store_its_generated_content" /> - </h3> + return ( + <> + {showStashAlert ? ( + <StashAlert + close={(confirm) => { + setShowStashAlert(false); + if (confirm) { + onNext(); + } + }} + /> + ) : null} + <section> + <h2 className="mb-3"> + <FormattedMessage id="setup.paths.set_up_your_paths" /> + </h2> <p> - <FormattedMessage - id="setup.paths.where_can_stash_store_its_generated_content_description" - values={{ - code: (chunks: string) => <code>{chunks}</code>, - }} - /> + <FormattedMessage id="setup.paths.description" /> </p> - <InputGroup> - <Form.Control - className="text-input" - value={generatedLocation} - placeholder={intl.formatMessage({ - id: "setup.paths.path_to_generated_directory_empty_for_default", - })} - onChange={(e) => setGeneratedLocation(e.currentTarget.value)} + </section> + <section> + <Form.Group id="stashes"> + <h3> + <FormattedMessage id="setup.paths.where_is_your_porn_located" /> + </h3> + <p> + <FormattedMessage id="setup.paths.where_is_your_porn_located_description" /> + </p> + <Card> + <StashConfiguration + stashes={stashes} + setStashes={(s) => setStashes(s)} + /> + </Card> + </Form.Group> + {overrideDatabase ? null : ( + <DatabaseSection + databaseFile={databaseFile} + setDatabaseFile={setDatabaseFile} /> - <InputGroup.Append> - <Button - variant="secondary" - className="text-input" - onClick={() => setShowGeneratedSelectDialog(true)} - > - <Icon icon={faEllipsisH} /> - </Button> - </InputGroup.Append> - </InputGroup> - </Form.Group> - ); - } + )} + {overrideGenerated ? null : ( + <GeneratedSection + generatedLocation={generatedLocation} + setGeneratedLocation={setGeneratedLocation} + /> + )} + {overrideCache ? null : ( + <CacheSection + cacheLocation={cacheLocation} + setCacheLocation={setCacheLocation} + /> + )} + {overrideBlobs ? null : ( + <BlobsSection + blobsLocation={blobsLocation} + setBlobsLocation={setBlobsLocation} + storeBlobsInDatabase={storeBlobsInDatabase} + setStoreBlobsInDatabase={setStoreBlobsInDatabase} + /> + )} + </section> + <section className="mt-5"> + <div className="d-flex justify-content-center"> + <Button variant="secondary mx-2 p-5" onClick={() => goBack()}> + <FormattedMessage id="actions.previous_action" /> + </Button> + <Button variant="primary mx-2 p-5" onClick={() => preNext()}> + <FormattedMessage id="actions.next_action" /> + </Button> + </div> + </section> + </> + ); +}; - function onCacheSelectClosed(d?: string) { - if (d) { - setCacheLocation(d); - } +const StashExclusions: React.FC<{ stash: GQL.StashConfig }> = ({ stash }) => { + if (!stash.excludeImage && !stash.excludeVideo) { + return null; + } - setShowCacheSelectDialog(false); + const excludes = []; + if (stash.excludeVideo) { + excludes.push("videos"); + } + if (stash.excludeImage) { + excludes.push("images"); } - function maybeRenderCacheSelectDialog() { - if (!showCacheSelectDialog) { - return; - } + return <span>{`(excludes ${excludes.join(" and ")})`}</span>; +}; + +const ConfirmStep: React.FC<IWizardStep> = ({ goBack, next }) => { + const { configuration, pathDir, pathJoin, pwd, setupState } = + useSetupContext(); + + const cfgFile = setupState.configLocation + ? setupState.configLocation + : pathJoin(pwd, "config.yml"); + const cfgDir = pathDir(cfgFile); + const stashes = setupState.stashes ?? []; + const { + databaseFile, + generatedLocation, + cacheLocation, + blobsLocation, + storeBlobsInDatabase, + } = setupState; - return <FolderSelectDialog onClose={onCacheSelectClosed} />; + const overrideDatabase = configuration?.general.databasePath; + const overrideGenerated = configuration?.general.generatedPath; + const overrideCache = configuration?.general.cachePath; + const overrideBlobs = configuration?.general.blobsPath; + + function joinCfgDir(path: string) { + if (cfgDir) { + return pathJoin(cfgDir, path); + } else { + return path; + } } - function maybeRenderCache() { - if (overrideCache) return; + return ( + <> + <section> + <h2 className="mb-3"> + <FormattedMessage id="setup.confirm.nearly_there" /> + </h2> + <p> + <FormattedMessage id="setup.confirm.almost_ready" /> + </p> + <dl> + <dt> + <FormattedMessage id="setup.confirm.configuration_file_location" /> + </dt> + <dd> + <code>{cfgFile}</code> + </dd> + </dl> + <dl> + <dt> + <FormattedMessage id="setup.confirm.stash_library_directories" /> + </dt> + <dd> + <ul> + {stashes.map((s) => ( + <li key={s.path}> + <code>{s.path} </code> + <StashExclusions stash={s} /> + </li> + ))} + </ul> + </dd> + </dl> + {!overrideDatabase && ( + <dl> + <dt> + <FormattedMessage id="setup.confirm.database_file_path" /> + </dt> + <dd> + <code>{databaseFile || joinCfgDir("stash-go.sqlite")}</code> + </dd> + </dl> + )} + {!overrideGenerated && ( + <dl> + <dt> + <FormattedMessage id="setup.confirm.generated_directory" /> + </dt> + <dd> + <code>{generatedLocation || joinCfgDir("generated")}</code> + </dd> + </dl> + )} + {!overrideCache && ( + <dl> + <dt> + <FormattedMessage id="setup.confirm.cache_directory" /> + </dt> + <dd> + <code>{cacheLocation || joinCfgDir("cache")}</code> + </dd> + </dl> + )} + {!overrideBlobs && ( + <dl> + <dt> + <FormattedMessage id="setup.confirm.blobs_directory" /> + </dt> + <dd> + <code> + {storeBlobsInDatabase ? ( + <FormattedMessage id="setup.confirm.blobs_use_database" /> + ) : ( + blobsLocation || joinCfgDir("blobs") + )} + </code> + </dd> + </dl> + )} + </section> + <section className="mt-5"> + <div className="d-flex justify-content-center"> + <Button variant="secondary mx-2 p-5" onClick={() => goBack()}> + <FormattedMessage id="actions.previous_action" /> + </Button> + <Button variant="success mx-2 p-5" onClick={() => next()}> + <FormattedMessage id="actions.confirm" /> + </Button> + </div> + </section> + </> + ); +}; - return ( - <Form.Group id="cache"> - <h3> - <FormattedMessage id="setup.paths.where_can_stash_store_cache_files" /> - </h3> +const DiscordLink = ( + <ExternalLink href="https://discord.gg/2TsNFKt">Discord</ExternalLink> +); +const GithubLink = ( + <ExternalLink href="https://github.com/stashapp/stash/issues"> + <FormattedMessage id="setup.github_repository" /> + </ExternalLink> +); + +const ErrorStep: React.FC<{ error: string; goBack: () => void }> = ({ + error, + goBack, +}) => { + return ( + <> + <section> + <h2> + <FormattedMessage id="setup.errors.something_went_wrong" /> + </h2> <p> <FormattedMessage - id="setup.paths.where_can_stash_store_cache_files_description" - values={{ - code: (chunks: string) => <code>{chunks}</code>, - }} + id="setup.errors.something_went_wrong_while_setting_up_your_system" + values={{ error: <pre>{error}</pre> }} /> </p> - <InputGroup> - <Form.Control - className="text-input" - value={cacheLocation} - placeholder={intl.formatMessage({ - id: "setup.paths.path_to_cache_directory_empty_for_default", - })} - onChange={(e) => setCacheLocation(e.currentTarget.value)} + <p> + <FormattedMessage + id="setup.errors.something_went_wrong_description" + values={{ GithubLink, DiscordLink }} /> - <InputGroup.Append> - <Button - variant="secondary" - className="text-input" - onClick={() => setShowCacheSelectDialog(true)} - > - <Icon icon={faEllipsisH} /> - </Button> - </InputGroup.Append> - </InputGroup> - </Form.Group> - ); - } + </p> + </section> + <section className="mt-5"> + <div className="d-flex justify-content-center"> + <Button variant="secondary mx-2 p-5" onClick={goBack}> + <FormattedMessage id="actions.previous_action" /> + </Button> + </div> + </section> + </> + ); +}; - function maybeRenderBlobs() { - if (overrideBlobs) return; +const SuccessStep: React.FC<{}> = () => { + const intl = useIntl(); + const history = useHistory(); - return ( - <Form.Group id="blobs"> - <h3> - <FormattedMessage id="setup.paths.where_can_stash_store_blobs" /> - </h3> + const [mutateDownloadFFMpeg] = GQL.useDownloadFfMpegMutation(); + + const [downloadFFmpeg, setDownloadFFmpeg] = useState(true); + + const { systemStatus } = useSetupContext(); + const status = systemStatus?.systemStatus; + + function onFinishClick() { + if ((!status?.ffmpegPath || !status?.ffprobePath) && downloadFFmpeg) { + mutateDownloadFFMpeg(); + } + + history.push("/settings?tab=library"); + } + + return ( + <> + <section> + <h2> + <FormattedMessage id="setup.success.your_system_has_been_created" /> + </h2> + <p> + <FormattedMessage id="setup.success.next_config_step_one" /> + </p> <p> <FormattedMessage - id="setup.paths.where_can_stash_store_blobs_description" + id="setup.success.next_config_step_two" values={{ code: (chunks: string) => <code>{chunks}</code>, + localized_task: intl.formatMessage({ + id: "config.categories.tasks", + }), + localized_scan: intl.formatMessage({ id: "actions.scan" }), }} /> </p> + {!status?.ffmpegPath || !status?.ffprobePath ? ( + <> + <Alert variant="warning text-center"> + <FormattedMessage + id="setup.success.missing_ffmpeg" + values={{ + code: (chunks: string) => <code>{chunks}</code>, + }} + /> + </Alert> + <p> + <Form.Check + id="download-ffmpeg" + checked={downloadFFmpeg} + label={intl.formatMessage({ + id: "setup.success.download_ffmpeg", + })} + onChange={() => setDownloadFFmpeg(!downloadFFmpeg)} + /> + </p> + </> + ) : null} + </section> + <section> + <h3> + <FormattedMessage id="setup.success.getting_help" /> + </h3> + <p> + <FormattedMessage + id="setup.success.in_app_manual_explained" + values={{ icon: <Icon icon={faQuestionCircle} /> }} + /> + </p> <p> <FormattedMessage - id="setup.paths.where_can_stash_store_blobs_description_addendum" + id="setup.success.help_links" + values={{ DiscordLink, GithubLink }} + /> + </p> + </section> + <section> + <h3> + <FormattedMessage id="setup.success.support_us" /> + </h3> + <p> + <FormattedMessage + id="setup.success.open_collective" values={{ - code: (chunks: string) => <code>{chunks}</code>, - strong: (chunks: string) => <strong>{chunks}</strong>, + open_collective_link: ( + <ExternalLink href="https://opencollective.com/stashapp"> + Open Collective + </ExternalLink> + ), }} /> </p> - <p> - <Form.Check - id="store-blobs-in-database" - checked={storeBlobsInDatabase} - label={intl.formatMessage({ - id: "setup.paths.store_blobs_in_database", - })} - onChange={() => setStoreBlobsInDatabase(!storeBlobsInDatabase)} - /> + <FormattedMessage id="setup.success.welcome_contrib" /> </p> + </section> + <section> + <p className="lead text-center"> + <FormattedMessage id="setup.success.thanks_for_trying_stash" /> + </p> + </section> + <section className="mt-5"> + <div className="d-flex justify-content-center"> + <Button variant="success mx-2 p-5" onClick={() => onFinishClick()}> + <FormattedMessage id="actions.finish" /> + </Button> + </div> + </section> + </> + ); +}; - {!storeBlobsInDatabase && ( - <InputGroup> - <Form.Control - className="text-input" - value={blobsLocation} - placeholder={intl.formatMessage({ - id: "setup.paths.path_to_blobs_directory_empty_for_default", - })} - onChange={(e) => setBlobsLocation(e.currentTarget.value)} - disabled={storeBlobsInDatabase} - /> - <InputGroup.Append> - <Button - variant="secondary" - className="text-input" - onClick={() => setShowBlobsDialog(true)} - disabled={storeBlobsInDatabase} - > - <Icon icon={faEllipsisH} /> - </Button> - </InputGroup.Append> - </InputGroup> - )} - </Form.Group> - ); - } +const FinishStep: React.FC<IWizardStep> = ({ goBack }) => { + const { setupError } = useSetupContext(); - function SetPathsStep() { - return ( - <> - {maybeRenderStashAlert()} - <section> - <h2 className="mb-3"> - <FormattedMessage id="setup.paths.set_up_your_paths" /> - </h2> - <p> - <FormattedMessage id="setup.paths.description" /> - </p> - </section> - <section> - <Form.Group id="stashes"> - <h3> - <FormattedMessage id="setup.paths.where_is_your_porn_located" /> - </h3> - <p> - <FormattedMessage id="setup.paths.where_is_your_porn_located_description" /> - </p> - <Card> - <StashConfiguration - stashes={stashes} - setStashes={(s) => setStashes(s)} - /> - </Card> - </Form.Group> - {maybeRenderDatabase()} - {maybeRenderGenerated()} - {maybeRenderCache()} - {maybeRenderBlobs()} - </section> - <section className="mt-5"> - <div className="d-flex justify-content-center"> - <Button variant="secondary mx-2 p-5" onClick={() => goBack()}> - <FormattedMessage id="actions.previous_action" /> - </Button> - <Button variant="primary mx-2 p-5" onClick={() => confirmPaths()}> - <FormattedMessage id="actions.next_action" /> - </Button> - </div> - </section> - </> - ); + if (setupError !== undefined) { + return <ErrorStep error={setupError} goBack={goBack} />; } - function maybeRenderExclusions(s: GQL.StashConfig) { - if (!s.excludeImage && !s.excludeVideo) { - return; - } + return <SuccessStep />; +}; - const excludes = []; - if (s.excludeVideo) { - excludes.push("videos"); - } - if (s.excludeImage) { - excludes.push("images"); - } +export const Setup: React.FC = () => { + const intl = useIntl(); + const { configuration, loading: configLoading } = + useContext(ConfigurationContext); - return `(excludes ${excludes.join(" and ")})`; - } + const [saveUI] = useConfigureUI(); - async function onSave() { - let configLocation = overrideConfig; - if (!configLocation) { - configLocation = setupInWorkDir ? "config.yml" : ""; - } + const { + data: systemStatus, + loading: statusLoading, + error: statusError, + } = useSystemStatus(); + + const [step, setStep] = useState(0); + const [setupInput, setSetupInput] = useState<Partial<GQL.SetupInput>>({}); + const [creating, setCreating] = useState(false); + const [setupError, setSetupError] = useState<string | undefined>(undefined); + + const history = useHistory(); + const steps: React.FC<IWizardStep>[] = [ + WelcomeStep, + SetPathsStep, + ConfirmStep, + FinishStep, + ]; + const Step = steps[step]; + + async function createSystem() { try { - setLoading(true); - await mutateSetup({ - configLocation, - databaseFile, - generatedLocation, - cacheLocation, - storeBlobsInDatabase, - blobsLocation, - stashes, - }); + setCreating(true); + setSetupError(undefined); + await mutateSetup(setupInput as GQL.SetupInput); // Set lastNoteSeen to hide release notes dialog await saveUI({ variables: { @@ -621,318 +980,95 @@ export const Setup: React.FC = () => { setSetupError(String(e)); } } finally { - setLoading(false); - next(); - } - } - - function ConfirmStep() { - let cfgDir: string; - let config: string; - if (overrideConfig) { - cfgDir = pathDir(overrideConfig); - config = overrideConfig; - } else { - cfgDir = setupInWorkDir ? pwd : fallbackStashDir; - config = pathJoin(cfgDir, "config.yml"); + setCreating(false); + setStep(step + 1); } - - function joinCfgDir(path: string) { - if (cfgDir) { - return pathJoin(cfgDir, path); - } else { - return path; - } - } - - return ( - <> - <section> - <h2 className="mb-3"> - <FormattedMessage id="setup.confirm.nearly_there" /> - </h2> - <p> - <FormattedMessage id="setup.confirm.almost_ready" /> - </p> - <dl> - <dt> - <FormattedMessage id="setup.confirm.configuration_file_location" /> - </dt> - <dd> - <code>{config}</code> - </dd> - </dl> - <dl> - <dt> - <FormattedMessage id="setup.confirm.stash_library_directories" /> - </dt> - <dd> - <ul> - {stashes.map((s) => ( - <li key={s.path}> - <code>{s.path} </code> - {maybeRenderExclusions(s)} - </li> - ))} - </ul> - </dd> - </dl> - {!overrideDatabase && ( - <dl> - <dt> - <FormattedMessage id="setup.confirm.database_file_path" /> - </dt> - <dd> - <code>{databaseFile || joinCfgDir("stash-go.sqlite")}</code> - </dd> - </dl> - )} - {!overrideGenerated && ( - <dl> - <dt> - <FormattedMessage id="setup.confirm.generated_directory" /> - </dt> - <dd> - <code>{generatedLocation || joinCfgDir("generated")}</code> - </dd> - </dl> - )} - {!overrideCache && ( - <dl> - <dt> - <FormattedMessage id="setup.confirm.cache_directory" /> - </dt> - <dd> - <code>{cacheLocation || joinCfgDir("cache")}</code> - </dd> - </dl> - )} - {!overrideBlobs && ( - <dl> - <dt> - <FormattedMessage id="setup.confirm.blobs_directory" /> - </dt> - <dd> - <code> - {storeBlobsInDatabase ? ( - <FormattedMessage id="setup.confirm.blobs_use_database" /> - ) : ( - blobsLocation || joinCfgDir("blobs") - )} - </code> - </dd> - </dl> - )} - </section> - <section className="mt-5"> - <div className="d-flex justify-content-center"> - <Button variant="secondary mx-2 p-5" onClick={() => goBack()}> - <FormattedMessage id="actions.previous_action" /> - </Button> - <Button variant="success mx-2 p-5" onClick={() => onSave()}> - <FormattedMessage id="actions.confirm" /> - </Button> - </div> - </section> - </> - ); } - function ErrorStep() { - function onBackClick() { - setSetupError(undefined); - goBack(2); - } - - return ( - <> - <section> - <h2> - <FormattedMessage id="setup.errors.something_went_wrong" /> - </h2> - <p> - <FormattedMessage - id="setup.errors.something_went_wrong_while_setting_up_your_system" - values={{ error: <pre>{setupError}</pre> }} - /> - </p> - <p> - <FormattedMessage - id="setup.errors.something_went_wrong_description" - values={{ githubLink, discordLink }} - /> - </p> - </section> - <section className="mt-5"> - <div className="d-flex justify-content-center"> - <Button variant="secondary mx-2 p-5" onClick={onBackClick}> - <FormattedMessage id="actions.previous_action" /> - </Button> - </div> - </section> - </> - ); - } + function next(input?: Partial<GQL.SetupInput>) { + setSetupInput({ ...setupInput, ...input }); - function onFinishClick() { - if ((!status?.ffmpegPath || !status?.ffprobePath) && downloadFFmpeg) { - mutateDownloadFFMpeg(); + if (Step === ConfirmStep) { + // create the system + createSystem(); + } else { + setStep(step + 1); } - - history.push("/settings?tab=library"); } - function SuccessStep() { - return ( - <> - <section> - <h2> - <FormattedMessage id="setup.success.your_system_has_been_created" /> - </h2> - <p> - <FormattedMessage id="setup.success.next_config_step_one" /> - </p> - <p> - <FormattedMessage - id="setup.success.next_config_step_two" - values={{ - code: (chunks: string) => <code>{chunks}</code>, - localized_task: intl.formatMessage({ - id: "config.categories.tasks", - }), - localized_scan: intl.formatMessage({ id: "actions.scan" }), - }} - /> - </p> - {!status?.ffmpegPath || !status?.ffprobePath ? ( - <> - <Alert variant="warning text-center"> - <FormattedMessage - id="setup.success.missing_ffmpeg" - values={{ - code: (chunks: string) => <code>{chunks}</code>, - }} - /> - </Alert> - <p> - <Form.Check - id="download-ffmpeg" - checked={downloadFFmpeg} - label={intl.formatMessage({ - id: "setup.success.download_ffmpeg", - })} - onChange={() => setDownloadFFmpeg(!downloadFFmpeg)} - /> - </p> - </> - ) : null} - </section> - <section> - <h3> - <FormattedMessage id="setup.success.getting_help" /> - </h3> - <p> - <FormattedMessage - id="setup.success.in_app_manual_explained" - values={{ icon: <Icon icon={faQuestionCircle} /> }} - /> - </p> - <p> - <FormattedMessage - id="setup.success.help_links" - values={{ discordLink, githubLink }} - /> - </p> - </section> - <section> - <h3> - <FormattedMessage id="setup.success.support_us" /> - </h3> - <p> - <FormattedMessage - id="setup.success.open_collective" - values={{ - open_collective_link: ( - <ExternalLink href="https://opencollective.com/stashapp"> - Open Collective - </ExternalLink> - ), - }} - /> - </p> - <p> - <FormattedMessage id="setup.success.welcome_contrib" /> - </p> - </section> - <section> - <p className="lead text-center"> - <FormattedMessage id="setup.success.thanks_for_trying_stash" /> - </p> - </section> - <section className="mt-5"> - <div className="d-flex justify-content-center"> - <Button variant="success mx-2 p-5" onClick={() => onFinishClick()}> - <FormattedMessage id="actions.finish" /> - </Button> - </div> - </section> - </> - ); - } - - function FinishStep() { - if (setupError !== undefined) { - return <ErrorStep />; + function goBack() { + if (Step === FinishStep) { + // go back to the step before ConfirmStep + setStep(step - 2); + } else { + setStep(step - 1); } - - return <SuccessStep />; } - // only display setup wizard if system is not setup - if (statusLoading || configLoading) { + if (configLoading || statusLoading) { return <LoadingIndicator />; } - if (step === 0 && status && status.status !== GQL.SystemStatusEnum.Setup) { + if ( + step === 0 && + systemStatus && + systemStatus.systemStatus.status !== GQL.SystemStatusEnum.Setup + ) { // redirect to main page history.push("/"); return <LoadingIndicator />; } - const WelcomeStep = overrideConfig - ? WelcomeSpecificConfig - : DefaultWelcomeStep; - const steps = [WelcomeStep, SetPathsStep, ConfirmStep, FinishStep]; - const Step = steps[step]; + if (statusError) { + return ( + <Container> + <Alert variant="danger"> + <FormattedMessage + id="setup.errors.unable_to_retrieve_system_status" + values={{ error: statusError.message }} + /> + </Alert> + </Container> + ); + } - function renderCreating() { + if (!configuration || !systemStatus) { return ( - <Card> - <LoadingIndicator - message={intl.formatMessage({ - id: "setup.creating.creating_your_system", - })} - /> - </Card> + <Container> + <Alert variant="danger"> + <FormattedMessage + id="setup.errors.unable_to_retrieve_configuration" + values={{ error: "configuration or systemStatus === undefined" }} + /> + </Alert> + </Container> ); } return ( - <Container> - {maybeRenderGeneratedSelectDialog()} - {maybeRenderCacheSelectDialog()} - {maybeRenderBlobsSelectDialog()} - <h1 className="text-center"> - <FormattedMessage id="setup.stash_setup_wizard" /> - </h1> - {loading ? ( - renderCreating() - ) : ( + <SetupContext + setupState={setupInput} + setupError={setupError} + configuration={configuration} + systemStatus={systemStatus} + > + <Container className="setup-wizard"> + <h1 className="text-center"> + <FormattedMessage id="setup.stash_setup_wizard" /> + </h1> <Card> - <Step /> + {creating ? ( + <LoadingIndicator + message={intl.formatMessage({ + id: "setup.creating.creating_your_system", + })} + /> + ) : ( + <Step next={next} goBack={goBack} /> + )} </Card> - )} - </Container> + </Container> + </SetupContext> ); }; diff --git a/ui/v2.5/src/components/Setup/styles.scss b/ui/v2.5/src/components/Setup/styles.scss index 36db2798a57..0eceeb8e521 100644 --- a/ui/v2.5/src/components/Setup/styles.scss +++ b/ui/v2.5/src/components/Setup/styles.scss @@ -24,3 +24,10 @@ margin-left: 0.5rem; } } + +.setup-wizard { + #blobs > div { + margin-bottom: 1rem; + margin-top: 0; + } +} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index edad1c8e7cf..784579c95b8 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1298,7 +1298,9 @@ "errors": { "something_went_wrong": "Oh no! Something went wrong!", "something_went_wrong_description": "If this looks like a problem with your inputs, go ahead and click back to fix them up. Otherwise, raise a bug on the {githubLink} or seek help in the {discordLink}.", - "something_went_wrong_while_setting_up_your_system": "Something went wrong while setting up your system. Here is the error we received: {error}" + "something_went_wrong_while_setting_up_your_system": "Something went wrong while setting up your system. Here is the error we received: {error}", + "unable_to_retrieve_system_status": "Unable to retrieve system status: {error}", + "unexpected_error": "An unexpected error occurred: {error}" }, "folder": { "file_path": "File path", From 2a454e5a1e5e2a97d1f281a96734939d7d28c39e Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 7 Nov 2024 14:29:13 +1100 Subject: [PATCH 86/91] Fix scraped tag name being used in matched scraped tags field (#5462) --- .../Shared/ScrapeDialog/ScrapedObjectsRow.tsx | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx index 07865533866..b8a7be085ba 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx @@ -6,7 +6,7 @@ import { ObjectScrapeResult, ScrapeResult, } from "src/components/Shared/ScrapeDialog/scrapeResult"; -import { TagSelect } from "src/components/Tags/TagSelect"; +import { TagIDSelect } from "src/components/Tags/TagSelect"; import { StudioSelect } from "src/components/Studios/StudioSelect"; import { GroupSelect } from "src/components/Groups/GroupSelect"; @@ -269,17 +269,12 @@ export const ScrapedTagsRow: React.FC< : scrapeResult.originalValue; const value = resultValue ?? []; - const selectValue = value.map((p) => { - const aliases: string[] = []; - return { - id: p.stored_id ?? "", - name: p.name ?? "", - aliases, - }; - }); + const selectValue = value.map((p) => p.stored_id ?? ""); + // we need to use TagIDSelect here because we want to use the local name + // of the tag instead of the name from the source return ( - <TagSelect + <TagIDSelect isMulti className="form-control" isDisabled={!isNew} @@ -289,7 +284,7 @@ export const ScrapedTagsRow: React.FC< onChangeFn(items.map((p) => ({ ...p, stored_id: p.id }))); } }} - values={selectValue} + ids={selectValue} /> ); } From 602f95dd29cb89d59c54d3917c4b97ea2d8115bc Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 7 Nov 2024 14:29:26 +1100 Subject: [PATCH 87/91] Fix video files with identical phashes being merged during scan (#5461) * Change Fingerprints.Remove to return new instead of mutate current * Match only by oshash and md5 when merging scenes during scan --- pkg/file/scan.go | 3 ++- pkg/models/fingerprint.go | 21 ++++++++++++++++++--- pkg/scene/scan.go | 6 +++++- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/pkg/file/scan.go b/pkg/file/scan.go index 3cfc4c26b22..8b0ec956e0e 100644 --- a/pkg/file/scan.go +++ b/pkg/file/scan.go @@ -1100,7 +1100,8 @@ func (s *scanJob) removeOutdatedFingerprints(existing models.File, fp models.Fin // oshash has changed, MD5 is missing - remove MD5 from the existing fingerprints logger.Infof("Removing outdated checksum from %s", existing.Base().Path) - existing.Base().Fingerprints.Remove(models.FingerprintTypeMD5) + b := existing.Base() + b.Fingerprints = b.Fingerprints.Remove(models.FingerprintTypeMD5) } // returns a file only if it was updated diff --git a/pkg/models/fingerprint.go b/pkg/models/fingerprint.go index 0123f289d9f..338e99534ab 100644 --- a/pkg/models/fingerprint.go +++ b/pkg/models/fingerprint.go @@ -28,16 +28,31 @@ func (f *Fingerprint) Value() string { type Fingerprints []Fingerprint -func (f *Fingerprints) Remove(type_ string) { +func (f Fingerprints) Remove(type_ string) Fingerprints { var ret Fingerprints - for _, ff := range *f { + for _, ff := range f { if ff.Type != type_ { ret = append(ret, ff) } } - *f = ret + return ret +} + +func (f Fingerprints) Filter(types ...string) Fingerprints { + var ret Fingerprints + + for _, ff := range f { + for _, t := range types { + if ff.Type == t { + ret = append(ret, ff) + break + } + } + } + + return ret } // Equals returns true if the contents of this slice are equal to those in the other slice. diff --git a/pkg/scene/scan.go b/pkg/scene/scan.go index 5676f6d4f4d..e1038fbc36a 100644 --- a/pkg/scene/scan.go +++ b/pkg/scene/scan.go @@ -16,6 +16,10 @@ import ( var ( ErrNotVideoFile = errors.New("not a video file") + + // fingerprint types to match with + // only try to match by data fingerprints, _not_ perceptual fingerprints + matchableFingerprintTypes = []string{models.FingerprintTypeOshash, models.FingerprintTypeMD5} ) type ScanCreatorUpdater interface { @@ -87,7 +91,7 @@ func (h *ScanHandler) Handle(ctx context.Context, f models.File, oldFile models. if len(existing) == 0 { // try also to match file by fingerprints - existing, err = h.CreatorUpdater.FindByFingerprints(ctx, videoFile.Fingerprints) + existing, err = h.CreatorUpdater.FindByFingerprints(ctx, videoFile.Fingerprints.Filter(matchableFingerprintTypes...)) if err != nil { return fmt.Errorf("finding existing scene by fingerprints: %w", err) } From 41d1b45fb97c2e136111f400e4e8aa346955194c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Nov 2024 14:54:22 +1100 Subject: [PATCH 88/91] Bump github.com/golang-jwt/jwt/v4 from 4.5.0 to 4.5.1 (#5451) Bumps [github.com/golang-jwt/jwt/v4](https://github.com/golang-jwt/jwt) from 4.5.0 to 4.5.1. - [Release notes](https://github.com/golang-jwt/jwt/releases) - [Changelog](https://github.com/golang-jwt/jwt/blob/main/VERSION_HISTORY.md) - [Commits](https://github.com/golang-jwt/jwt/compare/v4.5.0...v4.5.1) --- updated-dependencies: - dependency-name: github.com/golang-jwt/jwt/v4 dependency-type: direct:production ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ea1dbc61808..d1bd409390a 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/go-chi/httplog v0.3.1 github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 github.com/gofrs/uuid/v5 v5.1.0 - github.com/golang-jwt/jwt/v4 v4.5.0 + github.com/golang-jwt/jwt/v4 v4.5.1 github.com/golang-migrate/migrate/v4 v4.16.2 github.com/gorilla/securecookie v1.1.1 github.com/gorilla/sessions v1.2.1 diff --git a/go.sum b/go.sum index 3af4fce7f61..15516f7ff98 100644 --- a/go.sum +++ b/go.sum @@ -246,8 +246,8 @@ github.com/gofrs/uuid/v5 v5.1.0 h1:S5rqVKIigghZTCBKPCw0Y+bXkn26K3TB5mvQq2Ix8dk= github.com/gofrs/uuid/v5 v5.1.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= -github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= +github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-migrate/migrate/v4 v4.16.2 h1:8coYbMKUyInrFk1lfGfRovTLAW7PhWp8qQDT2iKfuoA= github.com/golang-migrate/migrate/v4 v4.16.2/go.mod h1:pfcJX4nPHaVdc5nmdCikFBWtm+UBpiZjRNNsyBbp0/o= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= From a18c538c1f807e465b042bb8cd456475cc0eeeb0 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 12 Nov 2024 16:59:28 +1100 Subject: [PATCH 89/91] Maintain saved filters in full export/import (#5465) * Remove ellipsis from full export button --- internal/manager/json_utils.go | 4 + internal/manager/task_export.go | 61 +++++++++ internal/manager/task_import.go | 52 ++++++++ pkg/models/jsonschema/load.go | 31 +++++ pkg/models/jsonschema/saved_filter.go | 27 ++++ pkg/models/paths/paths_json.go | 22 ++-- pkg/savedfilter/export.go | 19 +++ pkg/savedfilter/export_test.go | 91 +++++++++++++ pkg/savedfilter/import.go | 60 +++++++++ pkg/savedfilter/import_test.go | 124 ++++++++++++++++++ .../Settings/Tasks/DataManagementTasks.tsx | 2 +- 11 files changed, 484 insertions(+), 9 deletions(-) create mode 100644 pkg/models/jsonschema/load.go create mode 100644 pkg/models/jsonschema/saved_filter.go create mode 100644 pkg/savedfilter/export.go create mode 100644 pkg/savedfilter/export_test.go create mode 100644 pkg/savedfilter/import.go create mode 100644 pkg/savedfilter/import_test.go diff --git a/internal/manager/json_utils.go b/internal/manager/json_utils.go index f1ce60404f1..483794624ba 100644 --- a/internal/manager/json_utils.go +++ b/internal/manager/json_utils.go @@ -42,3 +42,7 @@ func (jp *jsonUtils) saveGallery(fn string, gallery *jsonschema.Gallery) error { func (jp *jsonUtils) saveFile(fn string, file jsonschema.DirEntry) error { return jsonschema.SaveFileFile(filepath.Join(jp.json.Files, fn), file) } + +func (jp *jsonUtils) saveSavedFilter(fn string, savedFilter *jsonschema.SavedFilter) error { + return jsonschema.SaveSavedFilterFile(filepath.Join(jp.json.SavedFilters, fn), savedFilter) +} diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go index 19abba2158d..fe35c150d34 100644 --- a/internal/manager/task_export.go +++ b/internal/manager/task_export.go @@ -23,6 +23,7 @@ import ( "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/paths" "github.com/stashapp/stash/pkg/performer" + "github.com/stashapp/stash/pkg/savedfilter" "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/sliceutil/stringslice" @@ -176,6 +177,7 @@ func (t *ExportTask) Start(ctx context.Context, wg *sync.WaitGroup) { t.ExportPerformers(ctx, workerCount) t.ExportStudios(ctx, workerCount) t.ExportTags(ctx, workerCount) + t.ExportSavedFilters(ctx, workerCount) return nil }) @@ -1186,3 +1188,62 @@ func (t *ExportTask) exportGroup(ctx context.Context, wg *sync.WaitGroup, jobCha } } } + +func (t *ExportTask) ExportSavedFilters(ctx context.Context, workers int) { + // don't export saved filters unless we're doing a full export + if !t.full { + return + } + + var wg sync.WaitGroup + + reader := t.repository.SavedFilter + var filters []*models.SavedFilter + var err error + filters, err = reader.All(ctx) + + if err != nil { + logger.Errorf("[saved filters] failed to fetch saved filters: %v", err) + } + + logger.Info("[saved filters] exporting") + startTime := time.Now() + + jobCh := make(chan *models.SavedFilter, workers*2) // make a buffered channel to feed workers + + for w := 0; w < workers; w++ { // create export Saved Filter workers + wg.Add(1) + go t.exportSavedFilter(ctx, &wg, jobCh) + } + + for i, savedFilter := range filters { + index := i + 1 + logger.Progressf("[saved filters] %d of %d", index, len(filters)) + + jobCh <- savedFilter // feed workers + } + + close(jobCh) + wg.Wait() + + logger.Infof("[saved filters] export complete in %s. %d workers used.", time.Since(startTime), workers) +} + +func (t *ExportTask) exportSavedFilter(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models.SavedFilter) { + defer wg.Done() + + for thisFilter := range jobChan { + newJSON, err := savedfilter.ToJSON(ctx, thisFilter) + + if err != nil { + logger.Errorf("[saved filter] <%s> error getting saved filter JSON: %v", thisFilter.Name, err) + continue + } + + fn := newJSON.Filename() + + if err := t.json.saveSavedFilter(fn, newJSON); err != nil { + logger.Errorf("[saved filter] <%s> failed to save json: %v", fn, err) + } + } +} diff --git a/internal/manager/task_import.go b/internal/manager/task_import.go index 87185c66183..8863a768dc7 100644 --- a/internal/manager/task_import.go +++ b/internal/manager/task_import.go @@ -20,6 +20,7 @@ import ( "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/paths" "github.com/stashapp/stash/pkg/performer" + "github.com/stashapp/stash/pkg/savedfilter" "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/studio" "github.com/stashapp/stash/pkg/tag" @@ -124,6 +125,7 @@ func (t *ImportTask) Start(ctx context.Context) { } } + t.ImportSavedFilters(ctx) t.ImportTags(ctx) t.ImportPerformers(ctx) t.ImportStudios(ctx) @@ -779,3 +781,53 @@ func (t *ImportTask) ImportImages(ctx context.Context) { logger.Info("[images] import complete") } + +func (t *ImportTask) ImportSavedFilters(ctx context.Context) { + logger.Info("[saved filters] importing") + + path := t.json.json.SavedFilters + files, err := os.ReadDir(path) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + logger.Errorf("[saved filters] failed to read saved filters directory: %v", err) + } + + return + } + + r := t.repository + + for i, fi := range files { + index := i + 1 + savedFilterJSON, err := jsonschema.LoadSavedFilterFile(filepath.Join(path, fi.Name())) + if err != nil { + logger.Errorf("[saved filters] failed to read json: %v", err) + continue + } + + logger.Progressf("[saved filters] %d of %d", index, len(files)) + + if err := r.WithTxn(ctx, func(ctx context.Context) error { + return t.importSavedFilter(ctx, savedFilterJSON) + }); err != nil { + logger.Errorf("[saved filters] <%s> failed to import: %v", fi.Name(), err) + continue + } + } + + logger.Info("[saved filters] import complete") +} + +func (t *ImportTask) importSavedFilter(ctx context.Context, savedFilterJSON *jsonschema.SavedFilter) error { + importer := &savedfilter.Importer{ + ReaderWriter: t.repository.SavedFilter, + Input: *savedFilterJSON, + MissingRefBehaviour: t.MissingRefBehaviour, + } + + if err := performImport(ctx, importer, t.DuplicateBehaviour); err != nil { + return err + } + + return nil +} diff --git a/pkg/models/jsonschema/load.go b/pkg/models/jsonschema/load.go new file mode 100644 index 00000000000..af0a79a6fde --- /dev/null +++ b/pkg/models/jsonschema/load.go @@ -0,0 +1,31 @@ +package jsonschema + +import ( + "fmt" + "os" + + jsoniter "github.com/json-iterator/go" +) + +func loadFile[T any](filePath string) (*T, error) { + var ret T + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer file.Close() + var json = jsoniter.ConfigCompatibleWithStandardLibrary + jsonParser := json.NewDecoder(file) + err = jsonParser.Decode(&ret) + if err != nil { + return nil, err + } + return &ret, nil +} + +func saveFile[T any](filePath string, obj *T) error { + if obj == nil { + return fmt.Errorf("object must not be nil") + } + return marshalToFile(filePath, obj) +} diff --git a/pkg/models/jsonschema/saved_filter.go b/pkg/models/jsonschema/saved_filter.go new file mode 100644 index 00000000000..ca828f3c2d5 --- /dev/null +++ b/pkg/models/jsonschema/saved_filter.go @@ -0,0 +1,27 @@ +package jsonschema + +import ( + "github.com/stashapp/stash/pkg/fsutil" + "github.com/stashapp/stash/pkg/models" +) + +type SavedFilter struct { + Mode models.FilterMode `db:"mode" json:"mode"` + Name string `db:"name" json:"name"` + FindFilter *models.FindFilterType `json:"find_filter"` + ObjectFilter map[string]interface{} `json:"object_filter"` + UIOptions map[string]interface{} `json:"ui_options"` +} + +func (s SavedFilter) Filename() string { + ret := fsutil.SanitiseBasename(s.Name + "_" + s.Mode.String()) + return ret + ".json" +} + +func LoadSavedFilterFile(filePath string) (*SavedFilter, error) { + return loadFile[SavedFilter](filePath) +} + +func SaveSavedFilterFile(filePath string, image *SavedFilter) error { + return saveFile[SavedFilter](filePath, image) +} diff --git a/pkg/models/paths/paths_json.go b/pkg/models/paths/paths_json.go index e6e302238f5..b2795409fe6 100644 --- a/pkg/models/paths/paths_json.go +++ b/pkg/models/paths/paths_json.go @@ -12,14 +12,15 @@ type JSONPaths struct { ScrapedFile string - Performers string - Scenes string - Images string - Galleries string - Studios string - Tags string - Groups string - Files string + Performers string + Scenes string + Images string + Galleries string + Studios string + Tags string + Groups string + Files string + SavedFilters string } func newJSONPaths(baseDir string) *JSONPaths { @@ -34,6 +35,7 @@ func newJSONPaths(baseDir string) *JSONPaths { jp.Groups = filepath.Join(baseDir, "movies") jp.Tags = filepath.Join(baseDir, "tags") jp.Files = filepath.Join(baseDir, "files") + jp.SavedFilters = filepath.Join(baseDir, "saved_filters") return &jp } @@ -52,6 +54,7 @@ func EmptyJSONDirs(baseDir string) { _ = fsutil.EmptyDir(jsonPaths.Groups) _ = fsutil.EmptyDir(jsonPaths.Tags) _ = fsutil.EmptyDir(jsonPaths.Files) + _ = fsutil.EmptyDir(jsonPaths.SavedFilters) } func EnsureJSONDirs(baseDir string) { @@ -83,4 +86,7 @@ func EnsureJSONDirs(baseDir string) { if err := fsutil.EnsureDir(jsonPaths.Files); err != nil { logger.Warnf("couldn't create directories for Files: %v", err) } + if err := fsutil.EnsureDir(jsonPaths.SavedFilters); err != nil { + logger.Warnf("couldn't create directories for Saved Filters: %v", err) + } } diff --git a/pkg/savedfilter/export.go b/pkg/savedfilter/export.go new file mode 100644 index 00000000000..a52f618c9c8 --- /dev/null +++ b/pkg/savedfilter/export.go @@ -0,0 +1,19 @@ +package savedfilter + +import ( + "context" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/jsonschema" +) + +// ToJSON converts a SavedFilter object into its JSON equivalent. +func ToJSON(ctx context.Context, filter *models.SavedFilter) (*jsonschema.SavedFilter, error) { + return &jsonschema.SavedFilter{ + Name: filter.Name, + Mode: filter.Mode, + FindFilter: filter.FindFilter, + ObjectFilter: filter.ObjectFilter, + UIOptions: filter.UIOptions, + }, nil +} diff --git a/pkg/savedfilter/export_test.go b/pkg/savedfilter/export_test.go new file mode 100644 index 00000000000..c05ec55351e --- /dev/null +++ b/pkg/savedfilter/export_test.go @@ -0,0 +1,91 @@ +package savedfilter + +import ( + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/jsonschema" + "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stretchr/testify/assert" + + "testing" +) + +const ( + savedFilterID = 1 + noImageID = 2 + errImageID = 3 + errAliasID = 4 + withParentsID = 5 + errParentsID = 6 +) + +const ( + filterName = "testFilter" + mode = models.FilterModeGalleries +) + +var ( + findFilter = models.FindFilterType{} + objectFilter = make(map[string]interface{}) + uiOptions = make(map[string]interface{}) +) + +func createSavedFilter(id int) models.SavedFilter { + return models.SavedFilter{ + ID: id, + Name: filterName, + Mode: mode, + FindFilter: &findFilter, + ObjectFilter: objectFilter, + UIOptions: uiOptions, + } +} + +func createJSONSavedFilter() *jsonschema.SavedFilter { + return &jsonschema.SavedFilter{ + Name: filterName, + Mode: mode, + FindFilter: &findFilter, + ObjectFilter: objectFilter, + UIOptions: uiOptions, + } +} + +type testScenario struct { + savedFilter models.SavedFilter + expected *jsonschema.SavedFilter + err bool +} + +var scenarios []testScenario + +func initTestTable() { + scenarios = []testScenario{ + { + createSavedFilter(savedFilterID), + createJSONSavedFilter(), + false, + }, + } +} + +func TestToJSON(t *testing.T) { + initTestTable() + + db := mocks.NewDatabase() + + for i, s := range scenarios { + savedFilter := s.savedFilter + json, err := ToJSON(testCtx, &savedFilter) + + switch { + case !s.err && err != nil: + t.Errorf("[%d] unexpected error: %s", i, err.Error()) + case s.err && err == nil: + t.Errorf("[%d] expected error not returned", i) + default: + assert.Equal(t, s.expected, json, "[%d]", i) + } + } + + db.AssertExpectations(t) +} diff --git a/pkg/savedfilter/import.go b/pkg/savedfilter/import.go new file mode 100644 index 00000000000..451987e4caf --- /dev/null +++ b/pkg/savedfilter/import.go @@ -0,0 +1,60 @@ +package savedfilter + +import ( + "context" + "fmt" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/jsonschema" +) + +type ImporterReaderWriter interface { + models.SavedFilterWriter +} + +type Importer struct { + ReaderWriter ImporterReaderWriter + Input jsonschema.SavedFilter + MissingRefBehaviour models.ImportMissingRefEnum + + savedFilter models.SavedFilter +} + +func (i *Importer) PreImport(ctx context.Context) error { + i.savedFilter = models.SavedFilter{ + Name: i.Input.Name, + Mode: i.Input.Mode, + FindFilter: i.Input.FindFilter, + ObjectFilter: i.Input.ObjectFilter, + UIOptions: i.Input.UIOptions, + } + + return nil +} + +func (i *Importer) PostImport(ctx context.Context, id int) error { + return nil +} + +func (i *Importer) Name() string { + return i.Input.Name +} + +func (i *Importer) FindExistingID(ctx context.Context) (*int, error) { + // for now, assume this is only imported in full, so we don't support updating existing filters + return nil, nil +} + +func (i *Importer) Create(ctx context.Context) (*int, error) { + err := i.ReaderWriter.Create(ctx, &i.savedFilter) + if err != nil { + return nil, fmt.Errorf("error creating saved filter: %v", err) + } + + id := i.savedFilter.ID + return &id, nil +} + +func (i *Importer) Update(ctx context.Context, id int) error { + return fmt.Errorf("updating existing saved filters is not supported") +} diff --git a/pkg/savedfilter/import_test.go b/pkg/savedfilter/import_test.go new file mode 100644 index 00000000000..f0d975ee12f --- /dev/null +++ b/pkg/savedfilter/import_test.go @@ -0,0 +1,124 @@ +package savedfilter + +import ( + "context" + "errors" + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/jsonschema" + "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +const ( + savedFilterNameErr = "savedFilterNameErr" + existingSavedFilterName = "existingSavedFilterName" + + existingFilterID = 100 +) + +var testCtx = context.Background() + +func TestImporterName(t *testing.T) { + i := Importer{ + Input: jsonschema.SavedFilter{ + Name: filterName, + }, + } + + assert.Equal(t, filterName, i.Name()) +} + +func TestImporterPreImport(t *testing.T) { + i := Importer{ + Input: jsonschema.SavedFilter{ + Name: filterName, + }, + } + + err := i.PreImport(testCtx) + assert.Nil(t, err) +} + +func TestImporterPostImport(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + ReaderWriter: db.SavedFilter, + Input: jsonschema.SavedFilter{}, + } + + err := i.PostImport(testCtx, savedFilterID) + assert.Nil(t, err) + + db.AssertExpectations(t) +} + +func TestImporterFindExistingID(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + ReaderWriter: db.SavedFilter, + Input: jsonschema.SavedFilter{ + Name: filterName, + }, + } + + id, err := i.FindExistingID(testCtx) + assert.Nil(t, id) + assert.Nil(t, err) +} + +func TestCreate(t *testing.T) { + db := mocks.NewDatabase() + + savedFilter := models.SavedFilter{ + Name: filterName, + } + + savedFilterErr := models.SavedFilter{ + Name: savedFilterNameErr, + } + + i := Importer{ + ReaderWriter: db.SavedFilter, + savedFilter: savedFilter, + } + + errCreate := errors.New("Create error") + db.SavedFilter.On("Create", testCtx, &savedFilter).Run(func(args mock.Arguments) { + t := args.Get(1).(*models.SavedFilter) + t.ID = savedFilterID + }).Return(nil).Once() + db.SavedFilter.On("Create", testCtx, &savedFilterErr).Return(errCreate).Once() + + id, err := i.Create(testCtx) + assert.Equal(t, savedFilterID, *id) + assert.Nil(t, err) + + i.savedFilter = savedFilterErr + id, err = i.Create(testCtx) + assert.Nil(t, id) + assert.NotNil(t, err) + + db.AssertExpectations(t) +} + +func TestUpdate(t *testing.T) { + db := mocks.NewDatabase() + + savedFilterErr := models.SavedFilter{ + Name: savedFilterNameErr, + } + + i := Importer{ + ReaderWriter: db.SavedFilter, + savedFilter: savedFilterErr, + } + + // Update is not currently supported + err := i.Update(testCtx, existingFilterID) + assert.NotNil(t, err) +} diff --git a/ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx b/ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx index f445b4332cc..e093dc60abe 100644 --- a/ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx @@ -520,7 +520,7 @@ export const DataManagementTasks: React.FC<IDataManagementTasks> = ({ type="submit" onClick={() => onExport()} > - <FormattedMessage id="actions.full_export" />… + <FormattedMessage id="actions.full_export" /> </Button> </Setting> From 64fed3553afad1ad6743b3fd821f314bb732b13b Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 13 Nov 2024 09:47:29 +1100 Subject: [PATCH 90/91] Lint fixes (#5476) * Fix lint errors * Bump lint action version --- .github/workflows/golangci-lint.yml | 2 +- pkg/sqlite/criterion_handlers.go | 12 ++++++------ pkg/sqlite/gallery_filter.go | 12 ++++++------ pkg/utils/vtt.go | 8 ++++---- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index cbb3b021f62..ab21c1c178c 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -38,7 +38,7 @@ jobs: run: docker exec -t build /bin/bash -c "make generate-backend" - name: Run golangci-lint - uses: golangci/golangci-lint-action@v3 + 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 diff --git a/pkg/sqlite/criterion_handlers.go b/pkg/sqlite/criterion_handlers.go index e021bd1759b..55ff31fca82 100644 --- a/pkg/sqlite/criterion_handlers.go +++ b/pkg/sqlite/criterion_handlers.go @@ -285,20 +285,20 @@ func resolutionCriterionHandler(resolution *models.ResolutionCriterionInput, hei addJoinFn(f) } - min := resolution.Value.GetMinResolution() - max := resolution.Value.GetMaxResolution() + mn := resolution.Value.GetMinResolution() + mx := resolution.Value.GetMaxResolution() widthHeight := fmt.Sprintf("MIN(%s, %s)", widthColumn, heightColumn) switch resolution.Modifier { case models.CriterionModifierEquals: - f.addWhere(fmt.Sprintf("%s BETWEEN %d AND %d", widthHeight, min, max)) + f.addWhere(fmt.Sprintf("%s BETWEEN %d AND %d", widthHeight, mn, mx)) case models.CriterionModifierNotEquals: - f.addWhere(fmt.Sprintf("%s NOT BETWEEN %d AND %d", widthHeight, min, max)) + f.addWhere(fmt.Sprintf("%s NOT BETWEEN %d AND %d", widthHeight, mn, mx)) case models.CriterionModifierLessThan: - f.addWhere(fmt.Sprintf("%s < %d", widthHeight, min)) + f.addWhere(fmt.Sprintf("%s < %d", widthHeight, mn)) case models.CriterionModifierGreaterThan: - f.addWhere(fmt.Sprintf("%s > %d", widthHeight, max)) + f.addWhere(fmt.Sprintf("%s > %d", widthHeight, mx)) } } } diff --git a/pkg/sqlite/gallery_filter.go b/pkg/sqlite/gallery_filter.go index ad5ac592ada..18718c511d9 100644 --- a/pkg/sqlite/gallery_filter.go +++ b/pkg/sqlite/gallery_filter.go @@ -414,20 +414,20 @@ func (qb *galleryFilterHandler) averageResolutionCriterionHandler(resolution *mo f.addLeftJoin("images_files", "", "images.id = images_files.image_id") f.addLeftJoin("image_files", "", "images_files.file_id = image_files.file_id") - min := resolution.Value.GetMinResolution() - max := resolution.Value.GetMaxResolution() + mn := resolution.Value.GetMinResolution() + mx := resolution.Value.GetMaxResolution() const widthHeight = "avg(MIN(image_files.width, image_files.height))" switch resolution.Modifier { case models.CriterionModifierEquals: - f.addHaving(fmt.Sprintf("%s BETWEEN %d AND %d", widthHeight, min, max)) + f.addHaving(fmt.Sprintf("%s BETWEEN %d AND %d", widthHeight, mn, mx)) case models.CriterionModifierNotEquals: - f.addHaving(fmt.Sprintf("%s NOT BETWEEN %d AND %d", widthHeight, min, max)) + f.addHaving(fmt.Sprintf("%s NOT BETWEEN %d AND %d", widthHeight, mn, mx)) case models.CriterionModifierLessThan: - f.addHaving(fmt.Sprintf("%s < %d", widthHeight, min)) + f.addHaving(fmt.Sprintf("%s < %d", widthHeight, mn)) case models.CriterionModifierGreaterThan: - f.addHaving(fmt.Sprintf("%s > %d", widthHeight, max)) + f.addHaving(fmt.Sprintf("%s > %d", widthHeight, mx)) } } } diff --git a/pkg/utils/vtt.go b/pkg/utils/vtt.go index 7af094c491d..8d9948a74be 100644 --- a/pkg/utils/vtt.go +++ b/pkg/utils/vtt.go @@ -26,12 +26,12 @@ func GetVTTTime(fracSeconds float64) string { return "00:00:00.000" } - var msec, sec, min, hour int + var msec, sec, mnt, hour int msec = int(fracSeconds * 1000) sec, msec = norm(sec, msec, 1000) - min, sec = norm(min, sec, 60) - hour, min = norm(hour, min, 60) + mnt, sec = norm(mnt, sec, 60) + hour, mnt = norm(hour, mnt, 60) - return fmt.Sprintf("%02d:%02d:%02d.%03d", hour, min, sec, msec) + return fmt.Sprintf("%02d:%02d:%02d.%03d", hour, mnt, sec, msec) } From 5f690d96bdeb341b50ba2e91d57898b2aef9aa0b Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 13 Nov 2024 10:14:55 +1100 Subject: [PATCH 91/91] Fix stash scraper errors and add apikey field (#5474) * Use hasura/go-graphql-client instead of shurcooL version * Fix graphql query errors * Support setting api key for stash server --- go.mod | 3 +- go.sum | 6 +- pkg/plugin/examples/common/graphql.go | 2 +- pkg/plugin/util/client.go | 2 +- pkg/scraper/config.go | 3 +- pkg/scraper/graphql.go | 55 ++++++++ pkg/scraper/image.go | 33 ++++- pkg/scraper/stash.go | 122 +++++++++++++++--- .../src/docs/en/Manual/ScraperDevelopment.md | 3 +- 9 files changed, 195 insertions(+), 34 deletions(-) create mode 100644 pkg/scraper/graphql.go diff --git a/go.mod b/go.mod index d1bd409390a..f35ee773e7d 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/gorilla/sessions v1.2.1 github.com/gorilla/websocket v1.5.0 github.com/hashicorp/golang-lru/v2 v2.0.7 + github.com/hasura/go-graphql-client v0.13.1 github.com/jinzhu/copier v0.4.0 github.com/jmoiron/sqlx v1.4.0 github.com/json-iterator/go v1.1.12 @@ -39,7 +40,6 @@ require ( github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/remeh/sizedwaitgroup v1.0.0 github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd - github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f github.com/sirupsen/logrus v1.9.3 github.com/spf13/cast v1.6.0 github.com/spf13/pflag v1.0.5 @@ -67,6 +67,7 @@ require ( github.com/asticode/go-astikit v0.20.0 // indirect github.com/asticode/go-astits v1.8.0 // indirect github.com/chromedp/sysutil v1.0.0 // indirect + github.com/coder/websocket v1.8.12 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.7.0 // indirect diff --git a/go.sum b/go.sum index 15516f7ff98..e069fb1a196 100644 --- a/go.sum +++ b/go.sum @@ -153,6 +153,8 @@ github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= @@ -394,6 +396,8 @@ github.com/hashicorp/vault/api v1.0.4/go.mod h1:gDcqh3WGcR1cpF5AJz/B1UFheUEneMoI github.com/hashicorp/vault/sdk v0.1.13/go.mod h1:B+hVj7TpuQY1Y/GPbCpffmgd+tSEwvhkWnjtSYCaS2M= github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= +github.com/hasura/go-graphql-client v0.13.1 h1:kKbjhxhpwz58usVl+Xvgah/TDha5K2akNTRQdsEHN6U= +github.com/hasura/go-graphql-client v0.13.1/go.mod h1:k7FF7h53C+hSNFRG3++DdVZWIuHdCaTbI7siTJ//zGQ= github.com/hjson/hjson-go/v4 v4.0.0 h1:wlm6IYYqHjOdXH1gHev4VoXCaW20HdQAGCxdOEEg2cs= github.com/hjson/hjson-go/v4 v4.0.0/go.mod h1:KaYt3bTw3zhBjYqnXkYywcYctk0A2nxeEFTse3rH13E= github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo= @@ -591,8 +595,6 @@ github.com/sagikazarmark/crypt v0.4.0/go.mod h1:ALv2SRj7GxYV4HO9elxH9nS6M9gW+xDN github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= -github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f h1:tygelZueB1EtXkPI6mQ4o9DQ0+FKW41hTbunoXZCTqk= -github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= diff --git a/pkg/plugin/examples/common/graphql.go b/pkg/plugin/examples/common/graphql.go index 8650758a8f0..40ac8d77e6e 100644 --- a/pkg/plugin/examples/common/graphql.go +++ b/pkg/plugin/examples/common/graphql.go @@ -8,7 +8,7 @@ import ( "errors" "fmt" - "github.com/shurcooL/graphql" + graphql "github.com/hasura/go-graphql-client" "github.com/stashapp/stash/pkg/plugin/common/log" ) diff --git a/pkg/plugin/util/client.go b/pkg/plugin/util/client.go index 7b33d8678e9..37c37bfac91 100644 --- a/pkg/plugin/util/client.go +++ b/pkg/plugin/util/client.go @@ -8,7 +8,7 @@ import ( "net/url" "strconv" - "github.com/shurcooL/graphql" + graphql "github.com/hasura/go-graphql-client" "github.com/stashapp/stash/pkg/plugin/common" ) diff --git a/pkg/scraper/config.go b/pkg/scraper/config.go index 9c51b4bba95..e19625f4528 100644 --- a/pkg/scraper/config.go +++ b/pkg/scraper/config.go @@ -114,7 +114,8 @@ func (c config) validate() error { } type stashServer struct { - URL string `yaml:"url"` + URL string `yaml:"url"` + ApiKey string `yaml:"apiKey"` } type scraperTypeConfig struct { diff --git a/pkg/scraper/graphql.go b/pkg/scraper/graphql.go new file mode 100644 index 00000000000..8f582fe8448 --- /dev/null +++ b/pkg/scraper/graphql.go @@ -0,0 +1,55 @@ +package scraper + +import ( + "errors" + "strings" + + "github.com/hasura/go-graphql-client" +) + +type graphqlErrors []error + +func (e graphqlErrors) Error() string { + b := strings.Builder{} + for _, err := range e { + _, _ = b.WriteString(err.Error()) + } + return b.String() +} + +type graphqlError struct { + err graphql.Error +} + +func (e graphqlError) Error() string { + unwrapped := e.err.Unwrap() + if unwrapped != nil { + var networkErr graphql.NetworkError + if errors.As(unwrapped, &networkErr) { + if networkErr.StatusCode() == 422 { + return networkErr.Body() + } + } + } + return e.err.Error() +} + +// convertGraphqlError converts a graphql.Error or graphql.Errors into an error with a useful message. +// graphql.Error swallows important information, so we need to convert it to a more useful error type. +func convertGraphqlError(err error) error { + var gqlErrs graphql.Errors + if errors.As(err, &gqlErrs) { + ret := make(graphqlErrors, len(gqlErrs)) + for i, e := range gqlErrs { + ret[i] = convertGraphqlError(e) + } + return ret + } + + var gqlErr graphql.Error + if errors.As(err, &gqlErr) { + return graphqlError{gqlErr} + } + + return err +} diff --git a/pkg/scraper/image.go b/pkg/scraper/image.go index 193ddc517b6..ee82d2f2105 100644 --- a/pkg/scraper/image.go +++ b/pkg/scraper/image.go @@ -122,13 +122,19 @@ func setGroupBackImage(ctx context.Context, client *http.Client, m *models.Scrap return nil } -func getImage(ctx context.Context, url string, client *http.Client, globalConfig GlobalConfig) (*string, error) { +type imageGetter struct { + client *http.Client + globalConfig GlobalConfig + requestModifier func(req *http.Request) +} + +func (i *imageGetter) getImage(ctx context.Context, url string) (*string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } - userAgent := globalConfig.GetScraperUserAgent() + userAgent := i.globalConfig.GetScraperUserAgent() if userAgent != "" { req.Header.Set("User-Agent", userAgent) } @@ -140,7 +146,11 @@ func getImage(ctx context.Context, url string, client *http.Client, globalConfig req.Header.Set("Referer", req.URL.Scheme+"://"+req.Host+"/") } - resp, err := client.Do(req) + if i.requestModifier != nil { + i.requestModifier(req) + } + + resp, err := i.client.Do(req) if err != nil { return nil, err @@ -167,10 +177,19 @@ func getImage(ctx context.Context, url string, client *http.Client, globalConfig return &img, nil } -func getStashPerformerImage(ctx context.Context, stashURL string, performerID string, client *http.Client, globalConfig GlobalConfig) (*string, error) { - return getImage(ctx, stashURL+"/performer/"+performerID+"/image", client, globalConfig) +func getImage(ctx context.Context, url string, client *http.Client, globalConfig GlobalConfig) (*string, error) { + g := imageGetter{ + client: client, + globalConfig: globalConfig, + } + + return g.getImage(ctx, url) +} + +func getStashPerformerImage(ctx context.Context, stashURL string, performerID string, imageGetter imageGetter) (*string, error) { + return imageGetter.getImage(ctx, stashURL+"/performer/"+performerID+"/image") } -func getStashSceneImage(ctx context.Context, stashURL string, sceneID string, client *http.Client, globalConfig GlobalConfig) (*string, error) { - return getImage(ctx, stashURL+"/scene/"+sceneID+"/screenshot", client, globalConfig) +func getStashSceneImage(ctx context.Context, stashURL string, sceneID string, imageGetter imageGetter) (*string, error) { + return imageGetter.getImage(ctx, stashURL+"/scene/"+sceneID+"/screenshot") } diff --git a/pkg/scraper/stash.go b/pkg/scraper/stash.go index a50db8b5e61..3e28a3e9916 100644 --- a/pkg/scraper/stash.go +++ b/pkg/scraper/stash.go @@ -4,9 +4,11 @@ import ( "context" "fmt" "net/http" + "strconv" + "strings" + graphql "github.com/hasura/go-graphql-client" "github.com/jinzhu/copier" - "github.com/shurcooL/graphql" "github.com/stashapp/stash/pkg/models" ) @@ -27,9 +29,21 @@ func newStashScraper(scraper scraperTypeConfig, client *http.Client, config conf } } +func setApiKeyHeader(apiKey string) func(req *http.Request) { + return func(req *http.Request) { + req.Header.Set("ApiKey", apiKey) + } +} + func (s *stashScraper) getStashClient() *graphql.Client { - url := s.config.StashServer.URL - return graphql.NewClient(url+"/graphql", nil) + url := s.config.StashServer.URL + "/graphql" + ret := graphql.NewClient(url, s.client) + + if s.config.StashServer.ApiKey != "" { + ret = ret.WithRequestModifier(setApiKeyHeader(s.config.StashServer.ApiKey)) + } + + return ret } type stashFindPerformerNamePerformer struct { @@ -58,14 +72,12 @@ type scrapedTagStash struct { type scrapedPerformerStash struct { Name *string `graphql:"name" json:"name"` Gender *string `graphql:"gender" json:"gender"` - URL *string `graphql:"url" json:"url"` - Twitter *string `graphql:"twitter" json:"twitter"` - Instagram *string `graphql:"instagram" json:"instagram"` + URLs []string `graphql:"urls" json:"urls"` Birthdate *string `graphql:"birthdate" json:"birthdate"` Ethnicity *string `graphql:"ethnicity" json:"ethnicity"` Country *string `graphql:"country" json:"country"` EyeColor *string `graphql:"eye_color" json:"eye_color"` - Height *string `graphql:"height" json:"height"` + Height *int `graphql:"height_cm" json:"height_cm"` Measurements *string `graphql:"measurements" json:"measurements"` FakeTits *string `graphql:"fake_tits" json:"fake_tits"` PenisLength *string `graphql:"penis_length" json:"penis_length"` @@ -73,12 +85,25 @@ type scrapedPerformerStash struct { CareerLength *string `graphql:"career_length" json:"career_length"` Tattoos *string `graphql:"tattoos" json:"tattoos"` Piercings *string `graphql:"piercings" json:"piercings"` - Aliases *string `graphql:"aliases" json:"aliases"` + Aliases []string `graphql:"alias_list" json:"alias_list"` Tags []*scrapedTagStash `graphql:"tags" json:"tags"` Details *string `graphql:"details" json:"details"` DeathDate *string `graphql:"death_date" json:"death_date"` HairColor *string `graphql:"hair_color" json:"hair_color"` - Weight *string `graphql:"weight" json:"weight"` + Weight *int `graphql:"weight" json:"weight"` +} + +func (s *stashScraper) imageGetter() imageGetter { + ret := imageGetter{ + client: s.client, + globalConfig: s.globalConfig, + } + + if s.config.StashServer.ApiKey != "" { + ret.requestModifier = setApiKeyHeader(s.config.StashServer.ApiKey) + } + + return ret } func (s *stashScraper) scrapeByFragment(ctx context.Context, input Input) (ScrapedContent, error) { @@ -102,12 +127,12 @@ func (s *stashScraper) scrapeByFragment(ctx context.Context, input Input) (Scrap // get the id from the URL field vars := map[string]interface{}{ - "f": performerID, + "f": graphql.ID(performerID), } err := client.Query(ctx, &q, vars) if err != nil { - return nil, err + return nil, convertGraphqlError(err) } // need to copy back to a scraped performer @@ -117,11 +142,28 @@ func (s *stashScraper) scrapeByFragment(ctx context.Context, input Input) (Scrap return nil, err } + // convert alias list to aliases + aliasStr := strings.Join(q.FindPerformer.Aliases, ", ") + ret.Aliases = &aliasStr + + // convert numeric to string + if q.FindPerformer.Height != nil { + heightStr := strconv.Itoa(*q.FindPerformer.Height) + ret.Height = &heightStr + } + if q.FindPerformer.Weight != nil { + weightStr := strconv.Itoa(*q.FindPerformer.Weight) + ret.Weight = &weightStr + } + // get the performer image directly - ret.Image, err = getStashPerformerImage(ctx, s.config.StashServer.URL, performerID, s.client, s.globalConfig) + ig := s.imageGetter() + img, err := getStashPerformerImage(ctx, s.config.StashServer.URL, performerID, ig) if err != nil { return nil, err } + ret.Images = []string{*img} + ret.Image = img return &ret, nil } @@ -143,8 +185,15 @@ func (s *stashScraper) scrapedStashSceneToScrapedScene(ctx context.Context, scen return nil, err } - // get the performer image directly - ret.Image, err = getStashSceneImage(ctx, s.config.StashServer.URL, scene.ID, s.client, s.globalConfig) + // convert first in files to file + if len(scene.Files) > 0 { + f := scene.Files[0].SceneFileType() + ret.File = &f + } + + // get the scene image directly + ig := s.imageGetter() + ret.Image, err = getStashSceneImage(ctx, s.config.StashServer.URL, scene.ID, ig) if err != nil { return nil, err } @@ -175,7 +224,7 @@ func (s *stashScraper) scrapeByName(ctx context.Context, name string, ty ScrapeC err := client.Query(ctx, &q, vars) if err != nil { - return nil, err + return nil, convertGraphqlError(err) } for _, scene := range q.FindScenes.Scenes { @@ -207,13 +256,41 @@ func (s *stashScraper) scrapeByName(ctx context.Context, name string, ty ScrapeC return nil, ErrNotSupported } +type stashVideoFile struct { + Size int64 `graphql:"size" json:"size"` + Duration float64 `graphql:"duration" json:"duration"` + VideoCodec string `graphql:"video_codec" json:"video_codec"` + AudioCodec string `graphql:"audio_codec" json:"audio_codec"` + Width int `graphql:"width" json:"width"` + Height int `graphql:"height" json:"height"` + Framerate float64 `graphql:"frame_rate" json:"frame_rate"` + Bitrate int `graphql:"bit_rate" json:"bit_rate"` +} + +func (f stashVideoFile) SceneFileType() models.SceneFileType { + ret := models.SceneFileType{ + Duration: &f.Duration, + VideoCodec: &f.VideoCodec, + AudioCodec: &f.AudioCodec, + Width: &f.Width, + Height: &f.Height, + Framerate: &f.Framerate, + Bitrate: &f.Bitrate, + } + + size := strconv.FormatInt(f.Size, 10) + ret.Size = &size + + return ret +} + type scrapedSceneStash struct { ID string `graphql:"id" json:"id"` Title *string `graphql:"title" json:"title"` Details *string `graphql:"details" json:"details"` - URL *string `graphql:"url" json:"url"` + URLs []string `graphql:"urls" json:"urls"` Date *string `graphql:"date" json:"date"` - File *models.SceneFileType `graphql:"file" json:"file"` + Files []stashVideoFile `graphql:"files" json:"files"` Studio *scrapedStudioStash `graphql:"studio" json:"studio"` Tags []*scrapedTagStash `graphql:"tags" json:"tags"` Performers []*scrapedPerformerStash `graphql:"performers" json:"performers"` @@ -239,12 +316,16 @@ func (s *stashScraper) scrapeSceneByScene(ctx context.Context, scene *models.Sce } vars := map[string]interface{}{ - "c": &input, + "c": input, } client := s.getStashClient() if err := client.Query(ctx, &q, vars); err != nil { - return nil, err + return nil, convertGraphqlError(err) + } + + if q.FindScene == nil { + return nil, nil } // need to copy back to a scraped scene @@ -254,7 +335,8 @@ func (s *stashScraper) scrapeSceneByScene(ctx context.Context, scene *models.Sce } // get the performer image directly - ret.Image, err = getStashSceneImage(ctx, s.config.StashServer.URL, q.FindScene.ID, s.client, s.globalConfig) + ig := s.imageGetter() + ret.Image, err = getStashSceneImage(ctx, s.config.StashServer.URL, q.FindScene.ID, ig) if err != nil { return nil, err } diff --git a/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md b/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md index caa3d41dc80..a0ac30547c2 100644 --- a/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md +++ b/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md @@ -247,7 +247,7 @@ sceneByURL: A different stash server can be configured as a scraping source. This action applies only to `performerByName`, `performerByFragment`, and `sceneByFragment` types. This action requires that the top-level `stashServer` field is configured. -`stashServer` contains a single `url` field for the remote stash server. The username and password can be embedded in this string using `username:password@host`. +`stashServer` contains a single `url` field for the remote stash server. The username and password can be embedded in this string using `username:password@host`. Alternatively, the `apiKey` field can be used to authenticate with the remote stash server. An example stash scrape configuration is below: @@ -260,6 +260,7 @@ performerByFragment: sceneByFragment: action: stash stashServer: + apiKey: <api key> url: http://stashserver.com:9999 ```