From da1ef146c68f5a4fe4528bdf9a29a89fee738dca Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Wed, 3 May 2023 06:13:51 +0300 Subject: [PATCH 001/135] Add Matrix badge and link in support section (#3710) --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 5f2c0fdcd9e..3840a654e91 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ https://stashapp.cc [![Docker pulls](https://img.shields.io/docker/pulls/stashapp/stash.svg)](https://hub.docker.com/r/stashapp/stash 'DockerHub') [![Open Collective backers](https://img.shields.io/opencollective/backers/stashapp?logo=opencollective)](https://opencollective.com/stashapp) [![Go Report Card](https://goreportcard.com/badge/github.com/stashapp/stash)](https://goreportcard.com/report/github.com/stashapp/stash) +[![Matrix](https://img.shields.io/matrix/stashapp:unredacted.org?logo=matrix&server_fqdn=matrix.org)](https://matrix.to/#/#stashapp:unredacted.org) [![Discord](https://img.shields.io/discord/559159668438728723.svg?logo=discord)](https://discord.gg/2TsNFKt) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/stashapp/stash?logo=github)](https://github.com/stashapp/stash/releases/latest) [![GitHub issues by-label](https://img.shields.io/github/issues-raw/stashapp/stash/bounty)](https://github.com/stashapp/stash/labels/bounty) @@ -58,6 +59,7 @@ Check out our documentation on [Stash-Docs](https://docs.stashapp.cc) for inform For more help you can: * Check the in-app documentation, in the top right corner of the app (it's also mirrored on [Stash-Docs](https://docs.stashapp.cc/in-app-manual)) +* Join the [Matrix space](https://matrix.to/#/#stashapp:unredacted.org) * Join the [Discord server](https://discord.gg/2TsNFKt), where the community can offer support. * Start a [discussion on GitHub](https://github.com/stashapp/stash/discussions) From 89ed6e9a67788d3d172c0fe652bee0dd69f30ffc Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Wed, 3 May 2023 05:24:29 +0200 Subject: [PATCH 002/135] Fix scene marker pinned filters (#3687) --- .../src/components/List/EditFilterDialog.tsx | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/List/EditFilterDialog.tsx b/ui/v2.5/src/components/List/EditFilterDialog.tsx index 4e52f12598c..8a41b4a2d9d 100644 --- a/ui/v2.5/src/components/List/EditFilterDialog.tsx +++ b/ui/v2.5/src/components/List/EditFilterDialog.tsx @@ -33,6 +33,7 @@ import { CriterionType } from "src/models/list-filter/types"; import { useToast } from "src/hooks/Toast"; import { useConfigureUI } from "src/core/StashService"; import { IUIConfig } from "src/core/config"; +import { FilterMode } from "src/core/generated-graphql"; interface ICriterionList { criteria: string[]; @@ -188,6 +189,21 @@ const CriterionOptionList: React.FC = ({ ); }; +const FilterModeToConfigKey = { + [FilterMode.Galleries]: "galleries", + [FilterMode.Images]: "images", + [FilterMode.Movies]: "movies", + [FilterMode.Performers]: "performers", + [FilterMode.SceneMarkers]: "sceneMarkers", + [FilterMode.Scenes]: "scenes", + [FilterMode.Studios]: "studios", + [FilterMode.Tags]: "tags", +}; + +function filterModeToConfigKey(filterMode: FilterMode) { + return FilterModeToConfigKey[filterMode]; +} + interface IEditFilterProps { filter: ListFilterModel; editingCriterion?: string; @@ -260,7 +276,7 @@ export const EditFilterDialog: React.FC = ({ const [saveUI] = useConfigureUI(); const pinnedFilters = useMemo( - () => ui.pinnedFilters?.[currentFilter.mode.toLowerCase()] ?? [], + () => ui.pinnedFilters?.[filterModeToConfigKey(currentFilter.mode)] ?? [], [currentFilter.mode, ui.pinnedFilters] ); const pinnedElements = useMemo( @@ -289,7 +305,7 @@ export const EditFilterDialog: React.FC = ({ ]); async function updatePinnedFilters(filters: string[]) { - const currentMode = currentFilter.mode.toLowerCase(); + const configKey = filterModeToConfigKey(currentFilter.mode); try { await saveUI({ variables: { @@ -297,7 +313,7 @@ export const EditFilterDialog: React.FC = ({ ...configuration?.ui, pinnedFilters: { ...ui.pinnedFilters, - [currentMode]: filters, + [configKey]: filters, }, }, }, From c9c5b557212a7ffa6bef0e475bc6a951465e7ade Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Wed, 3 May 2023 05:28:23 +0200 Subject: [PATCH 003/135] Ignore graphql context canceled errors (#3689) --- internal/api/error.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/api/error.go b/internal/api/error.go index 85d9cde28c1..208b2521cc2 100644 --- a/internal/api/error.go +++ b/internal/api/error.go @@ -3,6 +3,7 @@ package api import ( "context" "encoding/json" + "errors" "github.com/99designs/gqlgen/graphql" "github.com/stashapp/stash/pkg/logger" @@ -13,7 +14,7 @@ func gqlErrorHandler(ctx context.Context, e error) *gqlerror.Error { // log all errors - for now just log the error message // we can potentially add more context later fc := graphql.GetFieldContext(ctx) - if fc != nil { + if fc != nil && !errors.Is(e, context.Canceled) { logger.Errorf("%s: %v", fc.Path(), e) // log the args in debug level From 55e0d5c82fbe63baa2ff7ebdd4de6732cc6100a1 Mon Sep 17 00:00:00 2001 From: Bawdy Ink Slinger <51732963+BawdyInkSlinger@users.noreply.github.com> Date: Tue, 2 May 2023 20:29:38 -0700 Subject: [PATCH 004/135] Removed a sentence that is technically irrelevant to auto tagging (#3683) - (As far as I know,) scraping is irrelevant to auto tagging so I removed it from the Auto Tagging documentation. Alternatively, it could be moved to the bottom. Co-authored-by: Bawdy Ink Slinger --- ui/v2.5/src/docs/en/Manual/AutoTagging.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/docs/en/Manual/AutoTagging.md b/ui/v2.5/src/docs/en/Manual/AutoTagging.md index 4fba88ad01f..ef9035e6ce4 100644 --- a/ui/v2.5/src/docs/en/Manual/AutoTagging.md +++ b/ui/v2.5/src/docs/en/Manual/AutoTagging.md @@ -2,7 +2,7 @@ This task matches your Performers, Studios, and Tags against your media, based on names only. It finds Scenes, Images, and Galleries where the path or filename contains the Performer/Studio/Tag. -For each scene it finds that matches, it sets the applicable field. It will **only** tag based on performers, studios, and tags that already exist in your database. In order to completely identify and gather information about the scenes in your collection, you will need to use the Tagger view and/or Scraping tools. +For each scene it finds that matches, it sets the applicable field. It will **only** tag based on performers, studios, and tags that already exist in your database. When the Performer/Studio/Tag name has multiple words, the search will include paths/filenames where the Performer/Studio/Tag name is separated with `.`, `-` or `_` characters, as well as whitespace. From d6b4d16ff415979ff40051e985ee2bf993fe40a6 Mon Sep 17 00:00:00 2001 From: CJ <72030708+Teda1@users.noreply.github.com> Date: Tue, 2 May 2023 20:33:32 -0700 Subject: [PATCH 005/135] Adds ability to configure sort order for DLNA videos (#3645) --- graphql/documents/data/config.graphql | 1 + graphql/schema/types/config.graphql | 4 +++ internal/api/resolver_mutation_configure.go | 4 +++ internal/api/resolver_query_configuration.go | 1 + internal/dlna/cds.go | 11 +++++--- internal/dlna/dms.go | 1 + internal/dlna/service.go | 4 +++ internal/manager/config/config.go | 14 ++++++++++ .../Settings/SettingsServicesPanel.tsx | 27 ++++++++++++++++++- ui/v2.5/src/locales/en-GB.json | 4 ++- ui/v2.5/src/utils/dlnaVideoSort.ts | 17 ++++++++++++ 11 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 ui/v2.5/src/utils/dlnaVideoSort.ts diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql index 173a7948ee0..a96341653ee 100644 --- a/graphql/documents/data/config.graphql +++ b/graphql/documents/data/config.graphql @@ -99,6 +99,7 @@ fragment ConfigDLNAData on ConfigDLNAResult { enabled whitelistedIPs interfaces + videoSortOrder } fragment ConfigScrapingData on ConfigScrapingResult { diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index df0aba09280..904d235dd0e 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -431,6 +431,8 @@ input ConfigDLNAInput { whitelistedIPs: [String!] """List of interfaces to run DLNA on. Empty for all""" interfaces: [String!] + """Order to sort videos""" + videoSortOrder: String } type ConfigDLNAResult { @@ -441,6 +443,8 @@ type ConfigDLNAResult { whitelistedIPs: [String!]! """List of interfaces to run DLNA on. Empty for all""" interfaces: [String!]! + """Order to sort videos""" + videoSortOrder: String! } input ConfigScrapingInput { diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index 824f9e6d784..2a102af6eb0 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -493,6 +493,10 @@ func (r *mutationResolver) ConfigureDlna(ctx context.Context, input ConfigDLNAIn c.Set(config.DLNADefaultIPWhitelist, input.WhitelistedIPs) } + if input.VideoSortOrder != nil { + c.Set(config.DLNAVideoSortOrder, input.VideoSortOrder) + } + currentDLNAEnabled := c.GetDLNADefaultEnabled() if input.Enabled != nil && *input.Enabled != currentDLNAEnabled { c.Set(config.DLNADefaultEnabled, *input.Enabled) diff --git a/internal/api/resolver_query_configuration.go b/internal/api/resolver_query_configuration.go index fd598ce9270..643aa263bfb 100644 --- a/internal/api/resolver_query_configuration.go +++ b/internal/api/resolver_query_configuration.go @@ -202,6 +202,7 @@ func makeConfigDLNAResult() *ConfigDLNAResult { Enabled: config.GetDLNADefaultEnabled(), WhitelistedIPs: config.GetDLNADefaultIPWhitelist(), Interfaces: config.GetDLNAInterfaces(), + VideoSortOrder: config.GetVideoSortOrder(), } } diff --git a/internal/dlna/cds.go b/internal/dlna/cds.go index 4deb017f2d2..cf5deaa7c1e 100644 --- a/internal/dlna/cds.go +++ b/internal/dlna/cds.go @@ -444,10 +444,15 @@ func (me *contentDirectoryService) getVideos(sceneFilter *models.SceneFilterType var objs []interface{} if err := txn.WithReadTxn(context.TODO(), me.txnManager, func(ctx context.Context) error { - sort := "title" + sort := me.VideoSortOrder + direction := models.SortDirectionEnumDesc + if sort == "title" { + direction = models.SortDirectionEnumAsc + } findFilter := &models.FindFilterType{ - PerPage: &pageSize, - Sort: &sort, + PerPage: &pageSize, + Sort: &sort, + Direction: &direction, } scenes, total, err := scene.QueryWithCount(ctx, me.repository.SceneFinder, sceneFilter, findFilter) diff --git a/internal/dlna/dms.go b/internal/dlna/dms.go index fdef80db1c6..502dbe0e44e 100644 --- a/internal/dlna/dms.go +++ b/internal/dlna/dms.go @@ -276,6 +276,7 @@ type Server struct { repository Repository sceneServer sceneServer ipWhitelistManager *ipWhitelistManager + VideoSortOrder string } // UPnP SOAP service. diff --git a/internal/dlna/service.go b/internal/dlna/service.go index a257b7f940c..0d8932e0803 100644 --- a/internal/dlna/service.go +++ b/internal/dlna/service.go @@ -45,6 +45,7 @@ type dmsConfig struct { LogHeaders bool StallEventSubscribe bool NotifyInterval time.Duration + VideoSortOrder string } type sceneServer interface { @@ -56,6 +57,7 @@ type Config interface { GetDLNAInterfaces() []string GetDLNAServerName() string GetDLNADefaultIPWhitelist() []string + GetVideoSortOrder() string } type Service struct { @@ -123,6 +125,7 @@ func (s *Service) init() error { FriendlyName: friendlyName, LogHeaders: false, NotifyInterval: 30 * time.Second, + VideoSortOrder: s.config.GetVideoSortOrder(), } interfaces, err := s.getInterfaces() @@ -164,6 +167,7 @@ func (s *Service) init() error { // }, StallEventSubscribe: dmsConfig.StallEventSubscribe, NotifyInterval: dmsConfig.NotifyInterval, + VideoSortOrder: dmsConfig.VideoSortOrder, } return nil diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 4b2ba792165..fe973021914 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -210,6 +210,9 @@ const ( DLNADefaultIPWhitelist = "dlna.default_whitelist" DLNAInterfaces = "dlna.interfaces" + DLNAVideoSortOrder = "dlna.video_sort_order" + dlnaVideoSortOrderDefault = "title" + // Logging options LogFile = "logFile" LogOut = "logOut" @@ -1370,6 +1373,17 @@ func (i *Instance) GetDLNAInterfaces() []string { return i.getStringSlice(DLNAInterfaces) } +// GetVideoSortOrder returns the sort order to display videos. If +// empty, videos will be sorted by titles. +func (i *Instance) GetVideoSortOrder() string { + ret := i.getString(DLNAVideoSortOrder) + if ret == "" { + ret = dlnaVideoSortOrderDefault + } + + return ret +} + // GetLogFile returns the filename of the file to output logs to. // An empty string means that file logging will be disabled. func (i *Instance) GetLogFile() string { diff --git a/ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx b/ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx index 2db88f92680..38a1ccb7932 100644 --- a/ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx @@ -14,8 +14,17 @@ import { Icon } from "../Shared/Icon"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { ModalComponent } from "../Shared/Modal"; import { SettingSection } from "./SettingSection"; -import { BooleanSetting, StringListSetting, StringSetting } from "./Inputs"; +import { + BooleanSetting, + StringListSetting, + StringSetting, + SelectSetting, +} from "./Inputs"; import { SettingStateContext } from "./context"; +import { + videoSortOrderIntlMap, + defaultVideoSort, +} from "src/utils/dlnaVideoSort"; import { faClock, faTimes, @@ -445,6 +454,22 @@ export const SettingsServicesPanel: React.FC = () => { value={dlna.whitelistedIPs ?? undefined} onChange={(v) => saveDLNA({ whitelistedIPs: v })} /> + + saveDLNA({ videoSortOrder: v })} + > + {Array.from(videoSortOrderIntlMap.entries()).map((v) => ( + + ))} + ); diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index c232e096494..2272cd1c00e 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -234,7 +234,9 @@ "server_display_name": "Server Display Name", "server_display_name_desc": "Display name for the DLNA server. Defaults to {server_name} if empty.", "successfully_cancelled_temporary_behaviour": "Successfully cancelled temporary behaviour", - "until_restart": "until restart" + "until_restart": "until restart", + "video_sort_order": "Default Video Sort Order", + "video_sort_order_desc": "Order to sort videos by default." }, "general": { "auth": { diff --git a/ui/v2.5/src/utils/dlnaVideoSort.ts b/ui/v2.5/src/utils/dlnaVideoSort.ts new file mode 100644 index 00000000000..8cd26e6f926 --- /dev/null +++ b/ui/v2.5/src/utils/dlnaVideoSort.ts @@ -0,0 +1,17 @@ +export enum VideoSortOrder { + Created_At = "created_at", + Date = "date", + Random = "random", + Title = "title", + Updated_At = "updated_at", +} + +export const defaultVideoSort = VideoSortOrder.Title; + +export const videoSortOrderIntlMap = new Map([ + [VideoSortOrder.Created_At, "created_at"], + [VideoSortOrder.Date, "date"], + [VideoSortOrder.Random, "random"], + [VideoSortOrder.Title, "title"], + [VideoSortOrder.Updated_At, "updated_at"], +]); From 1606f1b17e58ea33c9eb65a0572e8fb467ccc3f2 Mon Sep 17 00:00:00 2001 From: Flashy78 <90150289+Flashy78@users.noreply.github.com> Date: Tue, 2 May 2023 20:34:57 -0700 Subject: [PATCH 006/135] Sort scrapers by name (#3691) --- pkg/scraper/cache.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pkg/scraper/cache.go b/pkg/scraper/cache.go index 3b53919947f..5a15239dbca 100644 --- a/pkg/scraper/cache.go +++ b/pkg/scraper/cache.go @@ -150,7 +150,6 @@ func (c *Cache) loadScrapers() (map[string]scraper, error) { logger.Debugf("Reading scraper configs from %s", path) - scraperFiles := []string{} err := fsutil.SymWalk(path, func(fp string, f os.FileInfo, err error) error { if filepath.Ext(fp) == ".yml" { conf, err := loadConfigFromYAMLFile(fp) @@ -160,7 +159,6 @@ func (c *Cache) loadScrapers() (map[string]scraper, error) { scraper := newGroupScraper(*conf, c.globalConfig) scrapers[scraper.spec().ID] = scraper } - scraperFiles = append(scraperFiles, fp) } return nil }) @@ -187,7 +185,7 @@ func (c *Cache) ReloadScrapers() error { } // ListScrapers lists scrapers matching one of the given types. -// Returns a list of scrapers, sorted by their ID. +// Returns a list of scrapers, sorted by their name. func (c Cache) ListScrapers(tys []ScrapeContentType) []*Scraper { var ret []*Scraper for _, s := range c.scrapers { @@ -201,7 +199,7 @@ func (c Cache) ListScrapers(tys []ScrapeContentType) []*Scraper { } sort.Slice(ret, func(i, j int) bool { - return ret[i].ID < ret[j].ID + return strings.ToLower(ret[i].Name) < strings.ToLower(ret[j].Name) }) return ret From 1717474a8161f15b7c5dbbe497800df146b572af Mon Sep 17 00:00:00 2001 From: dogwithakeyboard <128322708+dogwithakeyboard@users.noreply.github.com> Date: Wed, 3 May 2023 04:37:31 +0100 Subject: [PATCH 007/135] fix scene scraper movie error (#3633) --- .../src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx index 953b24b640b..cf658200ace 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx @@ -521,7 +521,7 @@ export const SceneScrapeDialog: React.FC = ({ // remove the movie from the list const newMoviesClone = newMovies.concat(); - const pIndex = newMoviesClone.indexOf(toCreate); + const pIndex = newMoviesClone.findIndex((p) => p.name === toCreate.name); if (pIndex === -1) throw new Error("Could not find movie to remove"); newMoviesClone.splice(pIndex, 1); @@ -558,7 +558,7 @@ export const SceneScrapeDialog: React.FC = ({ // remove the tag from the list const newTagsClone = newTags.concat(); const pIndex = newTagsClone.indexOf(toCreate); - if (pIndex === -1) throw new Error("Could not find performer to remove"); + if (pIndex === -1) throw new Error("Could not find tag to remove"); newTagsClone.splice(pIndex, 1); setNewTags(newTagsClone); From 67a2161c626441afae50b56d582e81dcce96c0e8 Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Wed, 3 May 2023 05:42:25 +0200 Subject: [PATCH 008/135] Fix generate task override behaviour (#3661) --- internal/manager/task_generate.go | 55 +++++++++++++------ ...task_generate_interactive_heatmap_speed.go | 11 +++- internal/manager/task_generate_phash.go | 10 +++- internal/manager/task_generate_preview.go | 42 ++++++++------ internal/manager/task_generate_screenshot.go | 17 +++--- internal/manager/task_generate_sprite.go | 7 ++- internal/manager/task_scan.go | 1 + internal/manager/task_transcode.go | 2 +- ui/v2.5/src/locales/en-GB.json | 2 +- 9 files changed, 96 insertions(+), 51 deletions(-) diff --git a/internal/manager/task_generate.go b/internal/manager/task_generate.go index c3b4f16f70d..c457ddedf5e 100644 --- a/internal/manager/task_generate.go +++ b/internal/manager/task_generate.go @@ -142,7 +142,35 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) { return } - logger.Infof("Generating %d covers %d sprites %d previews %d image previews %d markers %d transcodes %d phashes %d heatmaps & speeds", totals.covers, totals.sprites, totals.previews, totals.imagePreviews, totals.markers, totals.transcodes, totals.phashes, totals.interactiveHeatmapSpeeds) + logMsg := "Generating" + if j.input.Covers { + logMsg += fmt.Sprintf(" %d covers", totals.covers) + } + if j.input.Sprites { + logMsg += fmt.Sprintf(" %d sprites", totals.sprites) + } + if j.input.Previews { + logMsg += fmt.Sprintf(" %d previews", totals.previews) + } + if j.input.ImagePreviews { + logMsg += fmt.Sprintf(" %d image previews", totals.imagePreviews) + } + if j.input.Markers { + logMsg += fmt.Sprintf(" %d markers", totals.markers) + } + if j.input.Transcodes { + logMsg += fmt.Sprintf(" %d transcodes", totals.transcodes) + } + if j.input.Phashes { + logMsg += fmt.Sprintf(" %d phashes", totals.phashes) + } + if j.input.InteractiveHeatmapsSpeeds { + logMsg += fmt.Sprintf(" %d heatmaps & speeds", totals.interactiveHeatmapSpeeds) + } + if logMsg == "Generating" { + logMsg = "Nothing selected to generate" + } + logger.Infof(logMsg) progress.SetTotal(int(totals.tasks)) }() @@ -269,9 +297,10 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, task := &GenerateCoverTask{ txnManager: j.txnManager, Scene: *scene, + Overwrite: j.overwrite, } - if j.overwrite || task.required(ctx) { + if task.required(ctx) { totals.covers++ totals.tasks++ queue <- task @@ -285,7 +314,7 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, fileNamingAlgorithm: j.fileNamingAlgo, } - if j.overwrite || task.required() { + if task.required() { totals.sprites++ totals.tasks++ queue <- task @@ -309,21 +338,15 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, } if task.required() { - addTask := false - if j.overwrite || !task.doesVideoPreviewExist() { + if task.videoPreviewRequired() { totals.previews++ - addTask = true } - - if j.input.ImagePreviews && (j.overwrite || !task.doesImagePreviewExist()) { + if task.imagePreviewRequired() { totals.imagePreviews++ - addTask = true } - if addTask { - totals.tasks++ - queue <- task - } + totals.tasks++ + queue <- task } } @@ -357,7 +380,7 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, fileNamingAlgorithm: j.fileNamingAlgo, g: g, } - if task.isTranscodeNeeded() { + if task.required() { totals.transcodes++ totals.tasks++ queue <- task @@ -375,7 +398,7 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, Overwrite: j.overwrite, } - if task.shouldGenerate() { + if task.required() { totals.phashes++ totals.tasks++ queue <- task @@ -391,7 +414,7 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, TxnManager: j.txnManager, } - if task.shouldGenerate() { + if task.required() { totals.interactiveHeatmapSpeeds++ totals.tasks++ queue <- task diff --git a/internal/manager/task_generate_interactive_heatmap_speed.go b/internal/manager/task_generate_interactive_heatmap_speed.go index 564004b8e9d..4f91bd023ea 100644 --- a/internal/manager/task_generate_interactive_heatmap_speed.go +++ b/internal/manager/task_generate_interactive_heatmap_speed.go @@ -22,7 +22,7 @@ func (t *GenerateInteractiveHeatmapSpeedTask) GetDescription() string { } func (t *GenerateInteractiveHeatmapSpeedTask) Start(ctx context.Context) { - if !t.shouldGenerate() { + if !t.required() { return } @@ -52,13 +52,18 @@ func (t *GenerateInteractiveHeatmapSpeedTask) Start(ctx context.Context) { } } -func (t *GenerateInteractiveHeatmapSpeedTask) shouldGenerate() bool { +func (t *GenerateInteractiveHeatmapSpeedTask) required() bool { primaryFile := t.Scene.Files.Primary() if primaryFile == nil || !primaryFile.Interactive { return false } + + if t.Overwrite { + return true + } + sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm) - return !t.doesHeatmapExist(sceneHash) || primaryFile.InteractiveSpeed == nil || t.Overwrite + return !t.doesHeatmapExist(sceneHash) || primaryFile.InteractiveSpeed == nil } func (t *GenerateInteractiveHeatmapSpeedTask) doesHeatmapExist(sceneChecksum string) bool { diff --git a/internal/manager/task_generate_phash.go b/internal/manager/task_generate_phash.go index 6ba84069451..8ae84b02e03 100644 --- a/internal/manager/task_generate_phash.go +++ b/internal/manager/task_generate_phash.go @@ -24,7 +24,7 @@ func (t *GeneratePhashTask) GetDescription() string { } func (t *GeneratePhashTask) Start(ctx context.Context) { - if !t.shouldGenerate() { + if !t.required() { return } @@ -49,6 +49,10 @@ func (t *GeneratePhashTask) Start(ctx context.Context) { } } -func (t *GeneratePhashTask) shouldGenerate() bool { - return t.Overwrite || t.File.Fingerprints.Get(file.FingerprintTypePhash) == nil +func (t *GeneratePhashTask) required() bool { + if t.Overwrite { + return true + } + + return t.File.Fingerprints.Get(file.FingerprintTypePhash) == nil } diff --git a/internal/manager/task_generate_preview.go b/internal/manager/task_generate_preview.go index c81909417c0..df2a69ee57b 100644 --- a/internal/manager/task_generate_preview.go +++ b/internal/manager/task_generate_preview.go @@ -30,13 +30,9 @@ func (t *GeneratePreviewTask) GetDescription() string { } func (t *GeneratePreviewTask) Start(ctx context.Context) { - if !t.Overwrite && !t.required() { - return - } - videoChecksum := t.Scene.GetHash(t.fileNamingAlgorithm) - if t.Overwrite || !t.doesVideoPreviewExist() { + if t.videoPreviewRequired() { ffprobe := instance.FFProbe videoFile, err := ffprobe.NewVideoFile(t.Scene.Path) if err != nil { @@ -51,7 +47,7 @@ func (t *GeneratePreviewTask) Start(ctx context.Context) { } } - if t.ImagePreview && (t.Overwrite || !t.doesImagePreviewExist()) { + if t.imagePreviewRequired() { if err := t.generateWebp(videoChecksum); err != nil { logger.Errorf("error generating preview webp: %v", err) logErrorOutput(err) @@ -59,7 +55,7 @@ func (t *GeneratePreviewTask) Start(ctx context.Context) { } } -func (t GeneratePreviewTask) generateVideo(videoChecksum string, videoDuration float64, videoFrameRate float64) error { +func (t *GeneratePreviewTask) generateVideo(videoChecksum string, videoDuration float64, videoFrameRate float64) error { videoFilename := t.Scene.Path useVsync2 := false @@ -78,12 +74,16 @@ func (t GeneratePreviewTask) generateVideo(videoChecksum string, videoDuration f return nil } -func (t GeneratePreviewTask) generateWebp(videoChecksum string) error { +func (t *GeneratePreviewTask) generateWebp(videoChecksum string) error { videoFilename := t.Scene.Path return t.generator.PreviewWebp(context.TODO(), videoFilename, videoChecksum) } -func (t GeneratePreviewTask) required() bool { +func (t *GeneratePreviewTask) required() bool { + return t.videoPreviewRequired() || t.imagePreviewRequired() +} + +func (t *GeneratePreviewTask) videoPreviewRequired() bool { if t.Scene.Path == "" { return false } @@ -92,12 +92,6 @@ func (t GeneratePreviewTask) required() bool { return true } - videoExists := t.doesVideoPreviewExist() - imageExists := !t.ImagePreview || t.doesImagePreviewExist() - return !imageExists || !videoExists -} - -func (t *GeneratePreviewTask) doesVideoPreviewExist() bool { sceneChecksum := t.Scene.GetHash(t.fileNamingAlgorithm) if sceneChecksum == "" { return false @@ -108,10 +102,22 @@ func (t *GeneratePreviewTask) doesVideoPreviewExist() bool { t.videoPreviewExists = &videoExists } - return *t.videoPreviewExists + return !*t.videoPreviewExists } -func (t *GeneratePreviewTask) doesImagePreviewExist() bool { +func (t *GeneratePreviewTask) imagePreviewRequired() bool { + if !t.ImagePreview { + return false + } + + if t.Scene.Path == "" { + return false + } + + if t.Overwrite { + return true + } + sceneChecksum := t.Scene.GetHash(t.fileNamingAlgorithm) if sceneChecksum == "" { return false @@ -122,5 +128,5 @@ func (t *GeneratePreviewTask) doesImagePreviewExist() bool { t.imagePreviewExists = &imageExists } - return *t.imagePreviewExists + return !*t.imagePreviewExists } diff --git a/internal/manager/task_generate_screenshot.go b/internal/manager/task_generate_screenshot.go index 5d32f276258..b3cd93e38d2 100644 --- a/internal/manager/task_generate_screenshot.go +++ b/internal/manager/task_generate_screenshot.go @@ -21,21 +21,18 @@ func (t *GenerateCoverTask) GetDescription() string { } func (t *GenerateCoverTask) Start(ctx context.Context) { + if !t.required(ctx) { + return + } + scenePath := t.Scene.Path - var required bool if err := t.txnManager.WithReadTxn(ctx, func(ctx context.Context) error { - // don't generate the screenshot if it already exists - required = t.required(ctx) return t.Scene.LoadPrimaryFile(ctx, t.txnManager.File) }); err != nil { logger.Error(err) } - if !required { - return - } - videoFile := t.Scene.Files.Primary() if videoFile == nil { return @@ -92,7 +89,11 @@ func (t *GenerateCoverTask) Start(ctx context.Context) { } // required returns true if the sprite needs to be generated -func (t GenerateCoverTask) required(ctx context.Context) bool { +func (t *GenerateCoverTask) required(ctx context.Context) bool { + if t.Scene.Path == "" { + return false + } + if t.Overwrite { return true } diff --git a/internal/manager/task_generate_sprite.go b/internal/manager/task_generate_sprite.go index eb96d8f4c59..0275830ab72 100644 --- a/internal/manager/task_generate_sprite.go +++ b/internal/manager/task_generate_sprite.go @@ -20,7 +20,7 @@ func (t *GenerateSpriteTask) GetDescription() string { } func (t *GenerateSpriteTask) Start(ctx context.Context) { - if !t.Overwrite && !t.required() { + if !t.required() { return } @@ -54,6 +54,11 @@ func (t GenerateSpriteTask) required() bool { if t.Scene.Path == "" { return false } + + if t.Overwrite { + return true + } + sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm) return !t.doesSpriteExist(sceneHash) } diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index fa31af61008..43d264c2206 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -490,6 +490,7 @@ func (g *sceneGenerators) Generate(ctx context.Context, s *models.Scene, f *file taskCover := GenerateCoverTask{ Scene: *s, txnManager: instance.Repository, + Overwrite: overwrite, } taskCover.Start(ctx) progress.Increment() diff --git a/internal/manager/task_transcode.go b/internal/manager/task_transcode.go index 296042bddd2..edda08fbbd0 100644 --- a/internal/manager/task_transcode.go +++ b/internal/manager/task_transcode.go @@ -101,7 +101,7 @@ func (t *GenerateTranscodeTask) Start(ctc context.Context) { // return true if transcode is needed // used only when counting files to generate, doesn't affect the actual transcode generation // if container is missing from DB it is treated as non supported in order not to delay the user -func (t *GenerateTranscodeTask) isTranscodeNeeded() bool { +func (t *GenerateTranscodeTask) required() bool { f := t.Scene.Files.Primary() if f == nil { return false diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 2272cd1c00e..73f9a73e7a5 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -813,7 +813,7 @@ "markers_tooltip": "20 second videos which begin at the given timecode.", "override_preview_generation_options": "Override Preview Generation Options", "override_preview_generation_options_desc": "Override Preview Generation Options for this operation. Defaults are set in System -> Preview Generation.", - "overwrite": "Overwrite existing generated files", + "overwrite": "Overwrite existing files", "phash": "Perceptual hashes (for deduplication)", "preview_exclude_end_time_desc": "Exclude the last x seconds from scene previews. This can be a value in seconds, or a percentage (eg 2%) of the total scene duration.", "preview_exclude_end_time_head": "Exclude end time", From 002b71bd6763021e79c316bf5388f2813c1f1445 Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Wed, 3 May 2023 05:43:52 +0200 Subject: [PATCH 009/135] Fix filter dialog datepicker button padding (#3690) --- ui/v2.5/src/components/List/styles.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index e86199e6bed..40a9ead9123 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -154,7 +154,7 @@ input[type="range"].zoom-slider { } } - .btn { + .card-header .btn { border: 0; padding-bottom: 0; padding-top: 0; From 899d1b9395ab8a5bbd75ab34bdd22a5a3aae20a2 Mon Sep 17 00:00:00 2001 From: puc9 <51006296+puc9@users.noreply.github.com> Date: Tue, 2 May 2023 22:01:59 -0700 Subject: [PATCH 010/135] Limit duplicate matching to files that have ~ same duration (#3663) * Limit duplicate matching to files that have ~ same duration * Add UI for duration diff --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/documents/queries/scene.graphql | 4 +- graphql/schema/schema.graphql | 16 ++- internal/api/resolver_query_find_scene.go | 8 +- pkg/models/mocks/SceneReaderWriter.go | 2 +- pkg/models/scene.go | 2 +- pkg/sqlite/scene.go | 48 ++++--- pkg/sqlite/scene_test.go | 6 +- pkg/utils/phash.go | 22 ++- .../SceneDuplicateChecker.tsx | 134 ++++++++++++------ .../SceneDuplicateChecker/styles.scss | 4 + ui/v2.5/src/locales/en-GB.json | 6 + 11 files changed, 177 insertions(+), 75 deletions(-) diff --git a/graphql/documents/queries/scene.graphql b/graphql/documents/queries/scene.graphql index 1f762855aa4..e62303dc789 100644 --- a/graphql/documents/queries/scene.graphql +++ b/graphql/documents/queries/scene.graphql @@ -20,8 +20,8 @@ query FindScenesByPathRegex($filter: FindFilterType) { } } -query FindDuplicateScenes($distance: Int) { - findDuplicateScenes(distance: $distance) { +query FindDuplicateScenes($distance: Int, $duration_diff: Float) { + findDuplicateScenes(distance: $distance, duration_diff: $duration_diff) { ...SlimSceneData } } diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 112f8aba997..3a4f6e738f5 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -14,8 +14,16 @@ type Query { findScenesByPathRegex(filter: FindFilterType): FindScenesResultType! - """ Returns any groups of scenes that are perceptual duplicates within the queried distance """ - findDuplicateScenes(distance: Int): [[Scene!]!]! + """ + Returns any groups of scenes that are perceptual duplicates within the queried distance + and the difference between their duration is smaller than durationDiff + """ + findDuplicateScenes( + distance: Int, + """Max difference in seconds between files in order to be considered for similarity matching. + Fractional seconds are ok: 0.5 will mean only files that have durations within 0.5 seconds between them will be matched based on PHash distance.""" + duration_diff: Float + ): [[Scene!]!]! """Return valid stream paths""" sceneStreams(id: ID): [SceneStreamEndpoint!]! @@ -295,14 +303,14 @@ type Mutation { metadataClean(input: CleanMetadataInput!): ID! """Identifies scenes using scrapers. Returns the job ID""" metadataIdentify(input: IdentifyMetadataInput!): ID! - + """Migrate generated files for the current hash naming""" migrateHashNaming: ID! """Migrates legacy scene screenshot files into the blob storage""" migrateSceneScreenshots(input: MigrateSceneScreenshotsInput!): ID! """Migrates blobs from the old storage system to the current one""" migrateBlobs(input: MigrateBlobsInput!): ID! - + """Anonymise the database in a separate file. Optionally returns a link to download the database file""" anonymiseDatabase(input: AnonymiseDatabaseInput!): String diff --git a/internal/api/resolver_query_find_scene.go b/internal/api/resolver_query_find_scene.go index 1eaa2dc03bd..c60cf88c283 100644 --- a/internal/api/resolver_query_find_scene.go +++ b/internal/api/resolver_query_find_scene.go @@ -220,13 +220,17 @@ func (r *queryResolver) ParseSceneFilenames(ctx context.Context, filter *models. return ret, nil } -func (r *queryResolver) FindDuplicateScenes(ctx context.Context, distance *int) (ret [][]*models.Scene, err error) { +func (r *queryResolver) FindDuplicateScenes(ctx context.Context, distance *int, durationDiff *float64) (ret [][]*models.Scene, err error) { dist := 0 + durDiff := -1. if distance != nil { dist = *distance } + if durationDiff != nil { + durDiff = *durationDiff + } if err := r.withReadTxn(ctx, func(ctx context.Context) error { - ret, err = r.repository.Scene.FindDuplicates(ctx, dist) + ret, err = r.repository.Scene.FindDuplicates(ctx, dist, durDiff) return err }); err != nil { return nil, err diff --git a/pkg/models/mocks/SceneReaderWriter.go b/pkg/models/mocks/SceneReaderWriter.go index f67a909b4d4..7ee47e9060e 100644 --- a/pkg/models/mocks/SceneReaderWriter.go +++ b/pkg/models/mocks/SceneReaderWriter.go @@ -439,7 +439,7 @@ func (_m *SceneReaderWriter) FindByPerformerID(ctx context.Context, performerID } // FindDuplicates provides a mock function with given fields: ctx, distance -func (_m *SceneReaderWriter) FindDuplicates(ctx context.Context, distance int) ([][]*models.Scene, error) { +func (_m *SceneReaderWriter) FindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*models.Scene, error) { ret := _m.Called(ctx, distance) var r0 [][]*models.Scene diff --git a/pkg/models/scene.go b/pkg/models/scene.go index ac9cd93c891..90655ff5ecc 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -153,7 +153,7 @@ type SceneReader interface { FindByPath(ctx context.Context, path string) ([]*Scene, error) FindByPerformerID(ctx context.Context, performerID int) ([]*Scene, error) FindByGalleryID(ctx context.Context, performerID int) ([]*Scene, error) - FindDuplicates(ctx context.Context, distance int) ([][]*Scene, error) + FindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*Scene, error) GalleryIDLoader PerformerIDLoader diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index a049557daa8..721a4d456e1 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -36,23 +36,38 @@ const ( ) var findExactDuplicateQuery = ` -SELECT GROUP_CONCAT(scenes.id) as ids -FROM scenes -INNER JOIN scenes_files ON (scenes.id = scenes_files.scene_id) -INNER JOIN files ON (scenes_files.file_id = files.id) -INNER JOIN files_fingerprints ON (scenes_files.file_id = files_fingerprints.file_id AND files_fingerprints.type = 'phash') -GROUP BY files_fingerprints.fingerprint -HAVING COUNT(files_fingerprints.fingerprint) > 1 AND COUNT(DISTINCT scenes.id) > 1 -ORDER BY SUM(files.size) DESC; +SELECT GROUP_CONCAT(DISTINCT scene_id) as ids +FROM ( + SELECT scenes.id as scene_id + , video_files.duration as file_duration + , files.size as file_size + , files_fingerprints.fingerprint as phash + , abs(max(video_files.duration) OVER (PARTITION by files_fingerprints.fingerprint) - video_files.duration) as durationDiff + FROM scenes + INNER JOIN scenes_files ON (scenes.id = scenes_files.scene_id) + INNER JOIN files ON (scenes_files.file_id = files.id) + INNER JOIN files_fingerprints ON (scenes_files.file_id = files_fingerprints.file_id AND files_fingerprints.type = 'phash') + INNER JOIN video_files ON (files.id == video_files.file_id) +) +WHERE durationDiff <= ?1 + OR ?1 < 0 -- Always TRUE if the parameter is negative. + -- That will disable the durationDiff checking. +GROUP BY phash +HAVING COUNT(phash) > 1 + AND COUNT(DISTINCT scene_id) > 1 +ORDER BY SUM(file_size) DESC; ` var findAllPhashesQuery = ` -SELECT scenes.id as id, files_fingerprints.fingerprint as phash +SELECT scenes.id as id + , files_fingerprints.fingerprint as phash + , video_files.duration as duration FROM scenes -INNER JOIN scenes_files ON (scenes.id = scenes_files.scene_id) -INNER JOIN files ON (scenes_files.file_id = files.id) +INNER JOIN scenes_files ON (scenes.id = scenes_files.scene_id) +INNER JOIN files ON (scenes_files.file_id = files.id) INNER JOIN files_fingerprints ON (scenes_files.file_id = files_fingerprints.file_id AND files_fingerprints.type = 'phash') -ORDER BY files.size DESC +INNER JOIN video_files ON (files.id == video_files.file_id) +ORDER BY files.size DESC; ` type sceneRow struct { @@ -1729,11 +1744,11 @@ func (qb *SceneStore) GetStashIDs(ctx context.Context, sceneID int) ([]models.St return qb.stashIDRepository().get(ctx, sceneID) } -func (qb *SceneStore) FindDuplicates(ctx context.Context, distance int) ([][]*models.Scene, error) { +func (qb *SceneStore) FindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*models.Scene, error) { var dupeIds [][]int if distance == 0 { var ids []string - if err := qb.tx.Select(ctx, &ids, findExactDuplicateQuery); err != nil { + if err := qb.tx.Select(ctx, &ids, findExactDuplicateQuery, durationDiff); err != nil { return nil, err } @@ -1755,7 +1770,8 @@ func (qb *SceneStore) FindDuplicates(ctx context.Context, distance int) ([][]*mo if err := qb.queryFunc(ctx, findAllPhashesQuery, nil, false, func(rows *sqlx.Rows) error { phash := utils.Phash{ - Bucket: -1, + Bucket: -1, + Duration: -1, } if err := rows.StructScan(&phash); err != nil { return err @@ -1767,7 +1783,7 @@ func (qb *SceneStore) FindDuplicates(ctx context.Context, distance int) ([][]*mo return nil, err } - dupeIds = utils.FindDuplicates(hashes, distance) + dupeIds = utils.FindDuplicates(hashes, distance, durationDiff) } var duplicates [][]*models.Scene diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index 560d3fcfcae..137319c31f6 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -4237,7 +4237,8 @@ func TestSceneStore_FindDuplicates(t *testing.T) { withRollbackTxn(func(ctx context.Context) error { distance := 0 - got, err := qb.FindDuplicates(ctx, distance) + durationDiff := -1. + got, err := qb.FindDuplicates(ctx, distance, durationDiff) if err != nil { t.Errorf("SceneStore.FindDuplicates() error = %v", err) return nil @@ -4246,7 +4247,8 @@ func TestSceneStore_FindDuplicates(t *testing.T) { assert.Len(t, got, dupeScenePhashes) distance = 1 - got, err = qb.FindDuplicates(ctx, distance) + durationDiff = -1. + got, err = qb.FindDuplicates(ctx, distance, durationDiff) if err != nil { t.Errorf("SceneStore.FindDuplicates() error = %v", err) return nil diff --git a/pkg/utils/phash.go b/pkg/utils/phash.go index 7b15ec5e06b..395d86f9335 100644 --- a/pkg/utils/phash.go +++ b/pkg/utils/phash.go @@ -1,6 +1,7 @@ package utils import ( + "math" "strconv" "github.com/corona10/goimagehash" @@ -8,21 +9,28 @@ import ( ) type Phash struct { - SceneID int `db:"id"` - Hash int64 `db:"phash"` + SceneID int `db:"id"` + Hash int64 `db:"phash"` + Duration float64 `db:"duration"` Neighbors []int Bucket int } -func FindDuplicates(hashes []*Phash, distance int) [][]int { +func FindDuplicates(hashes []*Phash, distance int, durationDiff float64) [][]int { for i, scene := range hashes { sceneHash := goimagehash.NewImageHash(uint64(scene.Hash), goimagehash.PHash) for j, neighbor := range hashes { if i != j && scene.SceneID != neighbor.SceneID { - neighborHash := goimagehash.NewImageHash(uint64(neighbor.Hash), goimagehash.PHash) - neighborDistance, _ := sceneHash.Distance(neighborHash) - if neighborDistance <= distance { - scene.Neighbors = append(scene.Neighbors, j) + neighbourDurationDistance := 0. + if scene.Duration > 0 && neighbor.Duration > 0 { + neighbourDurationDistance = math.Abs(scene.Duration - neighbor.Duration) + } + if (neighbourDurationDistance <= durationDiff) || (durationDiff < 0) { + neighborHash := goimagehash.NewImageHash(uint64(neighbor.Hash), goimagehash.PHash) + neighborDistance, _ := sceneHash.Distance(neighborHash) + if neighborDistance <= distance { + scene.Neighbors = append(scene.Neighbors, j) + } } } } diff --git a/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx b/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx index 882664d2651..c45d1b29362 100644 --- a/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx +++ b/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx @@ -41,6 +41,8 @@ import { objectTitle } from "src/core/files"; const CLASSNAME = "duplicate-checker"; +const defaultDurationDiff = "1"; + export const SceneDuplicateChecker: React.FC = () => { const intl = useIntl(); const history = useHistory(); @@ -49,6 +51,9 @@ export const SceneDuplicateChecker: React.FC = () => { const currentPage = Number.parseInt(query.get("page") ?? "1", 10); const pageSize = Number.parseInt(query.get("size") ?? "20", 10); const hashDistance = Number.parseInt(query.get("distance") ?? "0", 10); + const durationDiff = Number.parseFloat( + query.get("durationDiff") ?? defaultDurationDiff + ); const [currentPageSize, setCurrentPageSize] = useState(pageSize); const [isMultiDelete, setIsMultiDelete] = useState(false); @@ -59,7 +64,10 @@ export const SceneDuplicateChecker: React.FC = () => { ); const { data, loading, refetch } = GQL.useFindDuplicateScenesQuery({ fetchPolicy: "no-cache", - variables: { distance: hashDistance }, + variables: { + distance: hashDistance, + duration_diff: durationDiff, + }, }); const { data: missingPhash } = GQL.useFindScenesQuery({ variables: { @@ -480,45 +488,91 @@ export const SceneDuplicateChecker: React.FC = () => {

- - - - - - - - setQuery({ - distance: - e.currentTarget.value === "0" - ? undefined - : e.currentTarget.value, - page: undefined, - }) - } - defaultValue={hashDistance} - className="input-control ml-4" - > - - - - - - - - - - - +
+ + + + + + + + setQuery({ + distance: + e.currentTarget.value === "0" + ? undefined + : e.currentTarget.value, + page: undefined, + }) + } + defaultValue={hashDistance} + className="input-control ml-4" + > + + + + + + + + + + + + + + + + + + + + setQuery({ + durationDiff: + e.currentTarget.value === defaultDurationDiff + ? undefined + : e.currentTarget.value, + page: undefined, + }) + } + defaultValue={durationDiff} + className="input-control ml-4" + > + + + + + + + + + +
{maybeRenderMissingPhashWarning()} {renderPagination()} diff --git a/ui/v2.5/src/components/SceneDuplicateChecker/styles.scss b/ui/v2.5/src/components/SceneDuplicateChecker/styles.scss index 24084527a83..9177a9367c9 100644 --- a/ui/v2.5/src/components/SceneDuplicateChecker/styles.scss +++ b/ui/v2.5/src/components/SceneDuplicateChecker/styles.scss @@ -10,4 +10,8 @@ .separator { height: 50px; } + + .form-group .row { + align-items: center; + } } diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 73f9a73e7a5..e049b1792f1 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -855,6 +855,11 @@ "donate": "Donate", "dupe_check": { "description": "Levels below 'Exact' can take longer to calculate. False positives might also be returned on lower accuracy levels.", + "duration_diff": "Maximum Duration Difference", + "duration_options": { + "any": "Any", + "equal": "Equal" + }, "found_sets": "{setCount, plural, one{# set of duplicates found.} other {# sets of duplicates found.}}", "options": { "exact": "Exact", @@ -1077,6 +1082,7 @@ "saved_filters": "Saved filters", "update_filter": "Update Filter" }, + "second": "Second", "seconds": "Seconds", "settings": "Settings", "setup": { From 79bc5c914fe24d36e057799679065977632b9e73 Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Wed, 3 May 2023 09:05:30 +0200 Subject: [PATCH 011/135] WallPanel refactor (#3686) --- .../Scenes/SceneDetails/SceneMarkersPanel.tsx | 11 +- ui/v2.5/src/components/Scenes/SceneList.tsx | 4 +- .../src/components/Scenes/SceneMarkerList.tsx | 4 +- ui/v2.5/src/components/Wall/WallItem.tsx | 225 ++++++++++-------- ui/v2.5/src/components/Wall/WallPanel.tsx | 119 +++++---- 5 files changed, 208 insertions(+), 155 deletions(-) diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx index ed1f4d7c079..21e23af23c0 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx @@ -3,7 +3,7 @@ import { Button } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; -import { WallPanel } from "src/components/Wall/WallPanel"; +import { MarkerWallPanel } from "src/components/Wall/WallPanel"; import { PrimaryTags } from "./PrimaryTags"; import { SceneMarkerForm } from "./SceneMarkerForm"; @@ -77,11 +77,12 @@ export const SceneMarkersPanel: React.FC = ( onEdit={onOpenEditor} /> - { + { + e.preventDefault(); window.scrollTo(0, 0); - onClickMarker(marker as GQL.SceneMarkerDataFragment); + onClickMarker(marker); }} /> diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index ada2d69dbd6..3156a8a253b 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -14,7 +14,7 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { Tagger } from "../Tagger/scenes/SceneTagger"; import { IPlaySceneOptions, SceneQueue } from "src/models/sceneQueue"; -import { WallPanel } from "../Wall/WallPanel"; +import { SceneWallPanel } from "../Wall/WallPanel"; import { SceneListTable } from "./SceneListTable"; import { EditScenesDialog } from "./EditScenesDialog"; import { DeleteScenesDialog } from "./DeleteScenesDialog"; @@ -314,7 +314,7 @@ export const SceneList: React.FC = ({ } if (filter.displayMode === DisplayMode.Wall) { return ( - diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx index af6635940ef..6de661671c2 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx @@ -12,7 +12,7 @@ import NavUtils from "src/utils/navigation"; import { makeItemList, PersistanceLevel } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; -import { WallPanel } from "../Wall/WallPanel"; +import { MarkerWallPanel } from "../Wall/WallPanel"; const SceneMarkerItemList = makeItemList({ filterMode: GQL.FilterMode.SceneMarkers, @@ -88,7 +88,7 @@ export const SceneMarkerList: React.FC = ({ if (filter.displayMode === DisplayMode.Wall) { return ( - + ); } } diff --git a/ui/v2.5/src/components/Wall/WallItem.tsx b/ui/v2.5/src/components/Wall/WallItem.tsx index 8f35559448d..686a355ccfb 100644 --- a/ui/v2.5/src/components/Wall/WallItem.tsx +++ b/ui/v2.5/src/components/Wall/WallItem.tsx @@ -1,4 +1,11 @@ -import React, { useRef, useState, useEffect, useMemo } from "react"; +import React, { + useRef, + useState, + useEffect, + useCallback, + MouseEvent, + useMemo, +} from "react"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import TextUtils from "src/utils/text"; @@ -9,18 +16,20 @@ import { ConfigurationContext } from "src/hooks/Config"; import { markerTitle } from "src/core/markers"; import { objectTitle } from "src/core/files"; -interface IWallItemProps { +export type WallItemType = keyof WallItemData; + +export type WallItemData = { + scene: GQL.SlimSceneDataFragment; + sceneMarker: GQL.SceneMarkerDataFragment; + image: GQL.SlimImageDataFragment; +}; + +interface IWallItemProps { + type: T; index?: number; - scene?: GQL.SlimSceneDataFragment; + data: WallItemData[T]; sceneQueue?: SceneQueue; - sceneMarker?: GQL.SceneMarkerDataFragment; - image?: GQL.SlimImageDataFragment; - clickHandler?: ( - item: - | GQL.SlimSceneDataFragment - | GQL.SceneMarkerDataFragment - | GQL.SlimImageDataFragment - ) => void; + clickHandler?: (e: MouseEvent, item: WallItemData[T]) => void; className: string; } @@ -31,26 +40,29 @@ interface IPreviews { } const Preview: React.FC<{ - previews?: IPreviews; + previews: IPreviews; config?: GQL.ConfigDataFragment; active: boolean; }> = ({ previews, config, active }) => { - const videoElement = useRef() as React.MutableRefObject; + const videoEl = useRef(null); const [isMissing, setIsMissing] = useState(false); const previewType = config?.interface?.wallPlayback; const soundOnPreview = config?.interface?.soundOnPreview ?? false; useEffect(() => { - if (!videoElement.current) return; - videoElement.current.muted = !(soundOnPreview && active); + const video = videoEl.current; + if (!video) return; + + video.muted = !(soundOnPreview && active); if (previewType !== "video") { - if (active) videoElement.current.play(); - else videoElement.current.pause(); + if (active) { + video.play(); + } else { + video.pause(); + } } - }, [videoElement, previewType, soundOnPreview, active]); - - if (!previews) return
; + }, [previewType, soundOnPreview, active]); const image = ( ); @@ -105,108 +117,123 @@ const Preview: React.FC<{ ); }; -export const WallItem: React.FC = (props: IWallItemProps) => { +export const WallItem = ({ + type, + index, + data, + sceneQueue, + clickHandler, + className, +}: IWallItemProps) => { const [active, setActive] = useState(false); - const wallItem = useRef() as React.MutableRefObject; + const itemEl = useRef(null); const { configuration: config } = React.useContext(ConfigurationContext); const showTextContainer = config?.interface.wallShowTitle ?? true; - const previews = props.sceneMarker - ? { - video: props.sceneMarker.stream, - animation: props.sceneMarker.preview, - image: props.sceneMarker.screenshot, - } - : props.scene - ? { - video: props.scene?.paths.preview ?? undefined, - animation: props.scene?.paths.webp ?? undefined, - image: props.scene?.paths.screenshot ?? undefined, - } - : props.image - ? { - image: props.image?.paths.thumbnail ?? undefined, - } - : undefined; + const previews = useMemo(() => { + switch (type) { + case "scene": + const scene = data as GQL.SlimSceneDataFragment; + return { + video: scene.paths.preview ?? undefined, + animation: scene.paths.webp ?? undefined, + image: scene.paths.screenshot ?? undefined, + }; + case "sceneMarker": + const sceneMarker = data as GQL.SceneMarkerDataFragment; + return { + video: sceneMarker.stream, + animation: sceneMarker.preview, + image: sceneMarker.screenshot, + }; + case "image": + const image = data as GQL.SlimImageDataFragment; + return { + image: image.paths.thumbnail ?? undefined, + }; + default: + // this is unreachable, inference fails for some reason + return type as never; + } + }, [type, data]); + const linkSrc = useMemo(() => { + switch (type) { + case "scene": + const scene = data as GQL.SlimSceneDataFragment; + return sceneQueue + ? sceneQueue.makeLink(scene.id, { sceneIndex: index }) + : `/scenes/${scene.id}`; + case "sceneMarker": + const sceneMarker = data as GQL.SceneMarkerDataFragment; + return NavUtils.makeSceneMarkerUrl(sceneMarker); + case "image": + const image = data as GQL.SlimImageDataFragment; + return `/images/${image.id}`; + default: + return type; + } + }, [type, data, sceneQueue, index]); + const title = useMemo(() => { + switch (type) { + case "scene": + const scene = data as GQL.SlimSceneDataFragment; + return objectTitle(scene); + case "sceneMarker": + const sceneMarker = data as GQL.SceneMarkerDataFragment; + const newTitle = markerTitle(sceneMarker); + const seconds = TextUtils.secondsToTimestamp(sceneMarker.seconds); + if (newTitle) { + return `${newTitle} - ${seconds}`; + } else { + return seconds; + } + case "image": + return ""; + default: + return type; + } + }, [type, data]); + const tags = useMemo(() => { + if (type === "sceneMarker") { + const sceneMarker = data as GQL.SceneMarkerDataFragment; + return [sceneMarker.primary_tag, ...sceneMarker.tags]; + } + }, [type, data]); const setInactive = () => setActive(false); - const toggleActive = (e: TransitionEvent) => { + const toggleActive = useCallback((e: TransitionEvent) => { if (e.propertyName === "transform" && e.elapsedTime === 0) { // Get the current scale of the wall-item. If it's smaller than 1.1 the item is being scaled up, otherwise down. - const matrixScale = getComputedStyle(wallItem.current).transform.match( + const matrixScale = getComputedStyle(itemEl.current!).transform.match( /-?\d+\.?\d+|\d+/g )?.[0]; const scale = Number.parseFloat(matrixScale ?? "2") || 2; - setActive(scale <= 1.1 && !active); + setActive((value) => scale <= 1.1 && !value); } - }; + }, []); useEffect(() => { - const { current } = wallItem; - current?.addEventListener("transitioncancel", setInactive); - current?.addEventListener("transitionstart", toggleActive); + const item = itemEl.current!; + item.addEventListener("transitioncancel", setInactive); + item.addEventListener("transitionstart", toggleActive); return () => { - current?.removeEventListener("transitioncancel", setInactive); - current?.removeEventListener("transitionstart", toggleActive); + item.removeEventListener("transitioncancel", setInactive); + item.removeEventListener("transitionstart", toggleActive); }; - }); + }, [toggleActive]); - const clickHandler = () => { - if (props.scene) { - props?.clickHandler?.(props.scene); - } - if (props.sceneMarker) { - props?.clickHandler?.(props.sceneMarker); - } - if (props.image) { - props?.clickHandler?.(props.image); - } + const onClick = (e: MouseEvent) => { + clickHandler?.(e, data); }; - const cont = config?.interface.continuePlaylistDefault ?? false; - - let linkSrc: string = "#"; - if (!props.clickHandler) { - if (props.scene) { - linkSrc = props.sceneQueue - ? props.sceneQueue.makeLink(props.scene.id, { - sceneIndex: props.index, - continue: cont, - }) - : `/scenes/${props.scene.id}`; - } else if (props.sceneMarker) { - linkSrc = NavUtils.makeSceneMarkerUrl(props.sceneMarker); - } else if (props.image) { - linkSrc = `/images/${props.image.id}`; - } - } - - const title = useMemo(() => { - if (props.sceneMarker) { - return `${markerTitle( - props.sceneMarker - )} - ${TextUtils.secondsToTimestamp(props.sceneMarker.seconds)}`; - } - - if (props.scene) { - return objectTitle(props.scene); - } - - return ""; - }, [props.sceneMarker, props.scene]); - const renderText = () => { if (!showTextContainer) return; - const tags = props.sceneMarker - ? [props.sceneMarker.primary_tag, ...props.sceneMarker.tags] - : []; - return (
{title}
- {tags.map((tag) => ( + {tags?.map((tag) => ( {tag.name} @@ -217,8 +244,8 @@ export const WallItem: React.FC = (props: IWallItemProps) => { return (
-
- +
+ {renderText()} diff --git a/ui/v2.5/src/components/Wall/WallPanel.tsx b/ui/v2.5/src/components/Wall/WallPanel.tsx index 2d7d5c932c0..91ee7607f4c 100644 --- a/ui/v2.5/src/components/Wall/WallPanel.tsx +++ b/ui/v2.5/src/components/Wall/WallPanel.tsx @@ -1,19 +1,13 @@ -import React from "react"; +import React, { MouseEvent } from "react"; import * as GQL from "src/core/generated-graphql"; import { SceneQueue } from "src/models/sceneQueue"; -import { WallItem } from "./WallItem"; +import { WallItem, WallItemData, WallItemType } from "./WallItem"; -interface IWallPanelProps { - scenes?: GQL.SlimSceneDataFragment[]; +interface IWallPanelProps { + type: T; + data: WallItemData[T][]; sceneQueue?: SceneQueue; - sceneMarkers?: GQL.SceneMarkerDataFragment[]; - images?: GQL.SlimImageDataFragment[]; - clickHandler?: ( - item: - | GQL.SlimSceneDataFragment - | GQL.SceneMarkerDataFragment - | GQL.SlimImageDataFragment - ) => void; + clickHandler?: (e: MouseEvent, item: WallItemData[T]) => void; } const calculateClass = (index: number, count: number) => { @@ -33,53 +27,84 @@ const calculateClass = (index: number, count: number) => { if (index % 5 === 4) return "transform-origin-right"; // Multiple of five if (index % 5 === 0) return "transform-origin-left"; - // Position is equal or larger than first postion in last row + // Position is equal or larger than first position in last row if (count - (count % 5 || 5) <= index + 1) return "transform-origin-bottom"; // Default return "transform-origin-center"; }; -export const WallPanel: React.FC = ( - props: IWallPanelProps -) => { - const scenes = (props.scenes ?? []).map((scene, index, sceneArray) => ( - - )); - - const sceneMarkers = (props.sceneMarkers ?? []).map( - (marker, index, markerArray) => ( +const WallPanel = ({ + type, + data, + sceneQueue, + clickHandler, +}: IWallPanelProps) => { + function renderItems() { + return data.map((item, index, arr) => ( - ) - ); - - const images = (props.images ?? []).map((image, index, imageArray) => ( - - )); + )); + } return (
- {scenes} - {sceneMarkers} - {images} + {renderItems()}
); }; + +interface IImageWallPanelProps { + images: GQL.SlimImageDataFragment[]; + clickHandler?: (e: MouseEvent, item: GQL.SlimImageDataFragment) => void; +} + +export const ImageWallPanel: React.FC = ({ + images, + clickHandler, +}) => { + return ; +}; + +interface IMarkerWallPanelProps { + markers: GQL.SceneMarkerDataFragment[]; + clickHandler?: (e: MouseEvent, item: GQL.SceneMarkerDataFragment) => void; +} + +export const MarkerWallPanel: React.FC = ({ + markers, + clickHandler, +}) => { + return ( + + ); +}; + +interface ISceneWallPanelProps { + scenes: GQL.SlimSceneDataFragment[]; + sceneQueue?: SceneQueue; + clickHandler?: (e: MouseEvent, item: GQL.SlimSceneDataFragment) => void; +} + +export const SceneWallPanel: React.FC = ({ + scenes, + sceneQueue, + clickHandler, +}) => { + return ( + + ); +}; From f3f7ee7fd28feafd3f3f0ccb345ad9a798fc47e7 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 4 May 2023 08:24:58 +1000 Subject: [PATCH 012/135] Fix cover generation error --- internal/manager/task_generate_screenshot.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/manager/task_generate_screenshot.go b/internal/manager/task_generate_screenshot.go index b3cd93e38d2..a245dcdca8d 100644 --- a/internal/manager/task_generate_screenshot.go +++ b/internal/manager/task_generate_screenshot.go @@ -21,17 +21,20 @@ func (t *GenerateCoverTask) GetDescription() string { } func (t *GenerateCoverTask) Start(ctx context.Context) { - if !t.required(ctx) { - return - } - scenePath := t.Scene.Path + var required bool if err := t.txnManager.WithReadTxn(ctx, func(ctx context.Context) error { + required = t.required(ctx) + return t.Scene.LoadPrimaryFile(ctx, t.txnManager.File) }); err != nil { logger.Error(err) } + + if !required { + return + } videoFile := t.Scene.Files.Primary() if videoFile == nil { @@ -89,6 +92,7 @@ func (t *GenerateCoverTask) Start(ctx context.Context) { } // required returns true if the sprite needs to be generated +// assumes in a transaction func (t *GenerateCoverTask) required(ctx context.Context) bool { if t.Scene.Path == "" { return false From b7d179e448b5f63326aa6a08bc840a5223bca756 Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Thu, 4 May 2023 05:33:39 +0200 Subject: [PATCH 013/135] Fix deceptive WEBM playback in Safari (#3676) * Fix babel deoptimization warning in vite dev server * Fix videojs HMR * Fix fake WEBM support in Safari --- .../components/ScenePlayer/ScenePlayer.tsx | 135 +++++++++--------- .../components/ScenePlayer/source-selector.ts | 24 ++-- .../src/components/ScenePlayer/styles.scss | 11 +- .../components/Scenes/SceneDetails/Scene.tsx | 1 - ui/v2.5/vite.config.js | 6 +- 5 files changed, 95 insertions(+), 82 deletions(-) diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index 1a4c5e87d89..bb04eec3f99 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -1,5 +1,6 @@ import React, { KeyboardEvent, + useCallback, useContext, useEffect, useMemo, @@ -159,7 +160,6 @@ function getMarkerTitle(marker: MarkerFragment) { } interface IScenePlayerProps { - className?: string; scene: GQL.SceneDataFragment | undefined | null; hideScrubberOverride: boolean; autoplay?: boolean; @@ -172,7 +172,6 @@ interface IScenePlayerProps { } export const ScenePlayer: React.FC = ({ - className, scene, hideScrubberOverride, autoplay, @@ -186,15 +185,14 @@ export const ScenePlayer: React.FC = ({ const { configuration } = useContext(ConfigurationContext); const interfaceConfig = configuration?.interface; const uiConfig = configuration?.ui as IUIConfig | undefined; - const videoRef = useRef(null); - const playerRef = useRef(); + const videoRef = useRef(null); + const [_player, setPlayer] = useState(); const sceneId = useRef(); const [sceneSaveActivity] = useSceneSaveActivity(); const [sceneIncrementPlayCount] = useSceneIncrementPlayCount(); const [time, setTime] = useState(0); const [ready, setReady] = useState(false); - const [sessionInitialised, setSessionInitialised] = useState(false); // tracks play session. This is reset whenever ScenePlayer page is exited const { interactive: interactiveClient, @@ -230,6 +228,12 @@ export const ScenePlayer: React.FC = ({ [file, permitLoop, maxLoopDuration] ); + const getPlayer = useCallback(() => { + if (!_player) return null; + if (_player.isDisposed()) return null; + return _player; + }, [_player]); + useEffect(() => { if (hideScrubberOverride || fullscreen) { setShowScrubber(false); @@ -249,18 +253,19 @@ export const ScenePlayer: React.FC = ({ useEffect(() => { sendSetTimestamp((value: number) => { - const player = playerRef.current; + const player = getPlayer(); if (player && value >= 0) { player.play()?.then(() => { player.currentTime(value); }); } }); - }, [sendSetTimestamp]); + }, [sendSetTimestamp, getPlayer]); // Initialize VideoJS player useEffect(() => { const options: VideoJsPlayerOptions = { + id: VIDEO_PLAYER_ID, controls: true, controlBar: { pictureInPictureToggle: false, @@ -292,6 +297,7 @@ export const ScenePlayer: React.FC = ({ playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2], inactivityTimeout: 2000, preload: "none", + playsinline: true, userActions: { hotkeys: function (this: VideoJsPlayer, event) { handleHotkeys(this, event); @@ -314,33 +320,42 @@ export const ScenePlayer: React.FC = ({ }, }; - const player = videojs(videoRef.current!, options); + const videoEl = document.createElement("video-js"); + videoEl.setAttribute("data-vjs-player", "true"); + videoEl.classList.add("vjs-big-play-centered"); + videoRef.current!.appendChild(videoEl); + + const vjs = videojs(videoEl, options); /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - const settings = (player as any).textTrackSettings; + const settings = (vjs as any).textTrackSettings; settings.setValues({ backgroundColor: "#000", backgroundOpacity: "0.5", }); settings.updateDisplay(); - player.focus(); - playerRef.current = player; + vjs.focus(); + setPlayer(vjs); // Video player destructor return () => { - playerRef.current = undefined; - player.dispose(); + vjs.dispose(); + videoEl.remove(); + setPlayer(undefined); + + // reset sceneId to force reload sources + sceneId.current = undefined; }; }, []); useEffect(() => { - const player = playerRef.current; + const player = getPlayer(); if (!player) return; const skipButtons = player.skipButtons(); skipButtons.setForwardHandler(onNext); skipButtons.setBackwardHandler(onPrevious); - }, [onNext, onPrevious]); + }, [getPlayer, onNext, onPrevious]); useEffect(() => { if (scene?.interactive && interactiveInitialised) { @@ -358,6 +373,9 @@ export const ScenePlayer: React.FC = ({ // Player event handlers useEffect(() => { + const player = getPlayer(); + if (!player) return; + function canplay(this: VideoJsPlayer) { if (initialTimestamp.current !== -1) { this.currentTime(initialTimestamp.current); @@ -381,9 +399,6 @@ export const ScenePlayer: React.FC = ({ setFullscreen(this.isFullscreen()); } - const player = playerRef.current; - if (!player) return; - player.on("canplay", canplay); player.on("playing", playing); player.on("loadstart", loadstart); @@ -395,9 +410,12 @@ export const ScenePlayer: React.FC = ({ player.off("loadstart", loadstart); player.off("fullscreenchange", fullscreenchange); }; - }, []); + }, [getPlayer]); useEffect(() => { + const player = getPlayer(); + if (!player) return; + function onplay(this: VideoJsPlayer) { this.persistVolume().enabled = true; if (scene?.interactive && interactiveReady.current) { @@ -424,9 +442,6 @@ export const ScenePlayer: React.FC = ({ setTime(this.currentTime()); } - const player = playerRef.current; - if (!player) return; - player.on("play", onplay); player.on("pause", pause); player.on("seeking", seeking); @@ -438,26 +453,22 @@ export const ScenePlayer: React.FC = ({ player.off("seeking", seeking); player.off("timeupdate", timeupdate); }; - }, [interactiveClient, scene]); + }, [getPlayer, interactiveClient, scene]); useEffect(() => { - const player = playerRef.current; + const player = getPlayer(); if (!player) return; // don't re-initialise the player unless the scene has changed if (!scene || !file || scene.id === sceneId.current) return; - // if new scene was picked from playlist - if (playerRef.current && sceneId.current) { - if (trackActivity) { - playerRef.current.trackActivity().reset(); - } - } - sceneId.current = scene.id; setReady(false); + // reset on new scene + player.trackActivity().reset(); + // always stop the interactive client on initialisation interactiveClient.pause(); interactiveReady.current = false; @@ -546,19 +557,19 @@ export const ScenePlayer: React.FC = ({ const alwaysStartFromBeginning = uiConfig?.alwaysStartFromBeginning ?? false; + const resumeTime = scene.resume_time ?? 0; let startPosition = _initialTimestamp; if ( !startPosition && - !(alwaysStartFromBeginning || sessionInitialised) && - file.duration > scene.resume_time! + !alwaysStartFromBeginning && + file.duration > resumeTime ) { - startPosition = scene.resume_time!; + startPosition = resumeTime; } initialTimestamp.current = startPosition; setTime(startPosition); - setSessionInitialised(true); player.load(); player.focus(); @@ -574,11 +585,10 @@ export const ScenePlayer: React.FC = ({ interactiveClient.pause(); }; }, [ + getPlayer, file, scene, - trackActivity, interactiveClient, - sessionInitialised, autoplay, interfaceConfig?.autostartVideo, uiConfig?.alwaysStartFromBeginning, @@ -586,7 +596,7 @@ export const ScenePlayer: React.FC = ({ ]); useEffect(() => { - const player = playerRef.current; + const player = getPlayer(); if (!player || !scene) return; const markers = player.markers(); @@ -603,10 +613,10 @@ export const ScenePlayer: React.FC = ({ } else { player.poster(""); } - }, [scene]); + }, [getPlayer, scene]); useEffect(() => { - const player = playerRef.current; + const player = getPlayer(); if (!player) return; async function saveActivity(resumeTime: number, playDuration: number) { @@ -637,6 +647,7 @@ export const ScenePlayer: React.FC = ({ activity.minimumPlayPercent = minimumPlayPercent; activity.setEnabled(trackActivity); }, [ + getPlayer, scene, trackActivity, minimumPlayPercent, @@ -645,15 +656,16 @@ export const ScenePlayer: React.FC = ({ ]); useEffect(() => { - const player = playerRef.current; + const player = getPlayer(); if (!player) return; player.loop(looping); interactiveClient.setLooping(looping); - }, [interactiveClient, looping]); + }, [getPlayer, interactiveClient, looping]); useEffect(() => { - if (!scene || !ready || !auto.current) { + const player = getPlayer(); + if (!player || !scene || !ready || !auto.current) { return; } @@ -666,9 +678,6 @@ export const ScenePlayer: React.FC = ({ return; } - const player = playerRef.current; - if (!player) return; - player.play()?.catch(() => { // Browser probably blocking non-muted autoplay, so mute and try again player.persistVolume().enabled = false; @@ -677,35 +686,36 @@ export const ScenePlayer: React.FC = ({ player.play(); }); auto.current = false; - }, [scene, ready, interactiveClient, currentScript]); + }, [getPlayer, scene, ready, interactiveClient, currentScript]); + // Attach handler for onComplete event useEffect(() => { - // Attach handler for onComplete event - const player = playerRef.current; + const player = getPlayer(); if (!player) return; player.on("ended", onComplete); return () => player.off("ended"); - }, [onComplete]); + }, [getPlayer, onComplete]); - const onScrubberScroll = () => { + function onScrubberScroll() { if (started.current) { - playerRef.current?.pause(); + getPlayer()?.pause(); } - }; - const onScrubberSeek = (seconds: number) => { + } + + function onScrubberSeek(seconds: number) { if (started.current) { - playerRef.current?.currentTime(seconds); + getPlayer()?.currentTime(seconds); } else { initialTimestamp.current = seconds; setTime(seconds); } - }; + } // Override spacebar to always pause/play function onKeyDown(this: HTMLDivElement, event: KeyboardEvent) { - const player = playerRef.current; + const player = getPlayer(); if (!player) return; if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { @@ -730,17 +740,10 @@ export const ScenePlayer: React.FC = ({ className={cx("VideoPlayer", { portrait: isPortrait })} onKeyDownCapture={onKeyDown} > -
-
+
{scene?.interactive && (interactiveState !== ConnectionState.Ready || - playerRef.current?.paused()) && } + getPlayer()?.paused()) && } {scene && file && showScrubber && ( src === source); if (this.selectedIndex === -1) return; - const currentTime = player.currentTime(); - - // put the selected source at the top of the list - const loadSources = [...this.sources]; - const selectedSrc = loadSources.splice(this.selectedIndex, 1)[0]; - loadSources.unshift(selectedSrc); + const loadSrc = this.sources[this.selectedIndex]; + const currentTime = player.currentTime(); const paused = player.paused(); - player.src(loadSources); + + player.src(loadSrc); player.one("canplay", () => { if (paused) { player.pause(); @@ -128,10 +125,15 @@ class SourceSelectorPlugin extends videojs.getPlugin("plugin") { if (!player.videoWidth() && !player.videoHeight()) { // Occurs during preload when videos with supported audio/unsupported video are preloaded. // Treat this as a decoding error and try the next source without playing. - // However on Safari we get an media event when m3u8 is loaded which needs to be ignored. + // However on Safari we get an media event when m3u8 or mpd is loaded which needs to be ignored. if (player.error() !== null) return; + const currentSrc = player.currentSrc(); - if (currentSrc !== null && !currentSrc.includes(".m3u8")) { + if (currentSrc === null) return; + + if (currentSrc.includes(".m3u8") || currentSrc.includes(".mpd")) { + player.play(); + } else { player.error(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED); return; } @@ -156,7 +158,7 @@ class SourceSelectorPlugin extends videojs.getPlugin("plugin") { console.log(`Trying next source in playlist: '${newSource.label}'`); this.menu.setSources(this.sources); this.selectedIndex = 0; - player.src(this.sources); + player.src(newSource); player.load(); player.play(); } else { @@ -179,7 +181,7 @@ class SourceSelectorPlugin extends videojs.getPlugin("plugin") { } this.sources = sources; - this.player.src(this.sources); + this.player.src(sources[0]); } get textTracks(): HTMLTrackElement[] { diff --git a/ui/v2.5/src/components/ScenePlayer/styles.scss b/ui/v2.5/src/components/ScenePlayer/styles.scss index 010ed1dcc8f..c8bee39ea95 100644 --- a/ui/v2.5/src/components/ScenePlayer/styles.scss +++ b/ui/v2.5/src/components/ScenePlayer/styles.scss @@ -16,12 +16,12 @@ $sceneTabWidth: 450px; height: 100vh; } - &.portrait .video-js { + &.portrait .video-wrapper { height: 177.78vw; } } -.video-js { +.video-wrapper { height: 56.25vw; overflow: hidden; width: 100%; @@ -29,6 +29,11 @@ $sceneTabWidth: 450px; @media (min-width: 1200px) { height: 100%; } +} + +.video-js { + height: 100%; + width: 100%; .vjs-button { outline: none; @@ -109,7 +114,7 @@ $sceneTabWidth: 450px; width: 100%; .vjs-progress-holder { - margin: 0 1rem; + margin: 0 15px; } } diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index 847237cd608..5d6bb369083 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -765,7 +765,6 @@ const SceneLoader: React.FC = () => {
{ let plugins = [ - react(), + react({ + babel: { + compact: true, + }, + }), tsconfigPaths(), viteCompression({ algorithm: "gzip", From b1c61d2846d13ae5ccdcde4baefeab1f411bc255 Mon Sep 17 00:00:00 2001 From: Flashy78 <90150289+Flashy78@users.noreply.github.com> Date: Wed, 3 May 2023 21:13:35 -0700 Subject: [PATCH 014/135] Identify: Select existing value on edit (#3696) * Select field option on edit * Fix create missing display --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- .../Dialogs/IdentifyDialog/FieldOptions.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/ui/v2.5/src/components/Dialogs/IdentifyDialog/FieldOptions.tsx b/ui/v2.5/src/components/Dialogs/IdentifyDialog/FieldOptions.tsx index ba027cd5c6a..68a31fe6b32 100644 --- a/ui/v2.5/src/components/Dialogs/IdentifyDialog/FieldOptions.tsx +++ b/ui/v2.5/src/components/Dialogs/IdentifyDialog/FieldOptions.tsx @@ -96,17 +96,13 @@ const FieldOptionsEditor: React.FC = ({ }); } - if (!localOptions) { - return <>; - } - return ( {allowSetDefault ? ( setLocalOptions({ ...localOptions, @@ -122,7 +118,7 @@ const FieldOptionsEditor: React.FC = ({ type="radio" key={f[0]} id={`${field}-strategy-${f[0]}`} - checked={localOptions.strategy === f[1]} + checked={strategy === f[1]} onChange={() => setLocalOptions({ ...localOptions, @@ -168,7 +164,9 @@ const FieldOptionsEditor: React.FC = ({ (f) => f.field === localOptions.field )?.createMissing; - if (localOptions.strategy === undefined) { + // if allowSetDefault is false, then strategy is considered merge + // if its true, then its using the default value and should not be shown here + if (localOptions.strategy === undefined && allowSetDefault) { return; } From 39ebd92e60c467e70c6746d0fb60787ec679a502 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 4 May 2023 14:23:23 +1000 Subject: [PATCH 015/135] Format --- internal/manager/task_generate_screenshot.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/manager/task_generate_screenshot.go b/internal/manager/task_generate_screenshot.go index a245dcdca8d..384d8740c7b 100644 --- a/internal/manager/task_generate_screenshot.go +++ b/internal/manager/task_generate_screenshot.go @@ -26,12 +26,12 @@ func (t *GenerateCoverTask) Start(ctx context.Context) { var required bool if err := t.txnManager.WithReadTxn(ctx, func(ctx context.Context) error { required = t.required(ctx) - + return t.Scene.LoadPrimaryFile(ctx, t.txnManager.File) }); err != nil { logger.Error(err) } - + if !required { return } From 242f61b5df7ceeb8c4c2a26d53c311dc97bced48 Mon Sep 17 00:00:00 2001 From: dogwithakeyboard <128322708+dogwithakeyboard@users.noreply.github.com> Date: Thu, 4 May 2023 06:03:09 +0100 Subject: [PATCH 016/135] Lightbox movie covers (#3705) * movie page lightbox * Use styling instead of bootstrap classes --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- .../components/Movies/MovieDetails/Movie.tsx | 61 +++++++++++++++++-- ui/v2.5/src/components/Movies/styles.scss | 1 + ui/v2.5/src/components/Performers/styles.scss | 12 +++- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx index a9813698d81..4e723858c32 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx @@ -1,4 +1,5 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; +import { Button } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { Helmet } from "react-helmet"; import Mousetrap from "mousetrap"; @@ -12,6 +13,7 @@ import { useParams, useHistory } from "react-router-dom"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { ErrorMessage } from "src/components/Shared/ErrorMessage"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; +import { useLightbox } from "src/hooks/Lightbox/hooks"; import { ModalComponent } from "src/components/Shared/Modal"; import { useToast } from "src/hooks/Toast"; import { MovieScenesPanel } from "./MovieScenesPanel"; @@ -37,6 +39,43 @@ const MoviePage: React.FC = ({ movie }) => { const [backImage, setBackImage] = useState(); const [encodingImage, setEncodingImage] = useState(false); + const defaultImage = + movie.front_image_path && movie.front_image_path.includes("default=true") + ? true + : false; + + const lightboxImages = useMemo(() => { + const covers = [ + ...(movie.front_image_path && !defaultImage + ? [ + { + paths: { + thumbnail: movie.front_image_path, + image: movie.front_image_path, + }, + }, + ] + : []), + ...(movie.back_image_path + ? [ + { + paths: { + thumbnail: movie.back_image_path, + image: movie.back_image_path, + }, + }, + ] + : []), + ]; + return covers; + }, [movie.front_image_path, movie.back_image_path, defaultImage]); + + const index = lightboxImages.length; + + const showLightbox = useLightbox({ + images: lightboxImages, + }); + const [updateMovie, { loading: updating }] = useMovieUpdate(); const [deleteMovie, { loading: deleting }] = useMovieDestroy({ id: movie.id, @@ -129,12 +168,22 @@ const MoviePage: React.FC = ({ movie }) => { } } - if (image) { + if (image && defaultImage) { return (
Front Cover
); + } else if (image) { + return ( + + ); } } @@ -150,9 +199,13 @@ const MoviePage: React.FC = ({ movie }) => { if (image) { return ( -
+
+ ); } } diff --git a/ui/v2.5/src/components/Movies/styles.scss b/ui/v2.5/src/components/Movies/styles.scss index 09cfa97dac7..1a8d64e2abf 100644 --- a/ui/v2.5/src/components/Movies/styles.scss +++ b/ui/v2.5/src/components/Movies/styles.scss @@ -32,6 +32,7 @@ max-width: 100%; .movie-image-container { + box-shadow: none; margin: 1rem; } diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index 7bc932363fd..bef4fa0eb33 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -1,7 +1,13 @@ #performer-page { - .performer-image-container .performer { - max-height: calc(100vh - 6rem); - max-width: 100%; + .performer-image-container { + .btn { + box-shadow: none; + } + + .performer { + max-height: calc(100vh - 6rem); + max-width: 100%; + } } .content-container { From ca45c391da31cd9bf03b78662212dd7796034085 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 5 May 2023 09:39:09 +1000 Subject: [PATCH 017/135] Include missing fields in performer batch tag (#3718) --- internal/manager/task_stash_box_tag.go | 50 ++++++++++++++++---------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index 886da242fda..e927a033518 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -119,24 +119,28 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) { aliases = []string{} } newPerformer := models.Performer{ - Aliases: models.NewRelatedStrings(aliases), - Birthdate: getDate(performer.Birthdate), - CareerLength: getString(performer.CareerLength), - Country: getString(performer.Country), - CreatedAt: currentTime, - Ethnicity: getString(performer.Ethnicity), - EyeColor: getString(performer.EyeColor), - FakeTits: getString(performer.FakeTits), - Gender: models.GenderEnum(getString(performer.Gender)), - Height: getIntPtr(performer.Height), - Weight: getIntPtr(performer.Weight), - Instagram: getString(performer.Instagram), - Measurements: getString(performer.Measurements), - Name: *performer.Name, - Piercings: getString(performer.Piercings), - Tattoos: getString(performer.Tattoos), - Twitter: getString(performer.Twitter), - URL: getString(performer.URL), + Aliases: models.NewRelatedStrings(aliases), + Disambiguation: getString(performer.Disambiguation), + Details: getString(performer.Details), + Birthdate: getDate(performer.Birthdate), + DeathDate: getDate(performer.DeathDate), + CareerLength: getString(performer.CareerLength), + Country: getString(performer.Country), + CreatedAt: currentTime, + Ethnicity: getString(performer.Ethnicity), + EyeColor: getString(performer.EyeColor), + HairColor: getString(performer.HairColor), + FakeTits: getString(performer.FakeTits), + Gender: models.GenderEnum(getString(performer.Gender)), + Height: getIntPtr(performer.Height), + Weight: getIntPtr(performer.Weight), + Instagram: getString(performer.Instagram), + Measurements: getString(performer.Measurements), + Name: *performer.Name, + Piercings: getString(performer.Piercings), + Tattoos: getString(performer.Tattoos), + Twitter: getString(performer.Twitter), + URL: getString(performer.URL), StashIDs: models.NewRelatedStashIDs([]models.StashID{ { Endpoint: t.box.Endpoint, @@ -192,6 +196,10 @@ func (t *StashBoxPerformerTagTask) getPartial(performer *models.ScrapedPerformer value := getDate(performer.Birthdate) partial.Birthdate = models.NewOptionalDate(*value) } + if performer.DeathDate != nil && *performer.DeathDate != "" && !excluded["deathdate"] { + value := getDate(performer.DeathDate) + partial.Birthdate = models.NewOptionalDate(*value) + } if performer.CareerLength != nil && !excluded["career_length"] { partial.CareerLength = models.NewOptionalString(*performer.CareerLength) } @@ -204,6 +212,9 @@ func (t *StashBoxPerformerTagTask) getPartial(performer *models.ScrapedPerformer if performer.EyeColor != nil && !excluded["eye_color"] { partial.EyeColor = models.NewOptionalString(*performer.EyeColor) } + if performer.HairColor != nil && !excluded["hair_color"] { + partial.HairColor = models.NewOptionalString(*performer.HairColor) + } if performer.FakeTits != nil && !excluded["fake_tits"] { partial.FakeTits = models.NewOptionalString(*performer.FakeTits) } @@ -231,6 +242,9 @@ func (t *StashBoxPerformerTagTask) getPartial(performer *models.ScrapedPerformer if excluded["name"] && performer.Name != nil { partial.Name = models.NewOptionalString(*performer.Name) } + if performer.Disambiguation != nil && !excluded["disambiguation"] { + partial.Disambiguation = models.NewOptionalString(*performer.Disambiguation) + } if performer.Piercings != nil && !excluded["piercings"] { partial.Piercings = models.NewOptionalString(*performer.Piercings) } From c77ff8989b5061d7c6b8155a6448ba2e0f7482ca Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 5 May 2023 09:39:28 +1000 Subject: [PATCH 018/135] Include precision in rating star classname (#3719) --- ui/v2.5/src/components/Shared/Rating/RatingStars.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Shared/Rating/RatingStars.tsx b/ui/v2.5/src/components/Shared/Rating/RatingStars.tsx index 99e2e5be658..d50700ce739 100644 --- a/ui/v2.5/src/components/Shared/Rating/RatingStars.tsx +++ b/ui/v2.5/src/components/Shared/Rating/RatingStars.tsx @@ -240,8 +240,10 @@ export const RatingStars: React.FC = ( ); }; + const precisionClassName = `rating-stars-precision-${props.precision}`; + return ( -
+
{Array.from(Array(max)).map((value, index) => renderRatingButton(index + 1) )} From 490a2aca0880eb317077a8bf477676a99b71b6f9 Mon Sep 17 00:00:00 2001 From: Robin <132836850+robinyoublind2@users.noreply.github.com> Date: Tue, 9 May 2023 20:04:20 -0500 Subject: [PATCH 019/135] Log warning when library overlaps generated folder in scan (#3725) --- internal/manager/task_scan.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index 43d264c2206..02ebfbc3013 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -246,6 +246,7 @@ func newScanFilter(c *config.Instance, minModTime time.Time) *scanFilter { func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) bool { if fsutil.IsPathInDir(f.generatedPath, path) { + logger.Warnf("Skipping %q as it overlaps with the generated folder", path) return false } From e7abeeb4df663cbc276a1e205a23b88c07b022df Mon Sep 17 00:00:00 2001 From: CJ <72030708+Teda1@users.noreply.github.com> Date: Tue, 9 May 2023 18:06:58 -0700 Subject: [PATCH 020/135] fixes scene card width on front page for mobile (#3724) --- ui/v2.5/src/components/FrontPage/styles.scss | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/v2.5/src/components/FrontPage/styles.scss b/ui/v2.5/src/components/FrontPage/styles.scss index a1661d032bc..e4049b5aa58 100644 --- a/ui/v2.5/src/components/FrontPage/styles.scss +++ b/ui/v2.5/src/components/FrontPage/styles.scss @@ -306,17 +306,17 @@ } @media (max-width: 576px) { - .slick-list .scene-card, - .slick-list .studio-card, - .slick-list .gallery-card { + .slick-list .scene-card.card, + .slick-list .studio-card.card, + .slick-list .gallery-card.card { width: 20rem; } - .slick-list .movie-card { + .slick-list .movie-card.card { width: 16rem; } - .slick-list .performer-card { + .slick-list .performer-card.card { width: 16rem; } From 61c0098ae6c1e4971373fbcc9767cff6de83e8a9 Mon Sep 17 00:00:00 2001 From: puc9 <51006296+puc9@users.noreply.github.com> Date: Tue, 9 May 2023 18:16:49 -0700 Subject: [PATCH 021/135] Close input file so SafeMove can delete it (#3714) * Close input file so SafeMove can delete it This is happening on Windows and over the network but at the end of SafeMove it fails the move with an error that it can't remove the input because it is in use. It turns out it is in use by the SafeMove itself :) * Copy the src file mod time --- pkg/fsutil/file.go | 60 +++++++++++++++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/pkg/fsutil/file.go b/pkg/fsutil/file.go index 7d91679fe35..1bf98266675 100644 --- a/pkg/fsutil/file.go +++ b/pkg/fsutil/file.go @@ -11,29 +11,55 @@ import ( "github.com/stashapp/stash/pkg/logger" ) -// 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. -func SafeMove(src, dst string) error { - err := os.Rename(src, dst) +// CopyFile copies the contents of the file at srcpath to a regular file at dstpath. +// It will copy the last modified timestamp +// If dstpath already exists the function will fail. +func CopyFile(srcpath, dstpath string) (err error) { + r, err := os.Open(srcpath) + if err != nil { + return err + } + w, err := os.OpenFile(dstpath, os.O_CREATE|os.O_EXCL, 0666) if err != nil { - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() + r.Close() // We need to close the input file as the defer below would not be called. + return err + } - out, err := os.Create(dst) - if err != nil { - return err + defer func() { + r.Close() // ok to ignore error: file was opened read-only. + e := w.Close() + // Report the error from w.Close, if any. + // But do so only if there isn't already an outgoing error. + if e != nil && err == nil { + err = e } - defer out.Close() - - _, err = io.Copy(out, in) - if err != nil { - return err + // Copy modified time + if err == nil { + // io.Copy succeeded, we should fix the dstpath timestamp + srcFileInfo, e := os.Stat(srcpath) + if e != nil { + err = e + return + } + + e = os.Chtimes(dstpath, srcFileInfo.ModTime(), srcFileInfo.ModTime()) + if e != nil { + err = e + } } + }() + + _, err = io.Copy(w, r) + return err +} - err = out.Close() +// 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. +func SafeMove(src, dst string) error { + err := os.Rename(src, dst) + + if err != nil { + err = CopyFile(src, dst) if err != nil { return err } From 0069c48e7e9298936dc5494865d0a4f674d6b29a Mon Sep 17 00:00:00 2001 From: yoshnopa <40072150+yoshnopa@users.noreply.github.com> Date: Wed, 10 May 2023 03:37:01 +0200 Subject: [PATCH 022/135] Folder Gallery creation on a per folder basis (#3715) * GalleryInExClusion // Create Gallery from folder based on file, short description in setting * GalleryInExClusion // No Folderiteration, expansion of docs * GalleryInExClusion // Only accept lowercase files * GalleryInExClusion // Correct text in settings --- pkg/image/scan.go | 18 +++++++++++++++++- ui/v2.5/src/docs/en/Manual/Configuration.md | 12 ++++++++++++ ui/v2.5/src/locales/en-GB.json | 4 ++-- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/pkg/image/scan.go b/pkg/image/scan.go index 4c5280f6b95..20bd609dc7c 100644 --- a/pkg/image/scan.go +++ b/pkg/image/scan.go @@ -307,7 +307,23 @@ func (h *ScanHandler) getOrCreateGallery(ctx context.Context, f file.File) (*mod return h.getOrCreateZipBasedGallery(ctx, f.Base().ZipFile) } - if h.ScanConfig.GetCreateGalleriesFromFolders() { + // Look for specific filename in Folder to find out if the Folder is marked to be handled differently as the setting + folderPath := filepath.Dir(f.Base().Path) + + forceGallery := false + if _, err := os.Stat(filepath.Join(folderPath, ".forcegallery")); err == nil { + forceGallery = true + } else if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("Could not test Path %s: %w", folderPath, err) + } + exemptGallery := false + if _, err := os.Stat(filepath.Join(folderPath, ".nogallery")); err == nil { + exemptGallery = true + } else if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("Could not test Path %s: %w", folderPath, err) + } + + if forceGallery || (h.ScanConfig.GetCreateGalleriesFromFolders() && !exemptGallery) { return h.getOrCreateFolderBasedGallery(ctx, f) } diff --git a/ui/v2.5/src/docs/en/Manual/Configuration.md b/ui/v2.5/src/docs/en/Manual/Configuration.md index 99b00f219fd..ee5cd131a20 100644 --- a/ui/v2.5/src/docs/en/Manual/Configuration.md +++ b/ui/v2.5/src/docs/en/Manual/Configuration.md @@ -36,6 +36,18 @@ exclude: _a useful [link](https://regex101.com/) to experiment with regexps_ +## Gallery Creation from Folders + +In the Library section you can find an option to create a gallery from each folder containing images. This will be applied on all libraries when activated, including the base folder of a library. + +If you wish to apply this on a per folder basis, you can create a file called **.nogallery** or **.forcegallery** in a folder that should act different than this global setting. + +This will either exclude the folder from becoming a gallery even if the setting is set, or create a gallery from the folder even if the setting is not set. + +The file will only be recognized if written in lower case letters. + +Files with a dot in front are handled as hidden in the Linux OS and Mac OS, so you will not see those files after creation on your system without setting your file manager accordingly. + ## Hashing algorithms Stash identifies video files by calculating a hash of the file. There are two algorithms available for hashing: `oshash` and `MD5`. `MD5` requires reading the entire file, and can therefore be slow, particularly when reading files over a network. `oshash` (which uses OpenSubtitle's hashing algorithm) only reads 64k from each end of the file. diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index e049b1792f1..7226bd4ba79 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -283,7 +283,7 @@ "check_for_insecure_certificates_desc": "Some sites use insecure ssl certificates. When unticked the scraper skips the insecure certificates check and allows scraping of those sites. If you get a certificate error when scraping untick this.", "chrome_cdp_path": "Chrome CDP path", "chrome_cdp_path_desc": "File path to the Chrome executable, or a remote address (starting with http:// or https://, for example http://localhost:9222/json/version) to a Chrome instance.", - "create_galleries_from_folders_desc": "If true, creates galleries from folders containing images.", + "create_galleries_from_folders_desc": "If true, creates galleries from folders containing images by default. Create a File called .forcegallery or .nogallery in a folder to enforce/prevent this.", "create_galleries_from_folders_label": "Create galleries from folders containing images", "database": "Database", "db_path_head": "Database Path", @@ -1243,4 +1243,4 @@ "weight_kg": "Weight (kg)", "years_old": "years old", "zip_file_count": "Zip File Count" -} \ No newline at end of file +} From 0e199a525f3991b55f44e581cb28b80b35130703 Mon Sep 17 00:00:00 2001 From: yoshnopa <40072150+yoshnopa@users.noreply.github.com> Date: Tue, 16 May 2023 02:26:35 +0200 Subject: [PATCH 023/135] ChapterBug // Fix jump to wrong page if chapter number if (number - 1) % pagelength = 0 (#3730) --- ui/v2.5/src/hooks/Lightbox/Lightbox.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx index ca79805d79c..bae92ab0cdc 100644 --- a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx +++ b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx @@ -466,7 +466,7 @@ export const LightboxComponent: React.FC = ({ function gotoPage(imageIndex: number) { const indexInPage = (imageIndex - 1) % pageSize; if (pageCallback) { - let jumppage = Math.floor(imageIndex / pageSize) + 1; + let jumppage = Math.floor((imageIndex - 1) / pageSize) + 1; if (page !== jumppage) { pageCallback({ page: jumppage }); oldImages.current = images; From a2e477e1a753fc1852665f459685a8070d01c415 Mon Sep 17 00:00:00 2001 From: yoshnopa <40072150+yoshnopa@users.noreply.github.com> Date: Wed, 17 May 2023 01:30:51 +0200 Subject: [PATCH 024/135] Support image clips/gifs (#3583) Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/documents/data/config.graphql | 3 + graphql/documents/data/file.graphql | 44 +++++- graphql/documents/data/image-slim.graphql | 5 + graphql/documents/data/image.graphql | 5 + graphql/schema/types/config.graphql | 4 + graphql/schema/types/file.graphql | 4 +- graphql/schema/types/image.graphql | 8 +- graphql/schema/types/metadata.graphql | 6 + internal/api/resolver_model_image.go | 108 +++++++++----- internal/api/resolver_model_scene.go | 54 +++---- internal/api/resolver_mutation_configure.go | 4 + internal/api/resolver_mutation_image.go | 4 +- internal/api/resolver_query_configuration.go | 1 + internal/api/routes_image.go | 21 ++- internal/api/urlbuilders/image.go | 12 ++ internal/manager/config/config.go | 8 ++ internal/manager/config/tasks.go | 2 + internal/manager/fingerprint.go | 2 +- internal/manager/manager.go | 10 +- internal/manager/manager_tasks.go | 14 ++ internal/manager/repository.go | 1 - internal/manager/scene.go | 2 +- internal/manager/task_clean.go | 4 +- internal/manager/task_generate.go | 51 +++++++ .../manager/task_generate_clip_preview.go | 68 +++++++++ internal/manager/task_scan.go | 106 +++++++++++--- pkg/ffmpeg/transcoder/image.go | 6 +- pkg/file/frame.go | 20 +++ pkg/file/image/scan.go | 35 ++++- pkg/file/image_file.go | 12 ++ pkg/file/video_file.go | 15 +- pkg/image/delete.go | 14 +- pkg/image/export_test.go | 8 +- pkg/image/import.go | 8 +- pkg/image/scan.go | 39 ++--- pkg/image/service.go | 2 +- pkg/image/thumbnail.go | 135 +++++++++++++----- pkg/models/generate.go | 1 + pkg/models/model_image.go | 19 +-- pkg/models/paths/paths_generated.go | 5 + pkg/models/relationships.go | 87 ----------- pkg/sqlite/image.go | 15 +- pkg/sqlite/image_test.go | 17 ++- scripts/test_db_generator/makeTestDB.go | 5 + .../components/Galleries/GalleryViewer.tsx | 4 +- ui/v2.5/src/components/Help/Manual.tsx | 8 +- ui/v2.5/src/components/Images/ImageCard.tsx | 18 ++- .../components/Images/ImageDetails/Image.tsx | 18 ++- .../ImageDetails/ImageFileInfoPanel.tsx | 14 +- ui/v2.5/src/components/Images/ImageList.tsx | 11 +- .../src/components/Images/ImageWallItem.tsx | 57 ++++++++ .../Settings/SettingsLibraryPanel.tsx | 8 ++ .../Settings/Tasks/GenerateOptions.tsx | 6 + .../components/Settings/Tasks/ScanOptions.tsx | 7 + ui/v2.5/src/core/createClient.ts | 9 +- ui/v2.5/src/docs/en/Manual/Galleries.md | 12 -- ui/v2.5/src/docs/en/Manual/Images.md | 27 ++++ ui/v2.5/src/docs/en/Manual/Tasks.md | 2 + ui/v2.5/src/hooks/Lightbox/Lightbox.tsx | 32 +++-- ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx | 119 +++++++++++++-- ui/v2.5/src/hooks/Lightbox/types.ts | 8 ++ ui/v2.5/src/locales/en-GB.json | 6 + 62 files changed, 998 insertions(+), 362 deletions(-) create mode 100644 internal/manager/task_generate_clip_preview.go create mode 100644 pkg/file/frame.go create mode 100644 ui/v2.5/src/components/Images/ImageWallItem.tsx delete mode 100644 ui/v2.5/src/docs/en/Manual/Galleries.md create mode 100644 ui/v2.5/src/docs/en/Manual/Images.md diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql index a96341653ee..2a56e951252 100644 --- a/graphql/documents/data/config.graphql +++ b/graphql/documents/data/config.graphql @@ -25,6 +25,7 @@ fragment ConfigGeneralData on ConfigGeneralResult { maxTranscodeSize maxStreamingTranscodeSize writeImageThumbnails + createImageClipsFromVideos apiKey username password @@ -140,6 +141,7 @@ fragment ConfigDefaultSettingsData on ConfigDefaultSettingsResult { scanGenerateSprites scanGeneratePhashes scanGenerateThumbnails + scanGenerateClipPreviews } identify { @@ -180,6 +182,7 @@ fragment ConfigDefaultSettingsData on ConfigDefaultSettingsResult { transcodes phashes interactiveHeatmapsSpeeds + clipPreviews } deleteFile diff --git a/graphql/documents/data/file.graphql b/graphql/documents/data/file.graphql index 7acb95feb95..52a4c50f89b 100644 --- a/graphql/documents/data/file.graphql +++ b/graphql/documents/data/file.graphql @@ -43,4 +43,46 @@ fragment GalleryFileData on GalleryFile { type value } -} \ No newline at end of file +} + +fragment VisualFileData on VisualFile { + ... on BaseFile { + id + path + size + mod_time + fingerprints { + type + value + } + } + ... on ImageFile { + id + path + size + mod_time + width + height + fingerprints { + type + value + } + } + ... on VideoFile { + id + path + size + mod_time + duration + video_codec + audio_codec + width + height + frame_rate + bit_rate + fingerprints { + type + value + } + } +} diff --git a/graphql/documents/data/image-slim.graphql b/graphql/documents/data/image-slim.graphql index 4f787d36e30..9f84904dcfe 100644 --- a/graphql/documents/data/image-slim.graphql +++ b/graphql/documents/data/image-slim.graphql @@ -13,6 +13,7 @@ fragment SlimImageData on Image { paths { thumbnail + preview image } @@ -45,4 +46,8 @@ fragment SlimImageData on Image { favorite image_path } + + visual_files { + ...VisualFileData + } } diff --git a/graphql/documents/data/image.graphql b/graphql/documents/data/image.graphql index f9adb5515c7..155c940e4bc 100644 --- a/graphql/documents/data/image.graphql +++ b/graphql/documents/data/image.graphql @@ -15,6 +15,7 @@ fragment ImageData on Image { paths { thumbnail + preview image } @@ -33,4 +34,8 @@ fragment ImageData on Image { performers { ...PerformerData } + + visual_files { + ...VisualFileData + } } diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 904d235dd0e..6c99393858e 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -106,6 +106,8 @@ input ConfigGeneralInput { """Write image thumbnails to disk when generating on the fly""" writeImageThumbnails: Boolean + """Create Image Clips from Video extensions when Videos are disabled in Library""" + createImageClipsFromVideos: Boolean """Username""" username: String """Password""" @@ -215,6 +217,8 @@ type ConfigGeneralResult { """Write image thumbnails to disk when generating on the fly""" writeImageThumbnails: Boolean! + """Create Image Clips from Video extensions when Videos are disabled in Library""" + createImageClipsFromVideos: Boolean! """API Key""" apiKey: String! """Username""" diff --git a/graphql/schema/types/file.graphql b/graphql/schema/types/file.graphql index 09b733c3995..755d632154e 100644 --- a/graphql/schema/types/file.graphql +++ b/graphql/schema/types/file.graphql @@ -73,12 +73,14 @@ type ImageFile implements BaseFile { fingerprints: [Fingerprint!]! width: Int! - height: Int! + height: Int! created_at: Time! updated_at: Time! } +union VisualFile = VideoFile | ImageFile + type GalleryFile implements BaseFile { id: ID! path: String! diff --git a/graphql/schema/types/image.graphql b/graphql/schema/types/image.graphql index 6832cab24b9..c2e34f085e8 100644 --- a/graphql/schema/types/image.graphql +++ b/graphql/schema/types/image.graphql @@ -16,8 +16,9 @@ type Image { file_mod_time: Time @deprecated(reason: "Use files.mod_time") - file: ImageFileType! @deprecated(reason: "Use files.mod_time") - files: [ImageFile!]! + file: ImageFileType! @deprecated(reason: "Use visual_files") + files: [ImageFile!]! @deprecated(reason: "Use visual_files") + visual_files: [VisualFile!]! paths: ImagePathsType! # Resolver galleries: [Gallery!]! @@ -35,6 +36,7 @@ type ImageFileType { type ImagePathsType { thumbnail: String # Resolver + preview: String # Resolver image: String # Resolver } @@ -95,4 +97,4 @@ type FindImagesResultType { """Total file size in bytes""" filesize: Float! images: [Image!]! -} \ No newline at end of file +} diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index ecde11eacce..8e575b3ece8 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -14,6 +14,7 @@ input GenerateMetadataInput { forceTranscodes: Boolean phashes: Boolean interactiveHeatmapsSpeeds: Boolean + clipPreviews: Boolean """scene ids to generate for""" sceneIDs: [ID!] @@ -49,6 +50,7 @@ type GenerateMetadataOptions { transcodes: Boolean phashes: Boolean interactiveHeatmapsSpeeds: Boolean + clipPreviews: Boolean } type GeneratePreviewOptions { @@ -98,6 +100,8 @@ input ScanMetadataInput { scanGeneratePhashes: Boolean """Generate image thumbnails during scan""" scanGenerateThumbnails: Boolean + """Generate image clip previews during scan""" + scanGenerateClipPreviews: Boolean "Filter options for the scan" filter: ScanMetaDataFilterInput @@ -120,6 +124,8 @@ type ScanMetadataOptions { scanGeneratePhashes: Boolean! """Generate image thumbnails during scan""" scanGenerateThumbnails: Boolean! + """Generate image clip previews during scan""" + scanGenerateClipPreviews: Boolean! } input CleanMetadataInput { diff --git a/internal/api/resolver_model_image.go b/internal/api/resolver_model_image.go index 2a1965c4e96..9bfadafc7a4 100644 --- a/internal/api/resolver_model_image.go +++ b/internal/api/resolver_model_image.go @@ -12,42 +12,55 @@ import ( "github.com/stashapp/stash/pkg/models" ) -func (r *imageResolver) getPrimaryFile(ctx context.Context, obj *models.Image) (*file.ImageFile, error) { +func convertImageFile(f *file.ImageFile) *ImageFile { + ret := &ImageFile{ + ID: strconv.Itoa(int(f.ID)), + Path: f.Path, + Basename: f.Basename, + ParentFolderID: strconv.Itoa(int(f.ParentFolderID)), + ModTime: f.ModTime, + Size: f.Size, + Width: f.Width, + Height: f.Height, + CreatedAt: f.CreatedAt, + UpdatedAt: f.UpdatedAt, + Fingerprints: resolveFingerprints(f.Base()), + } + + if f.ZipFileID != nil { + zipFileID := strconv.Itoa(int(*f.ZipFileID)) + ret.ZipFileID = &zipFileID + } + + return ret +} + +func (r *imageResolver) getPrimaryFile(ctx context.Context, obj *models.Image) (file.VisualFile, error) { if obj.PrimaryFileID != nil { f, err := loaders.From(ctx).FileByID.Load(*obj.PrimaryFileID) if err != nil { return nil, err } - ret, ok := f.(*file.ImageFile) + asFrame, ok := f.(file.VisualFile) if !ok { - return nil, fmt.Errorf("file %T is not an image file", f) + return nil, fmt.Errorf("file %T is not an frame", f) } - return ret, nil + return asFrame, nil } return nil, nil } -func (r *imageResolver) getFiles(ctx context.Context, obj *models.Image) ([]*file.ImageFile, error) { +func (r *imageResolver) getFiles(ctx context.Context, obj *models.Image) ([]file.File, error) { fileIDs, err := loaders.From(ctx).ImageFiles.Load(obj.ID) if err != nil { return nil, err } files, errs := loaders.From(ctx).FileByID.LoadAll(fileIDs) - ret := make([]*file.ImageFile, len(files)) - for i, bf := range files { - f, ok := bf.(*file.ImageFile) - if !ok { - return nil, fmt.Errorf("file %T is not an image file", f) - } - - ret[i] = f - } - - return ret, firstError(errs) + return files, firstError(errs) } func (r *imageResolver) Title(ctx context.Context, obj *models.Image) (*string, error) { @@ -65,9 +78,9 @@ func (r *imageResolver) File(ctx context.Context, obj *models.Image) (*ImageFile return nil, nil } - width := f.Width - height := f.Height - size := f.Size + width := f.GetWidth() + height := f.GetHeight() + size := f.Base().Size return &ImageFileType{ Size: int(size), Width: width, @@ -75,6 +88,32 @@ func (r *imageResolver) File(ctx context.Context, obj *models.Image) (*ImageFile }, nil } +func convertVisualFile(f file.File) VisualFile { + switch f := f.(type) { + case *file.ImageFile: + return convertImageFile(f) + case *file.VideoFile: + return convertVideoFile(f) + default: + panic(fmt.Sprintf("unknown file type %T", f)) + } +} + +func (r *imageResolver) VisualFiles(ctx context.Context, obj *models.Image) ([]VisualFile, error) { + fileIDs, err := loaders.From(ctx).ImageFiles.Load(obj.ID) + if err != nil { + return nil, err + } + + files, errs := loaders.From(ctx).FileByID.LoadAll(fileIDs) + ret := make([]VisualFile, len(files)) + for i, f := range files { + ret[i] = convertVisualFile(f) + } + + return ret, firstError(errs) +} + func (r *imageResolver) Date(ctx context.Context, obj *models.Image) (*string, error) { if obj.Date != nil { result := obj.Date.String() @@ -89,27 +128,18 @@ func (r *imageResolver) Files(ctx context.Context, obj *models.Image) ([]*ImageF return nil, err } - ret := make([]*ImageFile, len(files)) + var ret []*ImageFile - for i, f := range files { - ret[i] = &ImageFile{ - ID: strconv.Itoa(int(f.ID)), - Path: f.Path, - Basename: f.Basename, - ParentFolderID: strconv.Itoa(int(f.ParentFolderID)), - ModTime: f.ModTime, - Size: f.Size, - Width: f.Width, - Height: f.Height, - CreatedAt: f.CreatedAt, - UpdatedAt: f.UpdatedAt, - Fingerprints: resolveFingerprints(f.Base()), + for _, f := range files { + // filter out non-image files + imageFile, ok := f.(*file.ImageFile) + if !ok { + continue } - if f.ZipFileID != nil { - zipFileID := strconv.Itoa(int(*f.ZipFileID)) - ret[i].ZipFileID = &zipFileID - } + thisFile := convertImageFile(imageFile) + + ret = append(ret, thisFile) } return ret, nil @@ -121,7 +151,7 @@ func (r *imageResolver) FileModTime(ctx context.Context, obj *models.Image) (*ti return nil, err } if f != nil { - return &f.ModTime, nil + return &f.Base().ModTime, nil } return nil, nil @@ -131,10 +161,12 @@ func (r *imageResolver) Paths(ctx context.Context, obj *models.Image) (*ImagePat baseURL, _ := ctx.Value(BaseURLCtxKey).(string) builder := urlbuilders.NewImageURLBuilder(baseURL, obj) thumbnailPath := builder.GetThumbnailURL() + previewPath := builder.GetPreviewURL() imagePath := builder.GetImageURL() return &ImagePathsType{ Image: &imagePath, Thumbnail: &thumbnailPath, + Preview: &previewPath, }, nil } diff --git a/internal/api/resolver_model_scene.go b/internal/api/resolver_model_scene.go index 99f42e64fbe..cd6f16a5785 100644 --- a/internal/api/resolver_model_scene.go +++ b/internal/api/resolver_model_scene.go @@ -14,6 +14,35 @@ import ( "github.com/stashapp/stash/pkg/utils" ) +func convertVideoFile(f *file.VideoFile) *VideoFile { + ret := &VideoFile{ + ID: strconv.Itoa(int(f.ID)), + Path: f.Path, + Basename: f.Basename, + ParentFolderID: strconv.Itoa(int(f.ParentFolderID)), + ModTime: f.ModTime, + Format: f.Format, + Size: f.Size, + Duration: handleFloat64Value(f.Duration), + VideoCodec: f.VideoCodec, + AudioCodec: f.AudioCodec, + Width: f.Width, + Height: f.Height, + FrameRate: handleFloat64Value(f.FrameRate), + BitRate: int(f.BitRate), + CreatedAt: f.CreatedAt, + UpdatedAt: f.UpdatedAt, + Fingerprints: resolveFingerprints(f.Base()), + } + + if f.ZipFileID != nil { + zipFileID := strconv.Itoa(int(*f.ZipFileID)) + ret.ZipFileID = &zipFileID + } + + return ret +} + func (r *sceneResolver) getPrimaryFile(ctx context.Context, obj *models.Scene) (*file.VideoFile, error) { if obj.PrimaryFileID != nil { f, err := loaders.From(ctx).FileByID.Load(*obj.PrimaryFileID) @@ -112,30 +141,7 @@ func (r *sceneResolver) Files(ctx context.Context, obj *models.Scene) ([]*VideoF ret := make([]*VideoFile, len(files)) for i, f := range files { - ret[i] = &VideoFile{ - ID: strconv.Itoa(int(f.ID)), - Path: f.Path, - Basename: f.Basename, - ParentFolderID: strconv.Itoa(int(f.ParentFolderID)), - ModTime: f.ModTime, - Format: f.Format, - Size: f.Size, - Duration: handleFloat64Value(f.Duration), - VideoCodec: f.VideoCodec, - AudioCodec: f.AudioCodec, - Width: f.Width, - Height: f.Height, - FrameRate: handleFloat64Value(f.FrameRate), - BitRate: int(f.BitRate), - CreatedAt: f.CreatedAt, - UpdatedAt: f.UpdatedAt, - Fingerprints: resolveFingerprints(f.Base()), - } - - if f.ZipFileID != nil { - zipFileID := strconv.Itoa(int(*f.ZipFileID)) - ret[i].ZipFileID = &zipFileID - } + ret[i] = convertVideoFile(f) } return ret, nil diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index 2a102af6eb0..bdc93137f17 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -218,6 +218,10 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen c.Set(config.WriteImageThumbnails, *input.WriteImageThumbnails) } + if input.CreateImageClipsFromVideos != nil { + c.Set(config.CreateImageClipsFromVideos, *input.CreateImageClipsFromVideos) + } + if input.GalleryCoverRegex != nil { _, err := regexp.Compile(*input.GalleryCoverRegex) diff --git a/internal/api/resolver_mutation_image.go b/internal/api/resolver_mutation_image.go index 6a482ff0446..353dab744ee 100644 --- a/internal/api/resolver_mutation_image.go +++ b/internal/api/resolver_mutation_image.go @@ -126,9 +126,9 @@ func (r *mutationResolver) imageUpdate(ctx context.Context, input ImageUpdateInp } // ensure that new primary file is associated with scene - var f *file.ImageFile + var f file.File for _, ff := range i.Files.List() { - if ff.ID == converted { + if ff.Base().ID == converted { f = ff } } diff --git a/internal/api/resolver_query_configuration.go b/internal/api/resolver_query_configuration.go index 643aa263bfb..4c9f00aea0d 100644 --- a/internal/api/resolver_query_configuration.go +++ b/internal/api/resolver_query_configuration.go @@ -106,6 +106,7 @@ func makeConfigGeneralResult() *ConfigGeneralResult { MaxTranscodeSize: &maxTranscodeSize, MaxStreamingTranscodeSize: &maxStreamingTranscodeSize, WriteImageThumbnails: config.IsWriteImageThumbnails(), + CreateImageClipsFromVideos: config.IsCreateImageClipsFromVideos(), GalleryCoverRegex: config.GetGalleryCoverRegex(), APIKey: config.GetAPIKey(), Username: config.GetUsername(), diff --git a/internal/api/routes_image.go b/internal/api/routes_image.go index 2685a7a762f..4ea612d3b73 100644 --- a/internal/api/routes_image.go +++ b/internal/api/routes_image.go @@ -40,6 +40,7 @@ func (rs imageRoutes) Routes() chi.Router { r.Get("/image", rs.Image) r.Get("/thumbnail", rs.Thumbnail) + r.Get("/preview", rs.Preview) }) return r @@ -64,13 +65,19 @@ func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) { return } - encoder := image.NewThumbnailEncoder(manager.GetInstance().FFMPEG) + clipPreviewOptions := image.ClipPreviewOptions{ + InputArgs: manager.GetInstance().Config.GetTranscodeInputArgs(), + OutputArgs: manager.GetInstance().Config.GetTranscodeOutputArgs(), + Preset: manager.GetInstance().Config.GetPreviewPreset().String(), + } + + encoder := image.NewThumbnailEncoder(manager.GetInstance().FFMPEG, manager.GetInstance().FFProbe, clipPreviewOptions) data, err := encoder.GetThumbnail(f, models.DefaultGthumbWidth) if err != nil { // don't log for unsupported image format // don't log for file not found - can optionally be logged in serveImage if !errors.Is(err, image.ErrNotSupportedForThumbnail) && !errors.Is(err, fs.ErrNotExist) { - logger.Errorf("error generating thumbnail for %s: %v", f.Path, err) + logger.Errorf("error generating thumbnail for %s: %v", f.Base().Path, err) var exitErr *exec.ExitError if errors.As(err, &exitErr) { @@ -96,6 +103,14 @@ func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) { } } +func (rs imageRoutes) Preview(w http.ResponseWriter, r *http.Request) { + img := r.Context().Value(imageKey).(*models.Image) + filepath := manager.GetInstance().Paths.Generated.GetClipPreviewPath(img.Checksum, models.DefaultGthumbWidth) + + // don't check if the preview exists - we'll just return a 404 if it doesn't + utils.ServeStaticFile(w, r, filepath) +} + func (rs imageRoutes) Image(w http.ResponseWriter, r *http.Request) { i := r.Context().Value(imageKey).(*models.Image) @@ -107,7 +122,7 @@ func (rs imageRoutes) serveImage(w http.ResponseWriter, r *http.Request, i *mode const defaultImageImage = "image/image.svg" if i.Files.Primary() != nil { - err := i.Files.Primary().Serve(&file.OsFS{}, w, r) + err := i.Files.Primary().Base().Serve(&file.OsFS{}, w, r) if err == nil { return } diff --git a/internal/api/urlbuilders/image.go b/internal/api/urlbuilders/image.go index 735ce9610a9..3bc77d30b26 100644 --- a/internal/api/urlbuilders/image.go +++ b/internal/api/urlbuilders/image.go @@ -3,12 +3,15 @@ package urlbuilders import ( "strconv" + "github.com/stashapp/stash/internal/manager" + "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/models" ) type ImageURLBuilder struct { BaseURL string ImageID string + Checksum string UpdatedAt string } @@ -16,6 +19,7 @@ func NewImageURLBuilder(baseURL string, image *models.Image) ImageURLBuilder { return ImageURLBuilder{ BaseURL: baseURL, ImageID: strconv.Itoa(image.ID), + Checksum: image.Checksum, UpdatedAt: strconv.FormatInt(image.UpdatedAt.Unix(), 10), } } @@ -27,3 +31,11 @@ func (b ImageURLBuilder) GetImageURL() string { func (b ImageURLBuilder) GetThumbnailURL() string { return b.BaseURL + "/image/" + b.ImageID + "/thumbnail?t=" + b.UpdatedAt } + +func (b ImageURLBuilder) GetPreviewURL() string { + if exists, err := fsutil.FileExists(manager.GetInstance().Paths.Generated.GetClipPreviewPath(b.Checksum, models.DefaultGthumbWidth)); exists && err == nil { + return b.BaseURL + "/image/" + b.ImageID + "/preview?" + b.UpdatedAt + } else { + return "" + } +} diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index fe973021914..44c64392515 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -96,6 +96,9 @@ const ( WriteImageThumbnails = "write_image_thumbnails" writeImageThumbnailsDefault = true + CreateImageClipsFromVideos = "create_image_clip_from_videos" + createImageClipsFromVideosDefault = false + Host = "host" hostDefault = "0.0.0.0" @@ -865,6 +868,10 @@ func (i *Instance) IsWriteImageThumbnails() bool { return i.getBool(WriteImageThumbnails) } +func (i *Instance) IsCreateImageClipsFromVideos() bool { + return i.getBool(CreateImageClipsFromVideos) +} + func (i *Instance) GetAPIKey() string { return i.getString(ApiKey) } @@ -1513,6 +1520,7 @@ func (i *Instance) setDefaultValues(write bool) error { i.main.SetDefault(ThemeColor, DefaultThemeColor) i.main.SetDefault(WriteImageThumbnails, writeImageThumbnailsDefault) + i.main.SetDefault(CreateImageClipsFromVideos, createImageClipsFromVideosDefault) i.main.SetDefault(Database, defaultDatabaseFilePath) diff --git a/internal/manager/config/tasks.go b/internal/manager/config/tasks.go index 1e541fcc54d..b87a1d23a7a 100644 --- a/internal/manager/config/tasks.go +++ b/internal/manager/config/tasks.go @@ -19,6 +19,8 @@ type ScanMetadataOptions struct { ScanGeneratePhashes bool `json:"scanGeneratePhashes"` // Generate image thumbnails during scan ScanGenerateThumbnails bool `json:"scanGenerateThumbnails"` + // Generate image thumbnails during scan + ScanGenerateClipPreviews bool `json:"scanGenerateClipPreviews"` } type AutoTagMetadataOptions struct { diff --git a/internal/manager/fingerprint.go b/internal/manager/fingerprint.go index 5c2c663527e..fc183cc6a1b 100644 --- a/internal/manager/fingerprint.go +++ b/internal/manager/fingerprint.go @@ -63,7 +63,7 @@ func (c *fingerprintCalculator) CalculateFingerprints(f *file.BaseFile, o file.O var ret []file.Fingerprint calculateMD5 := true - if isVideo(f.Basename) { + if useAsVideo(f.Path) { var ( fp *file.Fingerprint err error diff --git a/internal/manager/manager.go b/internal/manager/manager.go index a952b712ce0..6d776fcf756 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -279,11 +279,11 @@ func initialize() error { } func videoFileFilter(ctx context.Context, f file.File) bool { - return isVideo(f.Base().Basename) + return useAsVideo(f.Base().Path) } func imageFileFilter(ctx context.Context, f file.File) bool { - return isImage(f.Base().Basename) + return useAsImage(f.Base().Path) } func galleryFileFilter(ctx context.Context, f file.File) bool { @@ -306,8 +306,10 @@ func makeScanner(db *sqlite.Database, pluginCache *plugin.Cache) *file.Scanner { Filter: file.FilterFunc(videoFileFilter), }, &file.FilteredDecorator{ - Decorator: &file_image.Decorator{}, - Filter: file.FilterFunc(imageFileFilter), + Decorator: &file_image.Decorator{ + FFProbe: instance.FFProbe, + }, + Filter: file.FilterFunc(imageFileFilter), }, }, FingerprintCalculator: &fingerprintCalculator{instance.Config}, diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index 10bcacab08b..3987fb9ba3a 100644 --- a/internal/manager/manager_tasks.go +++ b/internal/manager/manager_tasks.go @@ -15,6 +15,20 @@ import ( "github.com/stashapp/stash/pkg/models" ) +func useAsVideo(pathname string) bool { + if instance.Config.IsCreateImageClipsFromVideos() && config.StashConfigs.GetStashFromDirPath(instance.Config.GetStashPaths(), pathname).ExcludeVideo { + return false + } + return isVideo(pathname) +} + +func useAsImage(pathname string) bool { + if instance.Config.IsCreateImageClipsFromVideos() && config.StashConfigs.GetStashFromDirPath(instance.Config.GetStashPaths(), pathname).ExcludeVideo { + return isImage(pathname) || isVideo(pathname) + } + return isImage(pathname) +} + func isZip(pathname string) bool { gExt := config.GetInstance().GetGalleryExtensions() return fsutil.MatchExtension(pathname, gExt) diff --git a/internal/manager/repository.go b/internal/manager/repository.go index 41ac5f12ed5..dd49c4af763 100644 --- a/internal/manager/repository.go +++ b/internal/manager/repository.go @@ -15,7 +15,6 @@ import ( type ImageReaderWriter interface { models.ImageReaderWriter image.FinderCreatorUpdater - models.ImageFileLoader GetManyFileIDs(ctx context.Context, ids []int) ([][]file.ID, error) } diff --git a/internal/manager/scene.go b/internal/manager/scene.go index a653cb6329f..39b96fec74f 100644 --- a/internal/manager/scene.go +++ b/internal/manager/scene.go @@ -88,7 +88,7 @@ func GetSceneStreamPaths(scene *models.Scene, directStreamURL *url.URL, maxStrea // convert StreamingResolutionEnum to ResolutionEnum maxStreamingResolution := models.ResolutionEnum(maxStreamingTranscodeSize) - sceneResolution := pf.GetMinResolution() + sceneResolution := file.GetMinResolution(pf) includeSceneStreamPath := func(streamingResolution models.StreamingResolutionEnum) bool { var minResolution int if streamingResolution == models.StreamingResolutionEnumOriginal { diff --git a/internal/manager/task_clean.go b/internal/manager/task_clean.go index b90f11be89d..5eb4d20a976 100644 --- a/internal/manager/task_clean.go +++ b/internal/manager/task_clean.go @@ -201,9 +201,9 @@ func (f *cleanFilter) shouldCleanFile(path string, info fs.FileInfo, stash *conf switch { case info.IsDir() || fsutil.MatchExtension(path, f.zipExt): return f.shouldCleanGallery(path, stash) - case fsutil.MatchExtension(path, f.vidExt): + case useAsVideo(path): return f.shouldCleanVideoFile(path, stash) - case fsutil.MatchExtension(path, f.imgExt): + case useAsImage(path): return f.shouldCleanImage(path, stash) default: logger.Infof("File extension does not match any media extensions. Marking to clean: \"%s\"", path) diff --git a/internal/manager/task_generate.go b/internal/manager/task_generate.go index c457ddedf5e..ce3d7100028 100644 --- a/internal/manager/task_generate.go +++ b/internal/manager/task_generate.go @@ -7,6 +7,7 @@ import ( "github.com/remeh/sizedwaitgroup" "github.com/stashapp/stash/internal/manager/config" + "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" @@ -29,6 +30,7 @@ type GenerateMetadataInput struct { ForceTranscodes bool `json:"forceTranscodes"` Phashes bool `json:"phashes"` InteractiveHeatmapsSpeeds bool `json:"interactiveHeatmapsSpeeds"` + ClipPreviews bool `json:"clipPreviews"` // scene ids to generate for SceneIDs []string `json:"sceneIDs"` // marker ids to generate for @@ -69,6 +71,7 @@ type totalsGenerate struct { transcodes int64 phashes int64 interactiveHeatmapSpeeds int64 + clipPreviews int64 tasks int } @@ -167,6 +170,9 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) { if j.input.InteractiveHeatmapsSpeeds { logMsg += fmt.Sprintf(" %d heatmaps & speeds", totals.interactiveHeatmapSpeeds) } + if j.input.ClipPreviews { + logMsg += fmt.Sprintf(" %d Image Clip Previews", totals.clipPreviews) + } if logMsg == "Generating" { logMsg = "Nothing selected to generate" } @@ -254,6 +260,38 @@ func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, que } } + *findFilter.Page = 1 + for more := j.input.ClipPreviews; more; { + if job.IsCancelled(ctx) { + return totals + } + + images, err := image.Query(ctx, j.txnManager.Image, nil, findFilter) + if err != nil { + logger.Errorf("Error encountered queuing files to scan: %s", err.Error()) + return totals + } + + for _, ss := range images { + if job.IsCancelled(ctx) { + return totals + } + + if err := ss.LoadFiles(ctx, j.txnManager.Image); err != nil { + logger.Errorf("Error encountered queuing files to scan: %s", err.Error()) + return totals + } + + j.queueImageJob(g, ss, queue, &totals) + } + + if len(images) != batchSize { + more = false + } else { + *findFilter.Page++ + } + } + return totals } @@ -434,3 +472,16 @@ func (j *GenerateJob) queueMarkerJob(g *generate.Generator, marker *models.Scene totals.tasks++ queue <- task } + +func (j *GenerateJob) queueImageJob(g *generate.Generator, image *models.Image, queue chan<- Task, totals *totalsGenerate) { + task := &GenerateClipPreviewTask{ + Image: *image, + Overwrite: j.overwrite, + } + + if task.required() { + totals.clipPreviews++ + totals.tasks++ + queue <- task + } +} diff --git a/internal/manager/task_generate_clip_preview.go b/internal/manager/task_generate_clip_preview.go new file mode 100644 index 00000000000..b43ca7514dc --- /dev/null +++ b/internal/manager/task_generate_clip_preview.go @@ -0,0 +1,68 @@ +package manager + +import ( + "context" + "fmt" + + "github.com/stashapp/stash/pkg/file" + "github.com/stashapp/stash/pkg/fsutil" + "github.com/stashapp/stash/pkg/image" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" +) + +type GenerateClipPreviewTask struct { + Image models.Image + Overwrite bool +} + +func (t *GenerateClipPreviewTask) GetDescription() string { + return fmt.Sprintf("Generating Preview for image Clip %s", t.Image.Path) +} + +func (t *GenerateClipPreviewTask) Start(ctx context.Context) { + if !t.required() { + return + } + + prevPath := GetInstance().Paths.Generated.GetClipPreviewPath(t.Image.Checksum, models.DefaultGthumbWidth) + filePath := t.Image.Files.Primary().Base().Path + + clipPreviewOptions := image.ClipPreviewOptions{ + InputArgs: GetInstance().Config.GetTranscodeInputArgs(), + OutputArgs: GetInstance().Config.GetTranscodeOutputArgs(), + Preset: GetInstance().Config.GetPreviewPreset().String(), + } + + encoder := image.NewThumbnailEncoder(GetInstance().FFMPEG, GetInstance().FFProbe, clipPreviewOptions) + data, err := encoder.GetPreview(t.Image.Files.Primary(), models.DefaultGthumbWidth) + if err != nil { + logger.Errorf("getting preview for image %s: %w", filePath, err) + return + } + + err = fsutil.WriteFile(prevPath, data) + if err != nil { + logger.Errorf("writing preview for image %s: %w", filePath, err) + return + } + +} + +func (t *GenerateClipPreviewTask) required() bool { + _, ok := t.Image.Files.Primary().(*file.VideoFile) + if !ok { + return false + } + + if t.Overwrite { + return true + } + + prevPath := GetInstance().Paths.Generated.GetClipPreviewPath(t.Image.Checksum, models.DefaultGthumbWidth) + if exists, _ := fsutil.FileExists(prevPath); exists { + return false + } + + return true +} diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index 02ebfbc3013..7c5e2015641 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -141,8 +141,8 @@ func newHandlerRequiredFilter(c *config.Instance) *handlerRequiredFilter { func (f *handlerRequiredFilter) Accept(ctx context.Context, ff file.File) bool { path := ff.Base().Path - isVideoFile := fsutil.MatchExtension(path, f.vidExt) - isImageFile := fsutil.MatchExtension(path, f.imgExt) + isVideoFile := useAsVideo(path) + isImageFile := useAsImage(path) isZipFile := fsutil.MatchExtension(path, f.zipExt) var counter fileCounter @@ -255,8 +255,8 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) return false } - isVideoFile := fsutil.MatchExtension(path, f.vidExt) - isImageFile := fsutil.MatchExtension(path, f.imgExt) + isVideoFile := useAsVideo(path) + isImageFile := useAsImage(path) isZipFile := fsutil.MatchExtension(path, f.zipExt) // handle caption files @@ -289,7 +289,7 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) // shortcut: skip the directory entirely if it matches both exclusion patterns // add a trailing separator so that it correctly matches against patterns like path/.* pathExcludeTest := path + string(filepath.Separator) - if (s.ExcludeVideo || matchFileRegex(pathExcludeTest, f.videoExcludeRegex)) && (s.ExcludeImage || matchFileRegex(pathExcludeTest, f.imageExcludeRegex)) { + if (matchFileRegex(pathExcludeTest, f.videoExcludeRegex)) && (s.ExcludeImage || matchFileRegex(pathExcludeTest, f.imageExcludeRegex)) { logger.Debugf("Skipping directory %s as it matches video and image exclusion patterns", path) return false } @@ -306,17 +306,14 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) } type scanConfig struct { - isGenerateThumbnails bool + isGenerateThumbnails bool + isGenerateClipPreviews bool } func (c *scanConfig) GetCreateGalleriesFromFolders() bool { return instance.Config.GetCreateGalleriesFromFolders() } -func (c *scanConfig) IsGenerateThumbnails() bool { - return c.isGenerateThumbnails -} - func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progress *job.Progress) []file.Handler { db := instance.Database pluginCache := instance.PluginCache @@ -325,11 +322,16 @@ func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progre &file.FilteredHandler{ Filter: file.FilterFunc(imageFileFilter), Handler: &image.ScanHandler{ - CreatorUpdater: db.Image, - GalleryFinder: db.Gallery, - ThumbnailGenerator: &imageThumbnailGenerator{}, + CreatorUpdater: db.Image, + GalleryFinder: db.Gallery, + ScanGenerator: &imageGenerators{ + input: options, + taskQueue: taskQueue, + progress: progress, + }, ScanConfig: &scanConfig{ - isGenerateThumbnails: options.ScanGenerateThumbnails, + isGenerateThumbnails: options.ScanGenerateThumbnails, + isGenerateClipPreviews: options.ScanGenerateClipPreviews, }, PluginCache: pluginCache, Paths: instance.Paths, @@ -362,35 +364,97 @@ func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progre } } -type imageThumbnailGenerator struct{} +type imageGenerators struct { + input ScanMetadataInput + taskQueue *job.TaskQueue + progress *job.Progress +} + +func (g *imageGenerators) Generate(ctx context.Context, i *models.Image, f file.File) error { + const overwrite = false + + progress := g.progress + t := g.input + path := f.Base().Path + config := instance.Config + sequentialScanning := config.GetSequentialScanning() + + if t.ScanGenerateThumbnails { + // this should be quick, so always generate sequentially + if err := g.generateThumbnail(ctx, i, f); err != nil { + logger.Errorf("Error generating thumbnail for %s: %v", path, err) + } + } + + // avoid adding a task if the file isn't a video file + _, isVideo := f.(*file.VideoFile) + if isVideo && t.ScanGenerateClipPreviews { + // this is a bit of a hack: the task requires files to be loaded, but + // we don't really need to since we already have the file + ii := *i + ii.Files = models.NewRelatedFiles([]file.File{f}) + + progress.AddTotal(1) + previewsFn := func(ctx context.Context) { + taskPreview := GenerateClipPreviewTask{ + Image: ii, + Overwrite: overwrite, + } + + taskPreview.Start(ctx) + progress.Increment() + } + + if sequentialScanning { + previewsFn(ctx) + } else { + g.taskQueue.Add(fmt.Sprintf("Generating preview for %s", path), previewsFn) + } + } + + return nil +} -func (g *imageThumbnailGenerator) GenerateThumbnail(ctx context.Context, i *models.Image, f *file.ImageFile) error { +func (g *imageGenerators) generateThumbnail(ctx context.Context, i *models.Image, f file.File) error { thumbPath := GetInstance().Paths.Generated.GetThumbnailPath(i.Checksum, models.DefaultGthumbWidth) exists, _ := fsutil.FileExists(thumbPath) if exists { return nil } - if f.Height <= models.DefaultGthumbWidth && f.Width <= models.DefaultGthumbWidth { + path := f.Base().Path + + asFrame, ok := f.(file.VisualFile) + if !ok { + return fmt.Errorf("file %s does not implement Frame", path) + } + + if asFrame.GetHeight() <= models.DefaultGthumbWidth && asFrame.GetWidth() <= models.DefaultGthumbWidth { return nil } - logger.Debugf("Generating thumbnail for %s", f.Path) + logger.Debugf("Generating thumbnail for %s", path) + + clipPreviewOptions := image.ClipPreviewOptions{ + InputArgs: instance.Config.GetTranscodeInputArgs(), + OutputArgs: instance.Config.GetTranscodeOutputArgs(), + Preset: instance.Config.GetPreviewPreset().String(), + } - encoder := image.NewThumbnailEncoder(instance.FFMPEG) + encoder := image.NewThumbnailEncoder(instance.FFMPEG, instance.FFProbe, clipPreviewOptions) data, err := encoder.GetThumbnail(f, models.DefaultGthumbWidth) if err != nil { // don't log for animated images if !errors.Is(err, image.ErrNotSupportedForThumbnail) { - return fmt.Errorf("getting thumbnail for image %s: %w", f.Path, err) + return fmt.Errorf("getting thumbnail for image %s: %w", path, err) } return nil } err = fsutil.WriteFile(thumbPath, data) if err != nil { - return fmt.Errorf("writing thumbnail for image %s: %w", f.Path, err) + return fmt.Errorf("writing thumbnail for image %s: %w", path, err) } return nil diff --git a/pkg/ffmpeg/transcoder/image.go b/pkg/ffmpeg/transcoder/image.go index a476dff42bb..4221a9a5402 100644 --- a/pkg/ffmpeg/transcoder/image.go +++ b/pkg/ffmpeg/transcoder/image.go @@ -10,6 +10,7 @@ var ErrUnsupportedFormat = errors.New("unsupported image format") type ImageThumbnailOptions struct { InputFormat ffmpeg.ImageFormat + OutputFormat ffmpeg.ImageFormat OutputPath string MaxDimensions int Quality int @@ -29,12 +30,15 @@ func ImageThumbnail(input string, options ImageThumbnailOptions) ffmpeg.Args { VideoFilter(videoFilter). VideoCodec(ffmpeg.VideoCodecMJpeg) + args = append(args, "-frames:v", "1") + if options.Quality > 0 { args = args.FixedQualityScaleVideo(options.Quality) } args = args.ImageFormat(ffmpeg.ImageFormatImage2Pipe). - Output(options.OutputPath) + Output(options.OutputPath). + ImageFormat(options.OutputFormat) return args } diff --git a/pkg/file/frame.go b/pkg/file/frame.go new file mode 100644 index 00000000000..de9f7466233 --- /dev/null +++ b/pkg/file/frame.go @@ -0,0 +1,20 @@ +package file + +// VisualFile is an interface for files that have a width and height. +type VisualFile interface { + File + GetWidth() int + GetHeight() int + GetFormat() string +} + +func GetMinResolution(f VisualFile) int { + w := f.GetWidth() + h := f.GetHeight() + + if w < h { + return w + } + + return h +} diff --git a/pkg/file/image/scan.go b/pkg/file/image/scan.go index a029f5ccedb..afe4210e047 100644 --- a/pkg/file/image/scan.go +++ b/pkg/file/image/scan.go @@ -9,12 +9,15 @@ import ( _ "image/jpeg" _ "image/png" + "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/file" + "github.com/stashapp/stash/pkg/file/video" _ "golang.org/x/image/webp" ) // Decorator adds image specific fields to a File. type Decorator struct { + FFProbe ffmpeg.FFProbe } func (d *Decorator) Decorate(ctx context.Context, fs file.FS, f file.File) (file.File, error) { @@ -25,16 +28,38 @@ func (d *Decorator) Decorate(ctx context.Context, fs file.FS, f file.File) (file } defer r.Close() - c, format, err := image.DecodeConfig(r) + probe, err := d.FFProbe.NewVideoFile(base.Path) if err != nil { - return f, fmt.Errorf("decoding image file %q: %w", base.Path, err) + fmt.Printf("Warning: File %q could not be read with ffprobe: %s, assuming ImageFile", base.Path, err) + c, format, err := image.DecodeConfig(r) + if err != nil { + return f, fmt.Errorf("decoding image file %q: %w", base.Path, err) + } + return &file.ImageFile{ + BaseFile: base, + Format: format, + Width: c.Width, + Height: c.Height, + }, nil + } + + isClip := true + // This list is derived from ffmpegImageThumbnail in pkg/image/thumbnail. If one gets updated, the other should be as well + for _, item := range []string{"png", "mjpeg", "webp"} { + if item == probe.VideoCodec { + isClip = false + } + } + if isClip { + videoFileDecorator := video.Decorator{FFProbe: d.FFProbe} + return videoFileDecorator.Decorate(ctx, fs, f) } return &file.ImageFile{ BaseFile: base, - Format: format, - Width: c.Width, - Height: c.Height, + Format: probe.VideoCodec, + Width: probe.Width, + Height: probe.Height, }, nil } diff --git a/pkg/file/image_file.go b/pkg/file/image_file.go index 4e1f5690aa0..0de2d9b9871 100644 --- a/pkg/file/image_file.go +++ b/pkg/file/image_file.go @@ -7,3 +7,15 @@ type ImageFile struct { Width int `json:"width"` Height int `json:"height"` } + +func (f ImageFile) GetWidth() int { + return f.Width +} + +func (f ImageFile) GetHeight() int { + return f.Height +} + +func (f ImageFile) GetFormat() string { + return f.Format +} diff --git a/pkg/file/video_file.go b/pkg/file/video_file.go index ec08aad872b..382c81e199c 100644 --- a/pkg/file/video_file.go +++ b/pkg/file/video_file.go @@ -16,13 +16,14 @@ type VideoFile struct { InteractiveSpeed *int `json:"interactive_speed"` } -func (f VideoFile) GetMinResolution() int { - w := f.Width - h := f.Height +func (f VideoFile) GetWidth() int { + return f.Width +} - if w < h { - return w - } +func (f VideoFile) GetHeight() int { + return f.Height +} - return h +func (f VideoFile) GetFormat() string { + return f.Format } diff --git a/pkg/image/delete.go b/pkg/image/delete.go index b61e77045ec..dba0fd58760 100644 --- a/pkg/image/delete.go +++ b/pkg/image/delete.go @@ -22,13 +22,19 @@ type FileDeleter struct { // MarkGeneratedFiles marks for deletion the generated files for the provided image. func (d *FileDeleter) MarkGeneratedFiles(image *models.Image) error { + var files []string thumbPath := d.Paths.Generated.GetThumbnailPath(image.Checksum, models.DefaultGthumbWidth) exists, _ := fsutil.FileExists(thumbPath) if exists { - return d.Files([]string{thumbPath}) + files = append(files, thumbPath) + } + prevPath := d.Paths.Generated.GetClipPreviewPath(image.Checksum, models.DefaultGthumbWidth) + exists, _ = fsutil.FileExists(prevPath) + if exists { + files = append(files, prevPath) } - return nil + return d.Files(files) } // Destroy destroys an image, optionally marking the file and generated files for deletion. @@ -87,7 +93,7 @@ func (s *Service) deleteFiles(ctx context.Context, i *models.Image, fileDeleter for _, f := range i.Files.List() { // only delete files where there is no other associated image - otherImages, err := s.Repository.FindByFileID(ctx, f.ID) + otherImages, err := s.Repository.FindByFileID(ctx, f.Base().ID) if err != nil { return err } @@ -99,7 +105,7 @@ func (s *Service) deleteFiles(ctx context.Context, i *models.Image, fileDeleter // don't delete files in zip archives const deleteFile = true - if f.ZipFileID == nil { + if f.Base().ZipFileID == nil { if err := file.Destroy(ctx, s.File, f, fileDeleter.Deleter, deleteFile); err != nil { return err } diff --git a/pkg/image/export_test.go b/pkg/image/export_test.go index 7f3393d6f1c..64a0ebb284a 100644 --- a/pkg/image/export_test.go +++ b/pkg/image/export_test.go @@ -45,11 +45,9 @@ var ( func createFullImage(id int) models.Image { return models.Image{ ID: id, - Files: models.NewRelatedImageFiles([]*file.ImageFile{ - { - BaseFile: &file.BaseFile{ - Path: path, - }, + Files: models.NewRelatedFiles([]file.File{ + &file.BaseFile{ + Path: path, }, }), Title: title, diff --git a/pkg/image/import.go b/pkg/image/import.go index b5e54e5947e..6dfc0bde823 100644 --- a/pkg/image/import.go +++ b/pkg/image/import.go @@ -97,7 +97,7 @@ func (i *Importer) imageJSONToImage(imageJSON jsonschema.Image) models.Image { } func (i *Importer) populateFiles(ctx context.Context) error { - files := make([]*file.ImageFile, 0) + files := make([]file.File, 0) for _, ref := range i.Input.Files { path := ref @@ -109,11 +109,11 @@ func (i *Importer) populateFiles(ctx context.Context) error { if f == nil { return fmt.Errorf("image file '%s' not found", path) } else { - files = append(files, f.(*file.ImageFile)) + files = append(files, f) } } - i.image.Files = models.NewRelatedImageFiles(files) + i.image.Files = models.NewRelatedFiles(files) return nil } @@ -311,7 +311,7 @@ func (i *Importer) FindExistingID(ctx context.Context) (*int, error) { var err error for _, f := range i.image.Files.List() { - existing, err = i.ReaderWriter.FindByFileID(ctx, f.ID) + existing, err = i.ReaderWriter.FindByFileID(ctx, f.Base().ID) if err != nil { return nil, err } diff --git a/pkg/image/scan.go b/pkg/image/scan.go index 20bd609dc7c..55eafdd97a0 100644 --- a/pkg/image/scan.go +++ b/pkg/image/scan.go @@ -29,7 +29,7 @@ type FinderCreatorUpdater interface { UpdatePartial(ctx context.Context, id int, updatedImage models.ImagePartial) (*models.Image, error) AddFileID(ctx context.Context, id int, fileID file.ID) error models.GalleryIDLoader - models.ImageFileLoader + models.FileLoader } type GalleryFinderCreator interface { @@ -40,14 +40,17 @@ type GalleryFinderCreator interface { type ScanConfig interface { GetCreateGalleriesFromFolders() bool - IsGenerateThumbnails() bool +} + +type ScanGenerator interface { + Generate(ctx context.Context, i *models.Image, f file.File) error } type ScanHandler struct { CreatorUpdater FinderCreatorUpdater GalleryFinder GalleryFinderCreator - ThumbnailGenerator ThumbnailGenerator + ScanGenerator ScanGenerator ScanConfig ScanConfig @@ -60,6 +63,9 @@ func (h *ScanHandler) validate() error { if h.CreatorUpdater == nil { return errors.New("CreatorUpdater is required") } + if h.ScanGenerator == nil { + return errors.New("ScanGenerator is required") + } if h.GalleryFinder == nil { return errors.New("GalleryFinder is required") } @@ -78,10 +84,7 @@ func (h *ScanHandler) Handle(ctx context.Context, f file.File, oldFile file.File return err } - imageFile, ok := f.(*file.ImageFile) - if !ok { - return ErrNotImageFile - } + imageFile := f.Base() // try to match the file to an image existing, err := h.CreatorUpdater.FindByFileID(ctx, imageFile.ID) @@ -141,22 +144,20 @@ func (h *ScanHandler) Handle(ctx context.Context, f file.File, oldFile file.File } } - if h.ScanConfig.IsGenerateThumbnails() { - // do this after the commit so that the transaction isn't held up - txn.AddPostCommitHook(ctx, func(ctx context.Context) { - for _, s := range existing { - if err := h.ThumbnailGenerator.GenerateThumbnail(ctx, s, imageFile); err != nil { - // just log if cover generation fails. We can try again on rescan - logger.Errorf("Error generating thumbnail for %s: %v", imageFile.Path, err) - } + // do this after the commit so that generation doesn't hold up the transaction + txn.AddPostCommitHook(ctx, func(ctx context.Context) { + for _, s := range existing { + if err := h.ScanGenerator.Generate(ctx, s, f); err != nil { + // just log if cover generation fails. We can try again on rescan + logger.Errorf("Error generating content for %s: %v", imageFile.Path, err) } - }) - } + } + }) return nil } -func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models.Image, f *file.ImageFile, updateExisting bool) error { +func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models.Image, f *file.BaseFile, updateExisting bool) error { for _, i := range existing { if err := i.LoadFiles(ctx, h.CreatorUpdater); err != nil { return err @@ -164,7 +165,7 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models. found := false for _, sf := range i.Files.List() { - if sf.ID == f.Base().ID { + if sf.Base().ID == f.Base().ID { found = true break } diff --git a/pkg/image/service.go b/pkg/image/service.go index 667317735fd..5aacc4e59c2 100644 --- a/pkg/image/service.go +++ b/pkg/image/service.go @@ -15,7 +15,7 @@ type FinderByFile interface { type Repository interface { FinderByFile Destroyer - models.ImageFileLoader + models.FileLoader } type Service struct { diff --git a/pkg/image/thumbnail.go b/pkg/image/thumbnail.go index 80c2139ccb2..ca6fd40b9f3 100644 --- a/pkg/image/thumbnail.go +++ b/pkg/image/thumbnail.go @@ -12,7 +12,6 @@ import ( "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/ffmpeg/transcoder" "github.com/stashapp/stash/pkg/file" - "github.com/stashapp/stash/pkg/models" ) const ffmpegImageQuality = 5 @@ -27,13 +26,17 @@ var ( ErrNotSupportedForThumbnail = errors.New("unsupported image format for thumbnail") ) -type ThumbnailGenerator interface { - GenerateThumbnail(ctx context.Context, i *models.Image, f *file.ImageFile) error +type ThumbnailEncoder struct { + FFMpeg *ffmpeg.FFMpeg + FFProbe ffmpeg.FFProbe + ClipPreviewOptions ClipPreviewOptions + vips *vipsEncoder } -type ThumbnailEncoder struct { - ffmpeg *ffmpeg.FFMpeg - vips *vipsEncoder +type ClipPreviewOptions struct { + InputArgs []string + OutputArgs []string + Preset string } func GetVipsPath() string { @@ -43,9 +46,11 @@ func GetVipsPath() string { return vipsPath } -func NewThumbnailEncoder(ffmpegEncoder *ffmpeg.FFMpeg) ThumbnailEncoder { +func NewThumbnailEncoder(ffmpegEncoder *ffmpeg.FFMpeg, ffProbe ffmpeg.FFProbe, clipPreviewOptions ClipPreviewOptions) ThumbnailEncoder { ret := ThumbnailEncoder{ - ffmpeg: ffmpegEncoder, + FFMpeg: ffmpegEncoder, + FFProbe: ffProbe, + ClipPreviewOptions: clipPreviewOptions, } vipsPath := GetVipsPath() @@ -61,7 +66,7 @@ func NewThumbnailEncoder(ffmpegEncoder *ffmpeg.FFMpeg) ThumbnailEncoder { // the provided max size. It resizes based on the largest X/Y direction. // It returns nil and an error if an error occurs reading, decoding or encoding // the image, or if the image is not suitable for thumbnails. -func (e *ThumbnailEncoder) GetThumbnail(f *file.ImageFile, maxSize int) ([]byte, error) { +func (e *ThumbnailEncoder) GetThumbnail(f file.File, maxSize int) ([]byte, error) { reader, err := f.Open(&file.OsFS{}) if err != nil { return nil, err @@ -75,47 +80,113 @@ func (e *ThumbnailEncoder) GetThumbnail(f *file.ImageFile, maxSize int) ([]byte, data := buf.Bytes() - format := f.Format - animated := f.Format == formatGif + if imageFile, ok := f.(*file.ImageFile); ok { + format := imageFile.Format + animated := imageFile.Format == formatGif + + // #2266 - if image is webp, then determine if it is animated + if format == formatWebP { + animated = isWebPAnimated(data) + } - // #2266 - if image is webp, then determine if it is animated - if format == formatWebP { - animated = isWebPAnimated(data) + // #2266 - don't generate a thumbnail for animated images + if animated { + return nil, fmt.Errorf("%w: %s", ErrNotSupportedForThumbnail, format) + } } - // #2266 - don't generate a thumbnail for animated images - if animated { - return nil, fmt.Errorf("%w: %s", ErrNotSupportedForThumbnail, format) + // Videofiles can only be thumbnailed with ffmpeg + if _, ok := f.(*file.VideoFile); ok { + return e.ffmpegImageThumbnail(buf, maxSize) } // vips has issues loading files from stdin on Windows if e.vips != nil && runtime.GOOS != "windows" { return e.vips.ImageThumbnail(buf, maxSize) } else { - return e.ffmpegImageThumbnail(buf, format, maxSize) + return e.ffmpegImageThumbnail(buf, maxSize) } } -func (e *ThumbnailEncoder) ffmpegImageThumbnail(image *bytes.Buffer, format string, maxSize int) ([]byte, error) { - var ffmpegFormat ffmpeg.ImageFormat - - switch format { - case "jpeg": - ffmpegFormat = ffmpeg.ImageFormatJpeg - case "png": - ffmpegFormat = ffmpeg.ImageFormatPng - case "webp": - ffmpegFormat = ffmpeg.ImageFormatWebp - default: - return nil, ErrUnsupportedImageFormat +// GetPreview returns the preview clip of the provided image clip resized to +// the provided max size. It resizes based on the largest X/Y direction. +// It returns nil and an error if an error occurs reading, decoding or encoding +// the image, or if the image is not suitable for thumbnails. +// It is hardcoded to 30 seconds maximum right now +func (e *ThumbnailEncoder) GetPreview(f file.File, maxSize int) ([]byte, error) { + reader, err := f.Open(&file.OsFS{}) + if err != nil { + return nil, err + } + defer reader.Close() + + buf := new(bytes.Buffer) + if _, err := buf.ReadFrom(reader); err != nil { + return nil, err } + fileData, err := e.FFProbe.NewVideoFile(f.Base().Path) + if err != nil { + return nil, err + } + if fileData.Width <= maxSize { + maxSize = fileData.Width + } + clipDuration := fileData.VideoStreamDuration + if clipDuration > 30.0 { + clipDuration = 30.0 + } + return e.getClipPreview(buf, maxSize, clipDuration, fileData.FrameRate) +} + +func (e *ThumbnailEncoder) ffmpegImageThumbnail(image *bytes.Buffer, maxSize int) ([]byte, error) { args := transcoder.ImageThumbnail("-", transcoder.ImageThumbnailOptions{ - InputFormat: ffmpegFormat, + OutputFormat: ffmpeg.ImageFormatJpeg, OutputPath: "-", MaxDimensions: maxSize, Quality: ffmpegImageQuality, }) - return e.ffmpeg.GenerateOutput(context.TODO(), args, image) + return e.FFMpeg.GenerateOutput(context.TODO(), args, image) +} + +func (e *ThumbnailEncoder) getClipPreview(image *bytes.Buffer, maxSize int, clipDuration float64, frameRate float64) ([]byte, error) { + var thumbFilter ffmpeg.VideoFilter + thumbFilter = thumbFilter.ScaleMaxSize(maxSize) + + var thumbArgs ffmpeg.Args + thumbArgs = thumbArgs.VideoFilter(thumbFilter) + + o := e.ClipPreviewOptions + + thumbArgs = append(thumbArgs, + "-pix_fmt", "yuv420p", + "-preset", o.Preset, + "-crf", "25", + "-threads", "4", + "-strict", "-2", + "-f", "webm", + ) + + if frameRate <= 0.01 { + thumbArgs = append(thumbArgs, "-vsync", "2") + } + + thumbOptions := transcoder.TranscodeOptions{ + OutputPath: "-", + StartTime: 0, + Duration: clipDuration, + + XError: true, + SlowSeek: false, + + VideoCodec: ffmpeg.VideoCodecVP9, + VideoArgs: thumbArgs, + + ExtraInputArgs: o.InputArgs, + ExtraOutputArgs: o.OutputArgs, + } + + args := transcoder.Transcode("-", thumbOptions) + return e.FFMpeg.GenerateOutput(context.TODO(), args, image) } diff --git a/pkg/models/generate.go b/pkg/models/generate.go index 2fc66248c4c..c8fa9785cc4 100644 --- a/pkg/models/generate.go +++ b/pkg/models/generate.go @@ -18,6 +18,7 @@ type GenerateMetadataOptions struct { Transcodes bool `json:"transcodes"` Phashes bool `json:"phashes"` InteractiveHeatmapsSpeeds bool `json:"interactiveHeatmapsSpeeds"` + ClipPreviews bool `json:"clipPreviews"` } type GeneratePreviewOptions struct { diff --git a/pkg/models/model_image.go b/pkg/models/model_image.go index 42425c455a4..e025ba0b174 100644 --- a/pkg/models/model_image.go +++ b/pkg/models/model_image.go @@ -2,7 +2,6 @@ package models import ( "context" - "errors" "path/filepath" "strconv" "time" @@ -24,7 +23,7 @@ type Image struct { Date *Date `json:"date"` // transient - not persisted - Files RelatedImageFiles + Files RelatedFiles PrimaryFileID *file.ID // transient - path of primary file - empty if no files Path string @@ -39,14 +38,14 @@ type Image struct { PerformerIDs RelatedIDs `json:"performer_ids"` } -func (i *Image) LoadFiles(ctx context.Context, l ImageFileLoader) error { - return i.Files.load(func() ([]*file.ImageFile, error) { +func (i *Image) LoadFiles(ctx context.Context, l FileLoader) error { + return i.Files.load(func() ([]file.File, error) { return l.GetFiles(ctx, i.ID) }) } func (i *Image) LoadPrimaryFile(ctx context.Context, l file.Finder) error { - return i.Files.loadPrimary(func() (*file.ImageFile, error) { + return i.Files.loadPrimary(func() (file.File, error) { if i.PrimaryFileID == nil { return nil, nil } @@ -56,15 +55,11 @@ func (i *Image) LoadPrimaryFile(ctx context.Context, l file.Finder) error { return nil, err } - var vf *file.ImageFile if len(f) > 0 { - var ok bool - vf, ok = f[0].(*file.ImageFile) - if !ok { - return nil, errors.New("not an image file") - } + return f[0], nil } - return vf, nil + + return nil, nil }) } diff --git a/pkg/models/paths/paths_generated.go b/pkg/models/paths/paths_generated.go index aa65ea9189d..d87e1eed69a 100644 --- a/pkg/models/paths/paths_generated.go +++ b/pkg/models/paths/paths_generated.go @@ -78,3 +78,8 @@ func (gp *generatedPaths) GetThumbnailPath(checksum string, width int) string { fname := fmt.Sprintf("%s_%d.jpg", checksum, width) return filepath.Join(gp.Thumbnails, fsutil.GetIntraDir(checksum, thumbDirDepth, thumbDirLength), fname) } + +func (gp *generatedPaths) GetClipPreviewPath(checksum string, width int) string { + fname := fmt.Sprintf("%s_%d.webm", checksum, width) + return filepath.Join(gp.Thumbnails, fsutil.GetIntraDir(checksum, thumbDirDepth, thumbDirLength), fname) +} diff --git a/pkg/models/relationships.go b/pkg/models/relationships.go index b3afcad9ec4..3975bffc3e6 100644 --- a/pkg/models/relationships.go +++ b/pkg/models/relationships.go @@ -34,10 +34,6 @@ type VideoFileLoader interface { GetFiles(ctx context.Context, relatedID int) ([]*file.VideoFile, error) } -type ImageFileLoader interface { - GetFiles(ctx context.Context, relatedID int) ([]*file.ImageFile, error) -} - type FileLoader interface { GetFiles(ctx context.Context, relatedID int) ([]file.File, error) } @@ -320,89 +316,6 @@ func (r *RelatedVideoFiles) loadPrimary(fn func() (*file.VideoFile, error)) erro return nil } -type RelatedImageFiles struct { - primaryFile *file.ImageFile - files []*file.ImageFile - primaryLoaded bool -} - -func NewRelatedImageFiles(files []*file.ImageFile) RelatedImageFiles { - ret := RelatedImageFiles{ - files: files, - primaryLoaded: true, - } - - if len(files) > 0 { - ret.primaryFile = files[0] - } - - return ret -} - -// Loaded returns true if the relationship has been loaded. -func (r RelatedImageFiles) Loaded() bool { - return r.files != nil -} - -// Loaded returns true if the primary file relationship has been loaded. -func (r RelatedImageFiles) PrimaryLoaded() bool { - return r.primaryLoaded -} - -// List returns the related files. Panics if the relationship has not been loaded. -func (r RelatedImageFiles) List() []*file.ImageFile { - if !r.Loaded() { - panic("relationship has not been loaded") - } - - return r.files -} - -// Primary returns the primary file. Panics if the relationship has not been loaded. -func (r RelatedImageFiles) Primary() *file.ImageFile { - if !r.PrimaryLoaded() { - panic("relationship has not been loaded") - } - - return r.primaryFile -} - -func (r *RelatedImageFiles) load(fn func() ([]*file.ImageFile, error)) error { - if r.Loaded() { - return nil - } - - var err error - r.files, err = fn() - if err != nil { - return err - } - - if len(r.files) > 0 { - r.primaryFile = r.files[0] - } - - r.primaryLoaded = true - - return nil -} - -func (r *RelatedImageFiles) loadPrimary(fn func() (*file.ImageFile, error)) error { - if r.PrimaryLoaded() { - return nil - } - - var err error - r.primaryFile, err = fn() - if err != nil { - return err - } - - r.primaryLoaded = true - - return nil -} - type RelatedFiles struct { primaryFile file.File files []file.File diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 58ec592a910..f22cacf92f1 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -241,7 +241,7 @@ func (qb *ImageStore) Update(ctx context.Context, updatedObject *models.Image) e if updatedObject.Files.Loaded() { fileIDs := make([]file.ID, len(updatedObject.Files.List())) for i, f := range updatedObject.Files.List() { - fileIDs[i] = f.ID + fileIDs[i] = f.Base().ID } if err := imagesFilesTableMgr.replaceJoins(ctx, updatedObject.ID, fileIDs); err != nil { @@ -360,7 +360,7 @@ func (qb *ImageStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*mo return ret, nil } -func (qb *ImageStore) GetFiles(ctx context.Context, id int) ([]*file.ImageFile, error) { +func (qb *ImageStore) GetFiles(ctx context.Context, id int) ([]file.File, error) { fileIDs, err := qb.filesRepository().get(ctx, id) if err != nil { return nil, err @@ -372,16 +372,7 @@ func (qb *ImageStore) GetFiles(ctx context.Context, id int) ([]*file.ImageFile, return nil, err } - ret := make([]*file.ImageFile, len(files)) - for i, f := range files { - var ok bool - ret[i], ok = f.(*file.ImageFile) - if !ok { - return nil, fmt.Errorf("expected file to be *file.ImageFile not %T", f) - } - } - - return ret, nil + return files, nil } func (qb *ImageStore) GetManyFileIDs(ctx context.Context, ids []int) ([][]file.ID, error) { diff --git a/pkg/sqlite/image_test.go b/pkg/sqlite/image_test.go index 31f6d48761a..1a0fceb2963 100644 --- a/pkg/sqlite/image_test.go +++ b/pkg/sqlite/image_test.go @@ -97,7 +97,7 @@ func Test_imageQueryBuilder_Create(t *testing.T) { Organized: true, OCounter: ocounter, StudioID: &studioIDs[studioIdxWithImage], - Files: models.NewRelatedImageFiles([]*file.ImageFile{ + Files: models.NewRelatedFiles([]file.File{ imageFile.(*file.ImageFile), }), PrimaryFileID: &imageFile.Base().ID, @@ -149,7 +149,7 @@ func Test_imageQueryBuilder_Create(t *testing.T) { var fileIDs []file.ID if tt.newObject.Files.Loaded() { for _, f := range tt.newObject.Files.List() { - fileIDs = append(fileIDs, f.ID) + fileIDs = append(fileIDs, f.Base().ID) } } s := tt.newObject @@ -444,7 +444,7 @@ func Test_imageQueryBuilder_UpdatePartial(t *testing.T) { Organized: true, OCounter: ocounter, StudioID: &studioIDs[studioIdxWithImage], - Files: models.NewRelatedImageFiles([]*file.ImageFile{ + Files: models.NewRelatedFiles([]file.File{ makeImageFile(imageIdx1WithGallery), }), CreatedAt: createdAt, @@ -462,7 +462,7 @@ func Test_imageQueryBuilder_UpdatePartial(t *testing.T) { models.Image{ ID: imageIDs[imageIdx1WithGallery], OCounter: getOCounter(imageIdx1WithGallery), - Files: models.NewRelatedImageFiles([]*file.ImageFile{ + Files: models.NewRelatedFiles([]file.File{ makeImageFile(imageIdx1WithGallery), }), GalleryIDs: models.NewRelatedIDs([]int{}), @@ -965,7 +965,7 @@ func makeImageWithID(index int) *models.Image { ret := makeImage(index, true) ret.ID = imageIDs[index] - ret.Files = models.NewRelatedImageFiles([]*file.ImageFile{makeImageFile(index)}) + ret.Files = models.NewRelatedFiles([]file.File{makeImageFile(index)}) return ret } @@ -1868,8 +1868,11 @@ func verifyImagesResolution(t *testing.T, resolution models.ResolutionEnum) { t.Errorf("Error loading primary file: %s", err.Error()) return nil } - - verifyImageResolution(t, image.Files.Primary().Height, resolution) + asFrame, ok := image.Files.Primary().(file.VisualFile) + if !ok { + t.Errorf("Error: Associated primary file of image is not of type VisualFile") + } + verifyImageResolution(t, asFrame.GetHeight(), resolution) } return nil diff --git a/scripts/test_db_generator/makeTestDB.go b/scripts/test_db_generator/makeTestDB.go index bfdb042df97..a54e07a873c 100644 --- a/scripts/test_db_generator/makeTestDB.go +++ b/scripts/test_db_generator/makeTestDB.go @@ -347,6 +347,10 @@ func getResolution() (int, int) { return w, h } +func getBool() { + return rand.Intn(2) == 0 +} + func getDate() time.Time { s := rand.Int63n(time.Now().Unix()) @@ -371,6 +375,7 @@ func generateImageFile(parentFolderID file.FolderID, path string) file.File { BaseFile: generateBaseFile(parentFolderID, path), Height: h, Width: w, + Clip: getBool(), } } diff --git a/ui/v2.5/src/components/Galleries/GalleryViewer.tsx b/ui/v2.5/src/components/Galleries/GalleryViewer.tsx index 5eb9deae60e..3a860e48ba4 100644 --- a/ui/v2.5/src/components/Galleries/GalleryViewer.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryViewer.tsx @@ -67,8 +67,8 @@ export const GalleryViewer: React.FC = ({ galleryId }) => { images.forEach((image, index) => { let imageData = { src: image.paths.thumbnail!, - width: image.files[0].width, - height: image.files[0].height, + width: image.visual_files[0].width, + height: image.visual_files[0].height, tabIndex: index, key: image.id ?? index, loading: "lazy", diff --git a/ui/v2.5/src/components/Help/Manual.tsx b/ui/v2.5/src/components/Help/Manual.tsx index f9f18fa686e..0004325cf28 100644 --- a/ui/v2.5/src/components/Help/Manual.tsx +++ b/ui/v2.5/src/components/Help/Manual.tsx @@ -6,7 +6,7 @@ import AutoTagging from "src/docs/en/Manual/AutoTagging.md"; import JSONSpec from "src/docs/en/Manual/JSONSpec.md"; import Configuration from "src/docs/en/Manual/Configuration.md"; import Interface from "src/docs/en/Manual/Interface.md"; -import Galleries from "src/docs/en/Manual/Galleries.md"; +import Images from "src/docs/en/Manual/Images.md"; import Scraping from "src/docs/en/Manual/Scraping.md"; import ScraperDevelopment from "src/docs/en/Manual/ScraperDevelopment.md"; import Plugins from "src/docs/en/Manual/Plugins.md"; @@ -88,9 +88,9 @@ export const Manual: React.FC = ({ content: Browsing, }, { - key: "Galleries.md", - title: "Image Galleries", - content: Galleries, + key: "Images.md", + title: "Images and Galleries", + content: Images, }, { key: "Scraping.md", diff --git a/ui/v2.5/src/components/Images/ImageCard.tsx b/ui/v2.5/src/components/Images/ImageCard.tsx index 50ae8bcc44f..28598d417c2 100644 --- a/ui/v2.5/src/components/Images/ImageCard.tsx +++ b/ui/v2.5/src/components/Images/ImageCard.tsx @@ -30,7 +30,10 @@ export const ImageCard: React.FC = ( props: IImageCardProps ) => { const file = useMemo( - () => (props.image.files.length > 0 ? props.image.files[0] : undefined), + () => + props.image.visual_files.length > 0 + ? props.image.visual_files[0] + : undefined, [props.image] ); @@ -138,6 +141,13 @@ export const ImageCard: React.FC = ( return height > width; } + const source = + props.image.paths.preview != "" + ? props.image.paths.preview ?? "" + : props.image.paths.thumbnail ?? ""; + const video = source.includes("preview"); + const ImagePreview = video ? "video" : "img"; + return ( = ( image={ <>
- {props.image.title {props.onPreview ? (
diff --git a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx index eb3d1211c4b..dda47e9d2f6 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx @@ -51,7 +51,7 @@ export const Image: React.FC = () => { const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); async function onRescan() { - if (!image || !image.files.length) { + if (!image || !image.visual_files.length) { return; } @@ -181,8 +181,8 @@ export const Image: React.FC = () => { - {image.files.length > 1 && ( - + {image.visual_files.length > 1 && ( + )} @@ -260,6 +260,8 @@ export const Image: React.FC = () => { } const title = objectTitle(image); + const ImageView = + image.visual_files[0].__typename == "VideoFile" ? "video" : "img"; return (
@@ -286,8 +288,16 @@ export const Image: React.FC = () => { {renderTabs()}
- {title} diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx index 026c51dea6c..2b906c6d5ef 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx @@ -10,7 +10,7 @@ import TextUtils from "src/utils/text"; import { TextField, URLField } from "src/utils/field"; interface IFileInfoPanelProps { - file: GQL.ImageFileDataFragment; + file: GQL.ImageFileDataFragment | GQL.VideoFileDataFragment; primary?: boolean; ofMany?: boolean; onSetPrimaryFile?: () => void; @@ -110,17 +110,17 @@ export const ImageFileInfoPanel: React.FC = ( const [loading, setLoading] = useState(false); const [deletingFile, setDeletingFile] = useState< - GQL.ImageFileDataFragment | undefined + GQL.ImageFileDataFragment | GQL.VideoFileDataFragment | undefined >(); - if (props.image.files.length === 0) { + if (props.image.visual_files.length === 0) { return <>; } - if (props.image.files.length === 1) { + if (props.image.visual_files.length === 1) { return ( <> - + {props.image.url ? (
@@ -150,14 +150,14 @@ export const ImageFileInfoPanel: React.FC = ( } return ( - + {deletingFile && ( setDeletingFile(undefined)} selected={[deletingFile]} /> )} - {props.image.files.map((file, index) => ( + {props.image.visual_files.map((file, index) => ( diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index 2b3cc8c4646..2b3b359a6ea 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -22,6 +22,7 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { ImageCard } from "./ImageCard"; +import { ImageWallItem } from "./ImageWallItem"; import { EditImagesDialog } from "./EditImagesDialog"; import { DeleteImagesDialog } from "./DeleteImagesDialog"; import "flexbin/flexbin.css"; @@ -56,9 +57,12 @@ const ImageWall: React.FC = ({ images, handleImageOpen }) => { images.forEach((image, index) => { let imageData = { - src: image.paths.thumbnail!, - width: image.files[0].width, - height: image.files[0].height, + src: + image.paths.preview != "" + ? image.paths.preview! + : image.paths.thumbnail!, + width: image.visual_files[0].width, + height: image.visual_files[0].height, tabIndex: index, key: image.id, loading: "lazy", @@ -86,6 +90,7 @@ const ImageWall: React.FC = ({ images, handleImageOpen }) => { {photos.length ? ( = ( + props: IImageWallProps +) => { + type style = Record; + var imgStyle: style = { + margin: props.margin, + display: "block", + }; + + if (props.direction === "column") { + imgStyle.position = "absolute"; + imgStyle.left = props.left; + imgStyle.top = props.top; + } + + var handleClick = function handleClick( + event: React.MouseEvent + ) { + if (props.onClick) { + props.onClick(event, { index: props.index }); + } + }; + + const video = props.photo.src.includes("preview"); + const ImagePreview = video ? "video" : "img"; + + return ( + + ); +}; diff --git a/ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx b/ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx index b91f73f8bc6..d8cc0f67cee 100644 --- a/ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx @@ -130,6 +130,14 @@ export const SettingsLibraryPanel: React.FC = () => { onChange={(v) => saveGeneral({ writeImageThumbnails: v })} /> + saveGeneral({ createImageClipsFromVideos: v })} + /> + = ({ headingID="dialogs.scene_gen.interactive_heatmap_speed" onChange={(v) => setOptions({ interactiveHeatmapsSpeeds: v })} /> + setOptions({ clipPreviews: v })} + /> = ({ scanGenerateSprites, scanGeneratePhashes, scanGenerateThumbnails, + scanGenerateClipPreviews, } = options; function setOptions(input: Partial) { @@ -68,6 +69,12 @@ export const ScanOptions: React.FC = ({ headingID="config.tasks.generate_thumbnails_during_scan" onChange={(v) => setOptions({ scanGenerateThumbnails: v })} /> + setOptions({ scanGenerateClipPreviews: v })} + /> ); }; diff --git a/ui/v2.5/src/core/createClient.ts b/ui/v2.5/src/core/createClient.ts index c48fa480be3..b6601a6ccf3 100644 --- a/ui/v2.5/src/core/createClient.ts +++ b/ui/v2.5/src/core/createClient.ts @@ -88,6 +88,10 @@ const typePolicies: TypePolicies = { }, }; +const possibleTypes = { + VisualFile: ["VideoFile", "ImageFile"], +}; + export const baseURL = document.querySelector("base")?.getAttribute("href") ?? "/"; @@ -156,7 +160,10 @@ export const createClient = () => { const link = from([errorLink, splitLink]); - const cache = new InMemoryCache({ typePolicies }); + const cache = new InMemoryCache({ + typePolicies, + possibleTypes: possibleTypes, + }); const client = new ApolloClient({ link, cache, diff --git a/ui/v2.5/src/docs/en/Manual/Galleries.md b/ui/v2.5/src/docs/en/Manual/Galleries.md deleted file mode 100644 index c31e2b1c48c..00000000000 --- a/ui/v2.5/src/docs/en/Manual/Galleries.md +++ /dev/null @@ -1,12 +0,0 @@ -# Galleries - -**Note:** images are now included during the scan process and are loaded independently of galleries. It is _no longer necessary_ to have images in zip files to be scanned into your library. - -Galleries are automatically created from zip files found during scanning that contain images. It is also possible to automatically create galleries from folders containing images, by selecting the "Create galleries from folders containing images" checkbox in the Configuration page. It is also possible to manually create galleries. - -For best results, images in zip file should be stored without compression (copy, store or no compression options depending on the software you use. Eg on linux: `zip -0 -r gallery.zip foldertozip/`). This impacts **heavily** on the zip read performance. - -If a filename of an image in the gallery zip file ends with `cover.jpg`, it will be treated like a cover and presented first in the gallery view page and as a gallery cover in the gallery list view. If more than one images match the name the first one found in natural sort order is selected. - -Images can be added to a gallery by navigating to the gallery's page, selecting the "Add" tab, querying for and selecting the images to add, then selecting "Add to Gallery" from the `...` menu button. Likewise, images may be removed from a gallery by selecting the "Images" tab, selecting the images to remove and selecting "Remove from Gallery" from the `...` menu button. - diff --git a/ui/v2.5/src/docs/en/Manual/Images.md b/ui/v2.5/src/docs/en/Manual/Images.md new file mode 100644 index 00000000000..7b384596b00 --- /dev/null +++ b/ui/v2.5/src/docs/en/Manual/Images.md @@ -0,0 +1,27 @@ +# Images and Galleries + +Images are the parts which make up galleries, but you can also have them be scanned independently. To declare an image part of a gallery, there are four ways: + +1. Group them in a folder together and activate the **Create galleries from folders containing images** option in the library section of your settings. The gallery will get the name of the folder. +2. Group them in a folder together and create a file in the folder called .forcegallery. The gallery will get the name of the folder. +3. Group them into a zip archive together. The gallery will get the name of the archive. +4. You can simply create a gallery in stash itself by clicking on **New** in the Galleries tab. + +You can add images to every gallery manually in the gallery detail page. Deleting can be done by selecting the according images in the same view and clicking on the minus next to the edit button. + +For best results, images in zip file should be stored without compression (copy, store or no compression options depending on the software you use. Eg on linux: `zip -0 -r gallery.zip foldertozip/`). This impacts **heavily** on the zip read performance. + +If a filename of an image in the gallery zip file ends with `cover.jpg`, it will be treated like a cover and presented first in the gallery view page and as a gallery cover in the gallery list view. If more than one images match the name the first one found in natural sort order is selected. + +## Image clips/gifs + +Images can also be clips/gifs. These are meant to be short video loops. Right now they are not possible in zipfiles. To declare video files to be images, there are two ways: + +1. Deactivate video scanning for all libraries that contain clips/gifs, but keep image scanning active. Set the **Scan Video Extensions as Image Clip** option in the library section of your settings. +2. Make sure none of the file endings used by your clips/gifs are present in the **Video Extensions** and add them to the **Image Extensions** in the library section of your settings. + +A clip/gif will be a stillframe in the wall and grid view by default. To view the loop, you can go into the Lightbox Carousel (e.g. by clicking on an image in the wall view) or the image detail page. + +If you want the loop to be used as a preview on the wall and grid view, you will have to generate them. +You can do this as you scan for the new clip file by activating **Generate previews for image clips** on the scan settings, or do it after by going to the **Generated Content** section in the task section of your settings, activating **Image Clip Previews** and clicking generate. This takes a while, as the files are transcoded. + diff --git a/ui/v2.5/src/docs/en/Manual/Tasks.md b/ui/v2.5/src/docs/en/Manual/Tasks.md index f7df798f981..2856306ff27 100644 --- a/ui/v2.5/src/docs/en/Manual/Tasks.md +++ b/ui/v2.5/src/docs/en/Manual/Tasks.md @@ -20,6 +20,7 @@ The scan task accepts the following options: | Generate scrubber sprites | Generates sprites for the scene scrubber. | | 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. | # Auto Tagging See the [Auto Tagging](/help/AutoTagging.md) page. @@ -51,6 +52,7 @@ The generate task accepts the following options: | Transcodes | MP4 conversions of unsupported video formats. Allows direct streaming instead of live transcoding. | | Perceptual hashes (for deduplication) | Generates perceptual hashes for scene deduplication and identification. | | Generate heatmaps and speeds for interactive scenes | Generates heatmaps and speeds for interactive scenes. | +| Image Clip Previews | Generates a gif/looping video as thumbnail for image clips/gifs. | | Overwrite existing generated files | By default, where a generated file exists, it is not regenerated. When this flag is enabled, then the generated files are regenerated. | ## Transcodes diff --git a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx index bae92ab0cdc..8cadd2d5457 100644 --- a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx +++ b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx @@ -425,20 +425,25 @@ export const LightboxComponent: React.FC = ({ } } - const navItems = images.map((image, i) => ( - + React.createElement(image.paths.preview != "" ? "video" : "img", { + loop: image.paths.preview != "", + autoPlay: image.paths.preview != "", + src: + image.paths.preview != "" + ? image.paths.preview ?? "" + : image.paths.thumbnail ?? "", + alt: "", + className: cx(CLASSNAME_NAVIMAGE, { [CLASSNAME_NAVSELECTED]: i === index, - })} - onClick={(e: React.MouseEvent) => selectIndex(e, i)} - role="presentation" - loading="lazy" - key={image.paths.thumbnail} - onLoad={imageLoaded} - /> - )); + }), + onClick: (e: React.MouseEvent) => selectIndex(e, i), + role: "presentation", + loading: "lazy", + key: image.paths.thumbnail, + onLoad: imageLoaded, + }) + ); const onDelayChange = (e: React.ChangeEvent) => { let numberValue = Number.parseInt(e.currentTarget.value, 10); @@ -845,6 +850,7 @@ export const LightboxComponent: React.FC = ({ scrollAttemptsBeforeChange={scrollAttemptsBeforeChange} setZoom={(v) => setZoom(v)} resetPosition={resetPosition} + isVideo={image.visual_files?.[0]?.__typename == "VideoFile"} /> ) : undefined}
diff --git a/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx b/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx index dcddcbe5d32..425a3aacdd4 100644 --- a/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx +++ b/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx @@ -59,6 +59,7 @@ interface IProps { setZoom: (v: number) => void; onLeft: () => void; onRight: () => void; + isVideo: boolean; } export const LightboxImage: React.FC = ({ @@ -74,6 +75,7 @@ export const LightboxImage: React.FC = ({ current, setZoom, resetPosition, + isVideo, }) => { const [defaultZoom, setDefaultZoom] = useState(1); const [moving, setMoving] = useState(false); @@ -89,7 +91,7 @@ export const LightboxImage: React.FC = ({ const container = React.createRef(); const startPoints = useRef([0, 0]); - const pointerCache = useRef[]>([]); + const pointerCache = useRef([]); const prevDiff = useRef(); const scrollAttempts = useRef(0); @@ -100,6 +102,24 @@ export const LightboxImage: React.FC = ({ setBoxWidth(box.offsetWidth); setBoxHeight(box.offsetHeight); } + + function toggleVideoPlay() { + if (container.current) { + let openVideo = container.current.getElementsByTagName("video"); + if (openVideo.length > 0) { + let rect = openVideo[0].getBoundingClientRect(); + if (Math.abs(rect.x) < document.body.clientWidth / 2) { + openVideo[0].play(); + } else { + openVideo[0].pause(); + } + } + } + } + + setTimeout(() => { + toggleVideoPlay(); + }, 250); }, [container]); useEffect(() => { @@ -233,7 +253,12 @@ export const LightboxImage: React.FC = ({ calculateInitialPosition, ]); - function getScrollMode(ev: React.WheelEvent) { + function getScrollMode( + ev: + | React.WheelEvent + | React.WheelEvent + | React.WheelEvent + ) { if (ev.shiftKey) { switch (scrollMode) { case GQL.ImageLightboxScrollMode.Zoom: @@ -246,14 +271,24 @@ export const LightboxImage: React.FC = ({ return scrollMode; } - function onContainerScroll(ev: React.WheelEvent) { + function onContainerScroll( + ev: + | React.WheelEvent + | React.WheelEvent + | React.WheelEvent + ) { // don't zoom if mouse isn't over image if (getScrollMode(ev) === GQL.ImageLightboxScrollMode.PanY) { onImageScroll(ev); } } - function onImageScrollPanY(ev: React.WheelEvent) { + function onImageScrollPanY( + ev: + | React.WheelEvent + | React.WheelEvent + | React.WheelEvent + ) { if (current) { const [minY, maxY] = minMaxY(zoom * defaultZoom); @@ -298,7 +333,12 @@ export const LightboxImage: React.FC = ({ } } - function onImageScroll(ev: React.WheelEvent) { + function onImageScroll( + ev: + | React.WheelEvent + | React.WheelEvent + | React.WheelEvent + ) { const percent = ev.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP; switch (getScrollMode(ev)) { @@ -311,7 +351,11 @@ export const LightboxImage: React.FC = ({ } } - function onImageMouseOver(ev: React.MouseEvent) { + function onImageMouseOver( + ev: + | React.MouseEvent + | React.MouseEvent + ) { if (!moving) return; if (!ev.buttons) { @@ -327,14 +371,22 @@ export const LightboxImage: React.FC = ({ setPositionY(positionY + posY); } - function onImageMouseDown(ev: React.MouseEvent) { + function onImageMouseDown( + ev: + | React.MouseEvent + | React.MouseEvent + ) { startPoints.current = [ev.pageX, ev.pageY]; setMoving(true); mouseDownEvent.current = ev.nativeEvent; } - function onImageMouseUp(ev: React.MouseEvent) { + function onImageMouseUp( + ev: + | React.MouseEvent + | React.MouseEvent + ) { if (ev.button !== 0) return; if ( @@ -360,7 +412,12 @@ export const LightboxImage: React.FC = ({ } } - function onTouchStart(ev: React.TouchEvent) { + function onTouchStart( + ev: + | React.TouchEvent + | React.TouchEvent + | React.TouchEvent + ) { ev.preventDefault(); if (ev.touches.length === 1) { startPoints.current = [ev.touches[0].pageX, ev.touches[0].pageY]; @@ -368,7 +425,12 @@ export const LightboxImage: React.FC = ({ } } - function onTouchMove(ev: React.TouchEvent) { + function onTouchMove( + ev: + | React.TouchEvent + | React.TouchEvent + | React.TouchEvent + ) { if (!moving) return; if (ev.touches.length === 1) { @@ -381,7 +443,12 @@ export const LightboxImage: React.FC = ({ } } - function onPointerDown(ev: React.PointerEvent) { + function onPointerDown( + ev: + | React.PointerEvent + | React.PointerEvent + | React.PointerEvent + ) { // replace pointer event with the same id, if applicable pointerCache.current = pointerCache.current.filter( (e) => e.pointerId !== ev.pointerId @@ -391,7 +458,12 @@ export const LightboxImage: React.FC = ({ prevDiff.current = undefined; } - function onPointerUp(ev: React.PointerEvent) { + function onPointerUp( + ev: + | React.PointerEvent + | React.PointerEvent + | React.PointerEvent + ) { for (let i = 0; i < pointerCache.current.length; i++) { if (pointerCache.current[i].pointerId === ev.pointerId) { pointerCache.current.splice(i, 1); @@ -400,7 +472,12 @@ export const LightboxImage: React.FC = ({ } } - function onPointerMove(ev: React.PointerEvent) { + function onPointerMove( + ev: + | React.PointerEvent + | React.PointerEvent + | React.PointerEvent + ) { // find the event in the cache const cachedIndex = pointerCache.current.findIndex( (c) => c.pointerId === ev.pointerId @@ -432,6 +509,17 @@ export const LightboxImage: React.FC = ({ } } + const ImageView = isVideo ? "video" : "img"; + const customStyle = isVideo + ? { + touchAction: "none", + display: "flex", + margin: "auto", + width: "100%", + "max-height": "90vh", + } + : { touchAction: "none" }; + return (
= ({ > {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} - onImageScroll(e) : undefined} onMouseDown={(e) => onImageMouseDown(e)} onMouseUp={(e) => onImageMouseUp(e)} diff --git a/ui/v2.5/src/hooks/Lightbox/types.ts b/ui/v2.5/src/hooks/Lightbox/types.ts index 6b60422fd44..f955a060a78 100644 --- a/ui/v2.5/src/hooks/Lightbox/types.ts +++ b/ui/v2.5/src/hooks/Lightbox/types.ts @@ -3,6 +3,13 @@ import * as GQL from "src/core/generated-graphql"; interface IImagePaths { image?: GQL.Maybe; thumbnail?: GQL.Maybe; + preview?: GQL.Maybe; +} + +interface IFiles { + __typename?: string; + width: number; + height: number; } export interface ILightboxImage { @@ -11,6 +18,7 @@ export interface ILightboxImage { rating100?: GQL.Maybe; o_counter?: GQL.Maybe; paths: IImagePaths; + visual_files?: GQL.Maybe[]; } export interface IChapter { diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 7226bd4ba79..8827d38bc69 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -422,6 +422,7 @@ "generating_from_paths": "Generating for scenes from the following paths", "generating_scenes": "Generating for {num} {scene}" }, + "generate_clip_previews_during_scan": "Generate previews for image clips", "generate_desc": "Generate supporting image, sprite, video, vtt and other files.", "generate_phashes_during_scan": "Generate perceptual hashes", "generate_phashes_during_scan_tooltip": "For deduplication and scene identification.", @@ -592,6 +593,10 @@ "write_image_thumbnails": { "description": "Write image thumbnails to disk when generated on-the-fly", "heading": "Write image thumbnails" + }, + "create_image_clips_from_videos": { + "description": "When a library has Videos disabled, Video Files (files ending with Video Extension) will be scanned as Image Clip", + "heading": "Scan Video Extensions as Image Clip" } } }, @@ -799,6 +804,7 @@ "destination": "Reassign to" }, "scene_gen": { + "clip_previews": "Image Clip Previews", "covers": "Scene covers", "force_transcodes": "Force Transcode generation", "force_transcodes_tooltip": "By default, transcodes are only generated when the video file is not supported in the browser. When enabled, transcodes will be generated even when the video file appears to be supported in the browser.", From 11344c51b7b04d9955a1bdf672fe9f9ca5f9173c Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Wed, 17 May 2023 01:33:35 +0200 Subject: [PATCH 025/135] Fix missing tag images (#3736) --- internal/api/resolver_model_tag.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/resolver_model_tag.go b/internal/api/resolver_model_tag.go index f2c677b877b..6f74c8d1b06 100644 --- a/internal/api/resolver_model_tag.go +++ b/internal/api/resolver_model_tag.go @@ -114,7 +114,7 @@ func (r *tagResolver) ImagePath(ctx context.Context, obj *models.Tag) (*string, var hasImage bool if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error - hasImage, err = r.repository.Performer.HasImage(ctx, obj.ID) + hasImage, err = r.repository.Tag.HasImage(ctx, obj.ID) return err }); err != nil { return nil, err From 9a41841bd207e9721f404b602ea304bbb02e223b Mon Sep 17 00:00:00 2001 From: stash-translation-bot <94573628+stash-translation-bot@users.noreply.github.com> Date: Tue, 16 May 2023 21:32:00 -0700 Subject: [PATCH 026/135] Translations update from Stash (#3665) * Translated using Weblate (Portuguese (Brazil)) Currently translated at 86.9% (832 of 957 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/pt_BR/ * Translated using Weblate (Spanish) Currently translated at 84.3% (807 of 957 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/es/ * Translated using Weblate (Spanish) Currently translated at 89.1% (853 of 957 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/es/ * Translated using Weblate (Spanish) Currently translated at 90.8% (869 of 957 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/es/ * Translated using Weblate (Swedish) Currently translated at 100.0% (957 of 957 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/sv/ * Translated using Weblate (French) Currently translated at 100.0% (957 of 957 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/fr/ * Translated using Weblate (Estonian) Currently translated at 100.0% (957 of 957 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/et/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 95.1% (912 of 958 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/zh_Hans/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 95.1% (912 of 958 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/zh_Hans/ * Translated using Weblate (French) Currently translated at 100.0% (958 of 958 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/fr/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (958 of 958 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/zh_Hans/ * Translated using Weblate (German) Currently translated at 100.0% (958 of 958 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/de/ * Translated using Weblate (Swedish) Currently translated at 100.0% (958 of 958 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/sv/ * Translated using Weblate (Estonian) Currently translated at 100.0% (958 of 958 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/et/ * Translated using Weblate (Danish) Currently translated at 87.7% (841 of 958 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/da/ * Update translation files Updated by "Remove blank strings" hook in Weblate. Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/ * Translated using Weblate (French) Currently translated at 100.0% (964 of 964 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/fr/ * Translated using Weblate (Estonian) Currently translated at 100.0% (964 of 964 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/et/ * Translated using Weblate (Chinese (Traditional)) Currently translated at 93.1% (898 of 964 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/zh_Hant/ * Translated using Weblate (Swedish) Currently translated at 100.0% (964 of 964 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/sv/ * Translated using Weblate (French) Currently translated at 100.0% (964 of 964 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/fr/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 99.3% (958 of 964 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/zh_Hans/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (964 of 964 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/zh_Hans/ * Translated using Weblate (Estonian) Currently translated at 100.0% (964 of 964 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/et/ --------- Co-authored-by: Eduardo Souza Co-authored-by: Gabriel Velez Co-authored-by: Weblate Co-authored-by: Alpaca Serious Co-authored-by: MrOV3RDOSE Co-authored-by: Lauri Co-authored-by: JueLuo Co-authored-by: Yeluo Co-authored-by: Dee.H.Y Co-authored-by: Phasetime Co-authored-by: Christoph Holmes Co-authored-by: brestu Co-authored-by: Yesmola Co-authored-by: MoeHero <562416714@qq.com> --- ui/v2.5/src/locales/da-DK.json | 42 ++++- ui/v2.5/src/locales/de-DE.json | 5 +- ui/v2.5/src/locales/es-ES.json | 114 ++++++++++++- ui/v2.5/src/locales/et-EE.json | 19 ++- ui/v2.5/src/locales/fr-FR.json | 19 ++- ui/v2.5/src/locales/pt-BR.json | 21 +++ ui/v2.5/src/locales/sv-SE.json | 19 ++- ui/v2.5/src/locales/zh-CN.json | 286 ++++++++++++++++++++++----------- ui/v2.5/src/locales/zh-TW.json | 6 +- 9 files changed, 401 insertions(+), 130 deletions(-) diff --git a/ui/v2.5/src/locales/da-DK.json b/ui/v2.5/src/locales/da-DK.json index d9698b74b51..b83b8b48004 100644 --- a/ui/v2.5/src/locales/da-DK.json +++ b/ui/v2.5/src/locales/da-DK.json @@ -6,6 +6,7 @@ "add_to_entity": "Tilføj til {entityType}", "allow": "Tillad", "allow_temporarily": "Tillad midlertidigt", + "anonymise": "Anonymisér", "apply": "Anvend", "auto_tag": "Auto Tagge", "backup": "Backup", @@ -20,6 +21,7 @@ "confirm": "Bekræft", "continue": "Forsæt", "create": "Lav", + "create_chapters": "Skab Kapitel", "create_entity": "Lav {entityType}", "create_marker": "Lav Mærke", "created_entity": "Lavet {entity_type}: {entity_name}", @@ -32,6 +34,7 @@ "delete_stashid": "Slet StashID", "disallow": "Tillad ikke", "download": "Download", + "download_anonymised": "Hent anonymiseret", "download_backup": "Download Backup", "edit": "Ændre", "edit_entity": "Ændre {entityType}", @@ -58,6 +61,8 @@ "merge": "Fusioner", "merge_from": "Fusioner fra", "merge_into": "Fusioner til", + "migrate_blobs": "Migrér Blobs", + "migrate_scene_screenshots": "Migrér Scene-skærmbilleder", "next_action": "Næste", "not_running": "kører ikke", "open_in_external_player": "Åben i ekstern afspiller", @@ -122,14 +127,20 @@ "aliases": "Aliaser", "all": "alt", "also_known_as": "Også kendt som", + "appears_with": "Optræder Med", "ascending": "Stigende", "average_resolution": "Gennemsnitlig Opløsning", "between_and": "og", "birth_year": "Fødselsår", "birthdate": "Fødselsdato", "bitrate": "Bithastighed", + "blobs_storage_type": { + "database": "Database", + "filesystem": "Filsystem" + }, "captions": "Undertekster", "career_length": "Karrierer Længde", + "chapters": "Kapitler", "component_tagger": { "config": { "active_instance": "Aktiv stash-box instans:", @@ -183,6 +194,7 @@ "latest_version": "Seneste Version", "latest_version_build_hash": "Seneste version Byg Hash:", "new_version_notice": "[NY]", + "release_date": "Udgivelsesdato:", "stash_discord": "Tilmeld dig vores {url} kanal", "stash_home": "Stash hjem på {url}", "stash_open_collective": "Støt os gennem {url}", @@ -253,7 +265,15 @@ "description": "Mappelokation for SQLite database backup filer", "heading": "Backup mappesti" }, - "cache_location": "Directory placering af cachen", + "blobs_path": { + "description": "Hvori filsystemet binær data skal lagres. Anvendes kun når blobs lagres i Filsystemet. ADVARSEL: Ændres dette, kræves manuel flytning af eksisterende data.", + "heading": "Binær data filsystem-sti" + }, + "blobs_storage": { + "description": "Hvor binær data, som scene-forsider, performere, studie eller tag-billeder opbevares. Efter denne værdi ændres, skal den eksisterende data migreres med Migrér Blobs-opgaverne. Se Opgaver siden for migrering.", + "heading": "Binær data lagringstype" + }, + "cache_location": "Mappe-placering af cachen. Påkrævet, hvis der streames via HLS (som på Apple-enheder) eller DASH.", "cache_path_head": "Cache Sti", "calculate_md5_and_ohash_desc": "Beregn MD5 kontrolsum ud over oshash. Aktivering vil medføre, at indledende scanninger bliver langsommere. Filnavnehash skal indstilles til oshash for at deaktivere MD5-beregning.", "calculate_md5_and_ohash_label": "Beregn MD5 for videoer", @@ -263,19 +283,31 @@ "chrome_cdp_path_desc": "Filsti til den eksekverbare Chrome-fil eller en ekstern adresse (startende med http:// eller https://, for eksempel http://localhost:9222/json/version) til en Chrome-instans.", "create_galleries_from_folders_desc": "Hvis sandt, opretter gallerier fra mapper, der indeholder billeder.", "create_galleries_from_folders_label": "Opret gallerier fra mapper, der indeholder billeder", + "database": "Database", "db_path_head": "Databasesti", "directory_locations_to_your_content": "Adresser til dit indhold i mappen", "excluded_image_gallery_patterns_desc": "Regexps af billed- og gallerifiler/stier, der skal udelukkes fra Scan og tilføje til Clean", "excluded_image_gallery_patterns_head": "Udelukkede billed-/gallerimønstre", "excluded_video_patterns_desc": "Regexps af videofiler/stier, der skal udelukkes fra Scan og tilføje til Clean", "excluded_video_patterns_head": "Udelukkede videomønstre", + "ffmpeg": { + "hardware_acceleration": { + "desc": "Anvender tilgængelig hardware til live-omkodning af video.", + "heading": "FFmpeg hardware-indkodning" + }, + "live_transcode": { + "input_args": { + "desc": "Avanceret: Yderligere argumenter for videreførsel til ffmpeg forinden inputsfeltet, når live video omkodes.", + "heading": "FFmpeg Live Omkodning Input Argumenter" + } + } + }, "gallery_ext_desc": "Kommasepareret liste over filtypenavne, der vil blive identificeret som galleri-zip-filer.", "gallery_ext_head": "Galleri zip-udvidelser", "generated_file_naming_hash_desc": "Brug MD5 eller oshash til genereret filnavngivning. Ændring af dette kræver, at alle scener har den relevante MD5/oshash-værdi udfyldt. Efter at have ændret denne værdi, skal eksisterende genererede filer migreres eller regenereres. Se siden Opgaver for migrering.", "generated_file_naming_hash_head": "Genereret hash til filnavngivning", "generated_files_location": "Katalogplacering for de genererede filer (scenemarkører, sceneforhåndsvisninger, sprites osv.)", "generated_path_head": "Genereret sti", - "hashing": "Hashing", "image_ext_desc": "Kommasepareret liste over filtypenavne, der vil blive identificeret som billeder.", "image_ext_head": "Billedudvidelser", "include_audio_desc": "Inkluderer lydstream ved generering af forhåndsvisninger.", @@ -769,7 +801,7 @@ "gender": "Køn", "gender_types": { "FEMALE": "Kvinde", - "INTERSEX": "Intersex", + "INTERSEX": "Interkønnet", "MALE": "Mand", "NON_BINARY": "Ikke-binær", "TRANSGENDER_FEMALE": "Transkønnet kvinde", @@ -895,7 +927,7 @@ "resolution": "Opløsning", "scene": "Scene", "sceneTagger": "Scenetagger", - "sceneTags": "Scene Tags", + "sceneTags": "Scene-etiketter", "scene_count": "Scene antal", "scene_id": "Scene-id", "scenes": "Scener", @@ -1009,7 +1041,7 @@ "sub_tag_of": "Under-tag til {parent}", "sub_tags": "Under-tags", "subsidiary_studios": "Underliggende Studier", - "synopsis": "Synopsis", + "synopsis": "Synopse", "tag": "Tag", "tag_count": "Tag Antal", "tags": "Tags", diff --git a/ui/v2.5/src/locales/de-DE.json b/ui/v2.5/src/locales/de-DE.json index b4dc9c8642f..ea3b4289eef 100644 --- a/ui/v2.5/src/locales/de-DE.json +++ b/ui/v2.5/src/locales/de-DE.json @@ -127,6 +127,7 @@ "aliases": "Aliase", "all": "Alle", "also_known_as": "Auch bekannt unter", + "appears_with": "Tritt auf mit", "ascending": "Aufsteigend", "average_resolution": "Durchschnittliche Auflösung", "between_and": "und", @@ -306,8 +307,8 @@ }, "transcode": { "input_args": { - "desc": "Erweitert: Zusätzliche Parameter für die Live-Transkodierung mit ffmpeg, welche vor dem Eingabefeld übergeben werden können.", - "heading": "FFmpeg Live-Transkodierung Eingangsparameter" + "desc": "Erweitert: Zusätzliche Parameter für die Video-Generierung mit ffmpeg, welche vor dem Eingabefeld übergeben werden können.", + "heading": "FFmpeg Transkodierung Eingangsparameter" }, "output_args": { "desc": "Erweitert: Zusätzliche Parameter für die Videogenerierung mit ffmpeg, welche vor dem Ausgabefeld übergeben werden können.", diff --git a/ui/v2.5/src/locales/es-ES.json b/ui/v2.5/src/locales/es-ES.json index 2f115be1109..50f88ceeb34 100644 --- a/ui/v2.5/src/locales/es-ES.json +++ b/ui/v2.5/src/locales/es-ES.json @@ -6,6 +6,7 @@ "add_to_entity": "Añadir a {entityType}", "allow": "Permitir", "allow_temporarily": "Permitir temporalmente", + "anonymise": "Anonimizar", "apply": "Aplicar", "auto_tag": "Etiquetado automático", "backup": "Copia de seguridad", @@ -20,6 +21,7 @@ "confirm": "Confirmar", "continue": "Continuar", "create": "Crear", + "create_chapters": "Crear capítulo", "create_entity": "Crear {entityType}", "create_marker": "Crear marcador", "created_entity": "{entity_type} creado: {entity_name}", @@ -32,6 +34,7 @@ "delete_stashid": "Eliminar StashID", "disallow": "No permitir/Denegar", "download": "Descargar", + "download_anonymised": "Descargar anonimizado", "download_backup": "Descargar copia de seguridad", "edit": "Editar", "edit_entity": "Editar {entityType}", @@ -54,9 +57,12 @@ "import": "Importar…", "import_from_file": "Importar desde archivo", "logout": "Cerrar sesión", + "make_primary": "Establecer como primario", "merge": "Unir", "merge_from": "Fusionar desde (origen)", "merge_into": "Fusionar en (destino)", + "migrate_blobs": "Migrar blobs", + "migrate_scene_screenshots": "Migrar capturas de pantalla", "next_action": "Próximo", "not_running": "Apagado", "open_in_external_player": "Abrir en reproductor externo", @@ -127,8 +133,13 @@ "birth_year": "Año de nacimiento", "birthdate": "Cumpleaños", "bitrate": "Tasa de bits", + "blobs_storage_type": { + "database": "Base de datos", + "filesystem": "Sistema de ficheros" + }, "captions": "Subtítulos", "career_length": "Años en activo", + "chapters": "Capítulos", "component_tagger": { "config": { "active_instance": "Instancia activa de stash-box:", @@ -159,7 +170,7 @@ "duration_unknown": "Duración desconocida", "fp_found": "{fpCount, plural, =0 {No se han encontrado nuevas huellas dactilares} other {# resultados de huellas dactilares encontrados}}", "fp_matches": "La duración coincide", - "fp_matches_multi": "La duración coincide {matchCount}/{durationsLength} huella(s) dactilar(es)", + "fp_matches_multi": "{matchCount}/{durationsLength} cincidencias de fingerprint(s)", "hash_matches": "{hash_type} coincide", "match_failed_already_tagged": "Escena ya etiquetada", "match_failed_no_result": "No se han encontrado resultados", @@ -182,6 +193,7 @@ "latest_version": "Última Versión", "latest_version_build_hash": "Hash de la última versión:", "new_version_notice": "[NUEVA]", + "release_date": "Fecha de publicación:", "stash_discord": "Únete a nuestro canal {url}", "stash_home": "Página principal del proyecto en {url}", "stash_open_collective": "Donaciones al proyecto a través de {url}", @@ -252,7 +264,15 @@ "description": "Ubicación del directorio para copias de seguridad de archivos de bases de datos SQLite", "heading": "Ruta del directorio de la copia de seguridad" }, - "cache_location": "Ruta relativa del directorio donde se almacenarán los ficheros de la caché", + "blobs_path": { + "description": "Donde almacenar ficheros binarios. Solo aplicable cuando el sistema de archivos es del tipo \"blob\". AVISO: Cambiar este parámetro requiere mover manualmente los ficheros.", + "heading": "Ruta de sistema de archivos binario" + }, + "blobs_storage": { + "description": "Donde almacenar información binaria como por ejemplo imágenes de escenas, actores, estudios y etiquetas. Tras cambiar este valor, los datos existentes tienen que ser migrados usando la tarea \"Migrar blobs\". Ver la página \"Tareas\" para la migración.", + "heading": "Almacenamiento de datos binario" + }, + "cache_location": "Ruta de la caché. Requerido para utilizar streaming mediante HLS (por ejemplo dispositivos Apple) o DASH.", "cache_path_head": "Ruta relative para la caché", "calculate_md5_and_ohash_desc": "Calcular comprobación MD5 en adición a oshash. Habilitar esta opción puede provocar que los escaneos iniciales resulten más lentos. El cálculo de hash del nombre del fichero debe establecerse en oshash para deshabilitar el cálculo MD5.", "calculate_md5_and_ohash_label": "Calcular MD5 para los vídeos", @@ -262,12 +282,43 @@ "chrome_cdp_path_desc": "Ruta del archivo del ejecutable Chrome o una dirección remota (comenzando por http:// o https://, por ejemplo, http://localhost:9222/json/version) para una instancia Chrome.", "create_galleries_from_folders_desc": "Si esta opción está marcada se crearán automáticamente galerías de aquellos directorios que contienen imágenes.", "create_galleries_from_folders_label": "Crear galerías desde directorios con imágenes", + "database": "Base de datos", "db_path_head": "Ruta de la base de datos", "directory_locations_to_your_content": "Ruta relativa de los directorios que almacenan el contenido", "excluded_image_gallery_patterns_desc": "Expresiones regulares de archivos/rutas de imágenes que serán excluidos del escaneo y que serán añadidos a la tarea de depuración/limpieza", "excluded_image_gallery_patterns_head": "Patrones de imágenes/galerías excluidos", "excluded_video_patterns_desc": "Expresiones regulares de archivos/rutas de vídeos que serán excluidos del escaneo y que serán añadidos a la tarea de depuración/limpieza", "excluded_video_patterns_head": "Patrones de vídeo excluidos", + "ffmpeg": { + "hardware_acceleration": { + "desc": "Utiliza el hardware disponible para encodificar el video en tiempo real.", + "heading": "Encodificación hardware FFmpeg" + }, + "live_transcode": { + "input_args": { + "desc": "Avanzado: Argumentos adicionales para ffmpeg antes del campo de entrada al transcodificar vídeos en tiempo real.", + "heading": "Argumentos entrada transcodificación FFmpeg en tiempo real" + }, + "output_args": { + "desc": "Avanzado: Argumentos adicionales para ffmpeg antes del campo de salida al transcodificar vídeos en tiempo real.", + "heading": "Argumentos salida transcodificación FFmpeg en tiempo real" + } + }, + "transcode": { + "input_args": { + "desc": "Avanzado: Argumentos adicionales para ffmpeg antes del campo de entrada al generar vídeos.", + "heading": "Argumentos entrada transcodificación FFmpeg" + }, + "output_args": { + "desc": "Avanzado: Argumentos adicionales para ffmpeg antes del campo de salida al generar vídeos.", + "heading": "Argumentos salida transcodificación FFmpeg" + } + } + }, + "funscript_heatmap_draw_range": "Inluir rango en los mapas de calor generados", + "funscript_heatmap_draw_range_desc": "Dibujar rango de movimiento en el eje \"y\" del mapa de calor generado. Los mapas de calor existentes tendrán que ser generados de nuevo tras el cambio.", + "gallery_cover_regex_desc": "Expresión regular utilizada para identificar una imagen como carátula de una galería", + "gallery_cover_regex_label": "Patrón carátula galería", "gallery_ext_desc": "Lista delimitada por comas de extensiones de archivo que serán identificados como archivos de galería en formato zip.", "gallery_ext_head": "Extensiones de galería zip", "generated_file_naming_hash_desc": "Usar MD5 o oshash para la los nombres de archivo generados. Cambiar esta opción requiere que todas las escenas tengan relleno el correspondiente valor MD5/oshash. Después de cambiar este valor los ficheros generados existentes tendrán que ser migrados o regenerados. Ver la página de tareas para llevar a cabo la migración.", @@ -275,6 +326,7 @@ "generated_files_location": "Ruta relativa del directorio para los ficheros generados (marcadores de escena, vistas previas de escena, conjuntos de imágenes o “sprites”, etc)", "generated_path_head": "Ruta relativa para el directorio de arvhivos generados", "hashing": "Hashing", + "heatmap_generation": "Generación mapa de calor Funscript", "image_ext_desc": "Lista delimitada por comas de las extensiones de archivo que serán identificadas como imágenes.", "image_ext_head": "Extensiones de imagen", "include_audio_desc": "Incluye flujo de audio cuando se generen vistas previas.", @@ -303,7 +355,7 @@ "heading": "Ruta de Rastreadores" }, "scraping": "Rastreo", - "sqlite_location": "Ruta relativa para la base de datos SQLite (requiere reinicio)", + "sqlite_location": "Ruta relativa para la base de datos SQLite (requiere reinicio). AVISO: Almacenar la base de datos en un sistema distinto al servidor Stash (por ejemplo a través de la red) no está soportado!", "video_ext_desc": "Lista delimitada por comas de las extensiones de archivo que serán identificadas como vídeos.", "video_ext_head": "Extensiones de vídeo", "video_head": "Vídeo" @@ -345,6 +397,9 @@ }, "tasks": { "added_job_to_queue": "Añadido/a {operation_name} a la cola de trabajo", + "anonymise_and_download": "Realiza una copia aninimizada de la base de datos y descarga el fichero resultante.", + "anonymise_database": "Hace una copia de la base de datos al directorio de copias de seguridad anonimizando toda la información sensible. Esta copia se puede proveer a terceros para solucionar y depurar problemas. La base de datos original no es modificada. La base de datos anonimizada se almacena con el formato {filename_format}.", + "anonymising_database": "Aninimizando base de datos", "auto_tag": { "auto_tagging_all_paths": "Etiquetar automáticamente todas las rutas", "auto_tagging_paths": "Etiquetar automáticamente las siguientes rutas" @@ -353,7 +408,7 @@ "auto_tagging": "Auto-Etiquetado", "backing_up_database": "Guardando respaldo de la base de datos", "backup_and_download": "Lleva a cabo una copia de seguridad de la base de datos y la guarda en un fichero de respaldo.", - "backup_database": "Lleva a cabo una copia de seguridad de la base de datos en el mismo directorio en que se encuentre ésta. El formato de nombre del fichero generado es {filename_format}", + "backup_database": "Realiza una copia de seguridad de la base de datos en el directorio de copias de seguridad. La copia se almacena con el formato {filename_format}.", "cleanup_desc": "Buscar ficheros eliminados del sistema de archivos y eliminarlos de la base de datos. PRECAUCIÓN: esta es una acción destructiva.", "data_management": "Gestión de datos", "defaults_set": "Las opciones por defecto se han guardado y serán usadas cada vez que pulses el botoón de {action} en la página de Tareas.", @@ -371,6 +426,7 @@ "generate_previews_during_scan_tooltip": "Generar vistas previas animadas WebP, solo requerido si Tipo de Vista Previa es Imagen Animada.", "generate_sprites_during_scan": "Generar conjunto de imágenes o “sprites” de depuración", "generate_thumbnails_during_scan": "Generar miniaturas de las imágenes", + "generate_video_covers_during_scan": "Generar carátulas de escenas", "generate_video_previews_during_scan": "Generar vistas previas", "generate_video_previews_during_scan_tooltip": "Generar vistas previas en vídeo que se reproducen al pasar el ratón por encima de una escena", "generated_content": "Contenido generado", @@ -398,7 +454,16 @@ "incremental_import": "Importación gradual o progresiva desde un archivo zip de exportación aportado por el usuario.", "job_queue": "Cola de tareas", "maintenance": "Mantenimiento", + "migrate_blobs": { + "delete_old": "Borrar datos antiguos", + "description": "Migrar blobs al sistema de almacenamiento de blobs actual. Esta migración debería ejecutarse tras cambiar el sistema de almacenamiento de blobs. Opcionalmente se pueden borrar los datos antiguos tras la migración." + }, "migrate_hash_files": "Se ejecutará tras realizar un cambio de tipo de hash para renombrar los ficheros generados al nuevo formato hash.", + "migrate_scene_screenshots": { + "delete_files": "Borrar ficheros de capturas de pantalla", + "description": "Migrar capturas de pantalla de escenas al nuevo sistema de almacenamiento blob. Esta migración debería ejecutarse migrar un sistema existente a la versión 0.20. Opcionalmente se pueden borrar las capturas de pantalla antiguas tras la migración.", + "overwrite_existing": "Sobreescribir blobs existentes con datos de capturas de pantalla" + }, "migrations": "Migraciones", "only_dry_run": "Marca esta casilla para ejecutar en MODO DE SIMULACIÓN. No se eliminará información alguna, solo se mostrarán en el registro las acciones a realizar", "plugin_tasks": "Tareas de plugins", @@ -435,15 +500,20 @@ }, "basic_settings": "Ajustes básicos", "custom_css": { - "description": "La página debe ser recargada para que se lleven a cabo los cambios realizados.", + "description": "La página debe ser recargada para que se lleven a cabo los cambios realizados. Futuras versiones de Stash pueden no ser compatibles con CSS personalizados.", "heading": "CSS personalizado", "option_label": "Habilitar CSS personalizado" }, "custom_javascript": { - "description": "La página debe ser refrescada para que los cambios tomen efecto.", + "description": "La página debe ser refrescada para que los cambios tomen efecto. Futuras versiones de Stash pueden no ser compatibles con Javascripts personalizados.", "heading": "JavaScript personalizada", "option_label": "JavaScript personalizada activada" }, + "custom_locales": { + "description": "Sobreescribir traducciones individuales. El listado original se puede encontrar aquí: https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json. La página debe ser refrescada para reflejar los cambios realizados.", + "heading": "Traducciones personalizadas", + "option_label": "Traducción personalizada activada" + }, "delete_options": { "description": "Opciones por defecto al borrar escenas, imágenes y galerías.", "heading": "Opciones de eliminación", @@ -465,8 +535,21 @@ "heading": "Deshabilitar creación en menú desplegable" }, "heading": "Edición", + "max_options_shown": { + "label": "Número máximo de elementos a mostrar en menús desplegables" + }, "rating_system": { + "star_precision": { + "label": "Precisión estrellas valoraciones", + "options": { + "full": "Lleno", + "half": "Medias", + "quarter": "Cuartos", + "tenth": "Décimas" + } + }, "type": { + "label": "Sistema de valoración", "options": { "decimal": "Decimal", "stars": "Estrellas" @@ -480,6 +563,12 @@ }, "handy_connection": { "connect": "Conectar", + "server_offset": { + "heading": "Tiempo compensación servidor" + }, + "status": { + "heading": "Estado conexión móvil" + }, "sync": "Sincronizar" }, "handy_connection_key": { @@ -489,6 +578,11 @@ "image_lightbox": { "heading": "Lightbox para imágenes" }, + "image_wall": { + "direction": "Dirección", + "heading": "Imagen de fondo", + "margin": "Margen (píxeles)" + }, "images": { "heading": "Imágenes", "options": { @@ -510,6 +604,10 @@ "description": "Mostrar u ocultar los diferentes tipos de contenido del menú de navegación", "heading": "Elementos del menú" }, + "minimum_play_percent": { + "description": "Porcentaje de tiempo a reproducir una escena antes de incrementar su contador de visionados.", + "heading": "Porcentaje mínimo de reproducción" + }, "performers": { "options": { "image_location": { @@ -536,6 +634,7 @@ "scene_player": { "heading": "Reproductor de vídeo", "options": { + "always_start_from_beginning": "Siempre iniciar vídeo desde el inicio", "auto_start_video": "Iniciar vídeo automáticamente", "auto_start_video_on_play_selected": { "description": "Comenzar automáticamente la reproducción del vídeo de la escena cuando \"reproducir\" esté seleccionado o se seleccione una escena aleatoria desde la página de escenas", @@ -545,7 +644,8 @@ "description": "Reproducir la siguiente escena cuando el fichero en reproducción finalice", "heading": "(Por defecto) Continuar lista de reproducción" }, - "show_scrubber": "Mostrar depurador" + "show_scrubber": "Mostrar depurador", + "track_activity": "Registrar actividad" } }, "scene_wall": { diff --git a/ui/v2.5/src/locales/et-EE.json b/ui/v2.5/src/locales/et-EE.json index c3a010350d4..948d8df3fd0 100644 --- a/ui/v2.5/src/locales/et-EE.json +++ b/ui/v2.5/src/locales/et-EE.json @@ -127,6 +127,7 @@ "aliases": "Varjunimed", "all": "kõik", "also_known_as": "Tuntud ka kui", + "appears_with": "Esineb Koos", "ascending": "Kasvav", "average_resolution": "Keskmine Resolutsioon", "between_and": "ja", @@ -233,7 +234,9 @@ "server_display_name": "Serveri Nimi", "server_display_name_desc": "DLNA server nimi. Vaikimisi {server_name}, kui midagi pole sisestatud.", "successfully_cancelled_temporary_behaviour": "Edukalt tühistatud ajutine käitumine", - "until_restart": "restardini" + "until_restart": "restardini", + "video_sort_order": "Videote Sorteerimise Vaikeväärtus", + "video_sort_order_desc": "Viis, kuidas vaikimisi videoid sorteerida." }, "general": { "auth": { @@ -280,7 +283,7 @@ "check_for_insecure_certificates_desc": "Mõned lehed kasutavad ebaturvalisi ssl sertifikaate. Kui märkimata, kraapija jätab sertifikaadi kontrollimise vahele ning võimaldab nendelt lehtedelt andmeid kraapida. Kui kraapimise ajal esineb sertifikaadivigu, eemalda linnuke.", "chrome_cdp_path": "Chrome CDP tee", "chrome_cdp_path_desc": "Failitee Chrome käivitajani, või kaugaadress (algab http:// või https:// -iga, näiteks http://localhost:9222/json/version) Chrome'i eksemplarini.", - "create_galleries_from_folders_desc": "Kui lubatud, loob galeriisid pilte sisaldavatest kaustadest.", + "create_galleries_from_folders_desc": "Kui lubatud, loob vaikeväärtusena galeriisid pilte sisaldavatest kaustadest. Loo kasutas fail nimega .forcegallery või .nogallery, et seda sundida või sellest hoiduda.", "create_galleries_from_folders_label": "Loo galeriisid kaustadest, mis sisaldavad pilte", "database": "Andmebaas", "db_path_head": "Andmebaasi Failitee", @@ -306,8 +309,8 @@ }, "transcode": { "input_args": { - "desc": "Edasijõudnutele: Lisaargumendid mida edastada ffmpegi sisendväljale video reaalajas transkodeerimisel.", - "heading": "FFmpeg Reaalajas Transkodeerimise Sisendargumendid" + "desc": "Edasijõudnutele: Lisaargumendid mida edastada ffmpegi sisendväljale video genereerimisel.", + "heading": "FFmpeg Transkodeerimise Sisendargumendid" }, "output_args": { "desc": "Edasijõudnutele: Lisaargumendid mida edastada ffmpegi väljundväljale video genereerimisel.", @@ -810,7 +813,7 @@ "markers_tooltip": "20-sekundilised videod, mis algavad etteantud ajakoodiga.", "override_preview_generation_options": "Eelvaate Genereerimise Valikute Ülekirjutamine", "override_preview_generation_options_desc": "Eelvaate Genereerimise Sätete üle kirjutamine selle operatsiooni jaoks. Vaikeseaded määratakse jaotises Süsteem -> Eelvaate Genereerimine.", - "overwrite": "Kirjuta üle olemasolevad genereeritud failid", + "overwrite": "Kirjuta üle olemasolevad failid", "phash": "Nähtavad hashid (duplikaatide eemaldamiseks)", "preview_exclude_end_time_desc": "Välista stseeni eelvaadetest viimased x sekundid. See võib olla väärtus sekundites või protsent (nt 2%) stseeni kogukestusest.", "preview_exclude_end_time_head": "Välista lõpuaeg", @@ -852,6 +855,11 @@ "donate": "Anneta", "dupe_check": { "description": "Täpsetest madalamate tasemete arvutamine võib võtta kauem aega. Valepositiivsed tulemused võidakse tagastada ka madalamal täpsustasemel.", + "duration_diff": "Maksimaalse Pikkuse Vahe", + "duration_options": { + "any": "Kõik", + "equal": "Võrdne" + }, "found_sets": "{setCount, plural, one{# duplikaat leitud.} other {# duplikaati leitud.}}", "options": { "exact": "Täpselt", @@ -1074,6 +1082,7 @@ "saved_filters": "Salvestatud filtrid", "update_filter": "Uuenda Filtrit" }, + "second": "Sekund", "seconds": "Sekundit", "settings": "Sätted", "setup": { diff --git a/ui/v2.5/src/locales/fr-FR.json b/ui/v2.5/src/locales/fr-FR.json index d1bff5c6f24..93d25f24f17 100644 --- a/ui/v2.5/src/locales/fr-FR.json +++ b/ui/v2.5/src/locales/fr-FR.json @@ -127,6 +127,7 @@ "aliases": "Alias", "all": "tout", "also_known_as": "Également connu comme", + "appears_with": "Apparaît avec", "ascending": "Ascendant", "average_resolution": "Résolution moyenne", "between_and": "et", @@ -233,7 +234,9 @@ "server_display_name": "Nom d'affichage du serveur", "server_display_name_desc": "Nom d'affichage du serveur DLNA. Par défaut {server_name} si vide.", "successfully_cancelled_temporary_behaviour": "Le comportement temporaire a été annulé avec succès", - "until_restart": "jusqu'au redémarrage" + "until_restart": "jusqu'au redémarrage", + "video_sort_order": "Ordre de tri vidéo par défaut", + "video_sort_order_desc": "Commande pour trier les vidéos par défaut." }, "general": { "auth": { @@ -280,7 +283,7 @@ "check_for_insecure_certificates_desc": "Certains sites utilisent des certificats SSL non sécurisés. Lorsque cette option est décochée, l'extracteur de contenu ignore la vérification des certificats non sécurisés et autorise l'extraction de ces sites. Si vous obtenez une erreur de certificat lors de l'extraction, décochez cette option.", "chrome_cdp_path": "Chemin Chrome CDP (Chrome Debugging Protocol)", "chrome_cdp_path_desc": "Chemin de l'exécutable Chrome, ou adresse distante (commençant par http:// ou https://, par exemple http://localhost:9222/json/version) d'une instance de Chrome.", - "create_galleries_from_folders_desc": "Coché, crée des galeries à partir de dossiers contenant des images.", + "create_galleries_from_folders_desc": "Si vrai, crée par défaut des galeries à partir des répertoires contenant des images. Créer un fichier appelé .forcegallery ou .nogallery dans un répertoire pour forcer/empêcher cela.", "create_galleries_from_folders_label": "Créer des galeries à partir de dossiers contenant des images", "database": "Base de données", "db_path_head": "Chemin de la base de données", @@ -306,8 +309,8 @@ }, "transcode": { "input_args": { - "desc": "Avancé : Arguments supplémentaires à passer à FFmpeg avant le champ d'entrée lors du transcodage vidéo en temps réel.", - "heading": "Arguments d'entrée pour le transcodage FFmpeg en temps réel" + "desc": "Avancé : Arguments additionnels à transmettre à FFmpeg avant le champ d'entrée lors de la génération de la vidéo.", + "heading": "Arguments d'entrée de FFmpeg transcode" }, "output_args": { "desc": "Avancé : Arguments supplémentaires à passer à FFmpeg avant le champ de sortie lors de la génération de la vidéo.", @@ -810,7 +813,7 @@ "markers_tooltip": "Vidéos de 20 secondes qui débutent au repère temporel donné.", "override_preview_generation_options": "Remplacer les options de génération d'aperçu", "override_preview_generation_options_desc": "Remplacer les options de génération d'aperçu pour cette opération. Les valeurs par défaut sont définies dans Système -> Génération d'aperçus.", - "overwrite": "Remplacer les fichiers générés existants", + "overwrite": "Remplacer les fichiers existants", "phash": "Empreintes perceptuelles (pour la déduplication)", "preview_exclude_end_time_desc": "Exclure les x dernières secondes des aperçus de la scène. Cela peut être une valeur en secondes, ou un pourcentage (par exemple 2%) de la durée totale de la scène.", "preview_exclude_end_time_head": "Exclure le temps de fin", @@ -852,6 +855,11 @@ "donate": "Faire un don", "dupe_check": { "description": "Les niveaux en-deça de \"Exacte\" peuvent prendre plus de temps à calculer. Des faux positifs peuvent également être retournés à de faibles précisions.", + "duration_diff": "Écart maximum de temps", + "duration_options": { + "any": "Tous", + "equal": "Égal" + }, "found_sets": "{setCount, plural, one{# ensemble de doublons trouvé.} other {# ensembles de doublons trouvés.}}", "options": { "exact": "Exacte", @@ -1074,6 +1082,7 @@ "saved_filters": "Filtres sauvegardés", "update_filter": "Filtre actualisé" }, + "second": "Deuxième", "seconds": "Secondes", "settings": "Paramètres", "setup": { diff --git a/ui/v2.5/src/locales/pt-BR.json b/ui/v2.5/src/locales/pt-BR.json index 17946e51bcf..be494d6ee46 100644 --- a/ui/v2.5/src/locales/pt-BR.json +++ b/ui/v2.5/src/locales/pt-BR.json @@ -6,6 +6,7 @@ "add_to_entity": "Adicionar em {entityType}", "allow": "Permitir", "allow_temporarily": "Permitir temporariamente", + "anonymise": "Anonimizar", "apply": "Aplicar", "auto_tag": "Etiquetamento automático", "backup": "Backup", @@ -20,6 +21,7 @@ "confirm": "Confirmar", "continue": "Continuar", "create": "Criar", + "create_chapters": "Criar Capítulo", "create_entity": "Criar {entityType}", "create_marker": "Criar marcador", "created_entity": "Criar {entity_type}: {entity_name}", @@ -32,6 +34,7 @@ "delete_stashid": "Deletar StashID", "disallow": "Não permitir", "download": "Download", + "download_anonymised": "Download Anonimizado", "download_backup": "Download backup", "edit": "Editar", "edit_entity": "Editar {entityType}", @@ -54,9 +57,12 @@ "import": "Importar…", "import_from_file": "Importar de arquivo", "logout": "Sair", + "make_primary": "Tornar Primário", "merge": "Unir", "merge_from": "Unir do", "merge_into": "Unir em", + "migrate_blobs": "Migrar Blobs", + "migrate_scene_screenshots": "Migrar Print da Cena", "next_action": "Próximo", "not_running": "não realizado", "open_in_external_player": "Abrir em um reprodutor externo", @@ -66,6 +72,7 @@ "play_selected": "Tocar selecionado", "preview": "Previsualizar", "previous_action": "Voltar", + "reassign": "Reatribuir", "refresh": "Atualizar", "reload_plugins": "Recarregar plugins", "reload_scrapers": "Recarregar scrapers", @@ -98,10 +105,12 @@ "show": "Mostrar", "show_configuration": "Exibir Configuração", "skip": "Pular", + "split": "Dividir", "stop": "Parar", "submit": "Enviar", "submit_stash_box": "Enviar para o Stash-Box", "submit_update": "Enviar atualização", + "swap": "Trocar", "tasks": { "clean_confirm_message": "Tem certeza de que quer limpar? Isto irá apagar as informações do banco de dados e conteúdos gerados de todas as cenas e galerias que não são mais encontradas no sistema.", "dry_mode_selected": "Modo não destrutivo. Nenhum arquivo será apagado, apenas registrado.", @@ -120,11 +129,17 @@ "also_known_as": "Também conhecido(a) como", "ascending": "Ascendente", "average_resolution": "Resolução média", + "between_and": "e", "birth_year": "Ano de nascimento", "birthdate": "Data de nascimento", "bitrate": "Taxa de bits", + "blobs_storage_type": { + "database": "Banco de Dados", + "filesystem": "Arquivo de Sistema" + }, "captions": "Legendas", "career_length": "Duração da carreira", + "chapters": "Capítulos", "component_tagger": { "config": { "active_instance": "Instância do stash-box ativa:", @@ -178,6 +193,7 @@ "latest_version": "Última versão", "latest_version_build_hash": "Hash do executável da última versão:", "new_version_notice": "[NOVA]", + "release_date": "Data de Lançamento:", "stash_discord": "Junte-se ao nosso servidor no {url}", "stash_home": "Stash home no {url}", "stash_open_collective": "Apoie-nos através de {url}", @@ -244,6 +260,10 @@ "username": "Usuário", "username_desc": "Username para acessar o Stash. Deixe em branco para desativar a autenticação do usuário" }, + "backup_directory_path": { + "description": "Ditetório para aquivos de backup do banco de dados SQLite", + "heading": "Diretório de Backup" + }, "cache_location": "Localização do diretório do cache", "cache_path_head": "Caminho do cache", "calculate_md5_and_ohash_desc": "Calcular MD5 checksum além do oshash. A ativação fará com que as escaneamentos iniciais sejam mais lentos. Nomeação de arquivo Hash deve ser definido para oshash para desabilitar o cálculo MD5.", @@ -254,6 +274,7 @@ "chrome_cdp_path_desc": "Caminho do arquivo para o executavel do Chrome, ou um endereço remoto (começando com http:// ou https://, por exemplo http://localhost:9222/json/version) para uma instância do Chrome.", "create_galleries_from_folders_desc": "Se marcado, cria galerias de pastas contendo imagens.", "create_galleries_from_folders_label": "Crie galerias de pastas contendo imagens", + "database": "Banco de Dados", "db_path_head": "Caminho do banco de dados", "directory_locations_to_your_content": "Locais de diretório para o seu conteúdo", "excluded_image_gallery_patterns_desc": "Regexps de imagem e galeria de arquivos/caminhos para excluir do escaneamento e adicionar para limpar", diff --git a/ui/v2.5/src/locales/sv-SE.json b/ui/v2.5/src/locales/sv-SE.json index c882293031a..c508102a8ca 100644 --- a/ui/v2.5/src/locales/sv-SE.json +++ b/ui/v2.5/src/locales/sv-SE.json @@ -127,6 +127,7 @@ "aliases": "Alias", "all": "Allt", "also_known_as": "Även känd som", + "appears_with": "Uppträder Med", "ascending": "Stigande", "average_resolution": "Genomsnittlig upplösning", "between_and": "och", @@ -233,7 +234,9 @@ "server_display_name": "Servers visningsnamn", "server_display_name_desc": "Visningsnamnet för DLNA-servern. Återgår till standard {server_name} om tom.", "successfully_cancelled_temporary_behaviour": "Lyckad avbrytning av temporärt beteende", - "until_restart": "tills omstart" + "until_restart": "tills omstart", + "video_sort_order": "Standard Scen Sorteringsordning", + "video_sort_order_desc": "Ordningen som scener sorteras i som standard." }, "general": { "auth": { @@ -280,7 +283,7 @@ "check_for_insecure_certificates_desc": "Vissa webbplatser använder osäkra SSl-certifikat. När detta är avstängt kommer skraparen att kunna skrapa webbplatser med osäkra certifikat. Stäng av detta om du får certifikatfel vid skrapning.", "chrome_cdp_path": "Chrome CDP-sökväg", "chrome_cdp_path_desc": "Sökväg till Chrome-programfilen, eller en fjärradress (börjar med http:// eller https://, till exempel http://localhost:9222/json/version) till en Chrome-instans.", - "create_galleries_from_folders_desc": "Om sant, skapar gallerier från mappar som innehåller bilder.", + "create_galleries_from_folders_desc": "Om sant, skapar gallerier från mappar som innehåller bilder. Skapa en fil med namn .forcegalllery i en mapp för att aktivera/förhindra detta.", "create_galleries_from_folders_label": "Skapa gallerier från mappar som innehåller bilder", "database": "Databas", "db_path_head": "Databassökväg", @@ -306,8 +309,8 @@ }, "transcode": { "input_args": { - "desc": "Avancerat: Ytterligare argument att skicka till ffmpeg innan input-fältet vid liveomkodning av video.", - "heading": "FFmpeg Liveomkodning Input Argument" + "desc": "Avancerat: Ytterligare argument att skicka till ffmpeg innan input-fältet vid videogeneration.", + "heading": "FFmpeg Omkodning Input Argument" }, "output_args": { "desc": "Avancerat: Ytterligare argument att skicka till ffmpeg innan output-fältet vid videogenerering.", @@ -810,7 +813,7 @@ "markers_tooltip": "20-sekunders videor som börjar vid angiven tidsstämpel.", "override_preview_generation_options": "Åsidosätt Inställningar för Förhandsvisningsgeneration", "override_preview_generation_options_desc": "Åsidosätt inställningar för Förhandsvisningsgeneration. Standarder ställs in i System -> Förhandsvisningsgeneration.", - "overwrite": "Ersätt existerande genererade filer", + "overwrite": "Ersätt existerande filer", "phash": "Perceptuella hashar (för de-duplikation)", "preview_exclude_end_time_desc": "Exkludera de sista x sekunderna från videoförhandsvisning. Detta kan vara ett värde i sekunder, eller en procent (ex. 2%) av scenes totala speltid.", "preview_exclude_end_time_head": "Exludera sluttid", @@ -852,6 +855,11 @@ "donate": "Donera", "dupe_check": { "description": "Nivåer under 'Exakt' kan ta längre tid att beräkna. Falskt positiva svar riskeras också genom att välja en lägre nivå.", + "duration_diff": "Maximal Speltidsskillnad", + "duration_options": { + "any": "Allt", + "equal": "Lika" + }, "found_sets": "{setCount, plural, one{# kopia hittades.} other {# antal kopior hittades.}}", "options": { "exact": "Exakt", @@ -1074,6 +1082,7 @@ "saved_filters": "Sparade filter", "update_filter": "Uppdatera filter" }, + "second": "Sekund", "seconds": "Sekunder", "settings": "Inställningar", "setup": { diff --git a/ui/v2.5/src/locales/zh-CN.json b/ui/v2.5/src/locales/zh-CN.json index eb11b1ac52c..30a8491edf7 100644 --- a/ui/v2.5/src/locales/zh-CN.json +++ b/ui/v2.5/src/locales/zh-CN.json @@ -1,14 +1,14 @@ { "actions": { - "add": "添加", - "add_directory": "添加目录", - "add_entity": "添加 {entityType}", - "add_to_entity": "添加到 {entityType}", + "add": "新增", + "add_directory": "新增目录", + "add_entity": "新增{entityType}", + "add_to_entity": "新增至{entityType}", "allow": "允许", "allow_temporarily": "暂时允许", "anonymise": "匿名化", "apply": "应用", - "auto_tag": "自动添加标签", + "auto_tag": "自动标签", "backup": "备份", "browse_for_image": "浏览图片…", "cancel": "取消", @@ -21,28 +21,29 @@ "confirm": "确认", "continue": "继续", "create": "创建", - "create_entity": "创建 {entityType}", + "create_chapters": "创建章节", + "create_entity": "创建{entityType}", "create_marker": "创建标记", - "created_entity": "已经创建 {entity_type}: {entity_name}", + "created_entity": "已创建{entity_type}: {entity_name}", "customise": "自定义", "delete": "删除", - "delete_entity": "删除 {entityType}", + "delete_entity": "删除{entityType}", "delete_file": "删除文件", - "delete_file_and_funscript": "删除文件(和funscript)", - "delete_generated_supporting_files": "删除已经生成的支持文件", + "delete_file_and_funscript": "删除文件 (和 funscript)", + "delete_generated_supporting_files": "删除已生成的附加文件", "delete_stashid": "删除 StashID", "disallow": "不允许", "download": "下载", "download_anonymised": "匿名下载", "download_backup": "下载备份", "edit": "编辑", - "edit_entity": "编辑 {entityType}", + "edit_entity": "编辑{entityType}", "export": "导出", "export_all": "导出所有…", "find": "搜索", - "finish": "结束", - "from_file": "从文件…", - "from_url": "从网址…", + "finish": "完成", + "from_file": "来自文件…", + "from_url": "来自网址…", "full_export": "完整导出", "full_import": "完整导入", "generate": "生成", @@ -51,31 +52,33 @@ "hash_migration": "识别码迁移", "hide": "隐藏", "hide_configuration": "隐藏设定", - "identify": "鉴定", - "ignore": "略过", + "identify": "刮削", + "ignore": "忽略", "import": "导入…", "import_from_file": "从文件导入", - "logout": "注销", + "logout": "登出", "make_primary": "作为主要文件", "merge": "合并", - "merge_from": "合并源", - "merge_into": "合并入", - "next_action": "下一个", + "merge_from": "与其他项目合并", + "merge_into": "合并至其他项目", + "migrate_blobs": "迁移数据", + "migrate_scene_screenshots": "迁移截图", + "next_action": "下一步", "not_running": "未运行", "open_in_external_player": "由外部播放器打开", "open_random": "开启随机", "overwrite": "覆盖", "play_random": "随机播放", - "play_selected": "播放已选择的", + "play_selected": "播放所选", "preview": "预览", - "previous_action": "回去", + "previous_action": "上一步", "reassign": "重新分配", "refresh": "刷新", - "reload_plugins": "重新加载插件", - "reload_scrapers": "重新加载网页挖掘器", + "reload_plugins": "重载插件", + "reload_scrapers": "重载刮削器", "remove": "移除", "remove_from_gallery": "从图库中删除", - "rename_gen_files": "重命名生成的文件", + "rename_gen_files": "重命名已生成的文件", "rescan": "重新扫描", "reshuffle": "重新排列", "running": "运行中", @@ -83,23 +86,23 @@ "save_delete_settings": "在删除时使用以下默认选项", "save_filter": "保存过滤条件", "scan": "扫描", - "scrape": "挖掘", - "scrape_query": "挖掘 查询指令", - "scrape_scene_fragment": "以部分名字挖掘", - "scrape_with": "使用挖掘器…", + "scrape": "刮削", + "scrape_query": "刮削查询关键字", + "scrape_scene_fragment": "以部分名称刮削", + "scrape_with": "使用刮削器…", "search": "搜索", "select_all": "选择所有", - "select_entity": "选择 {entityType}", + "select_entity": "选择{entityType}", "select_folders": "选择目录", "select_none": "清除选择", - "selective_auto_tag": "选择性自动生成标签", + "selective_auto_tag": "选择性自动标签", "selective_clean": "选择性清理", "selective_scan": "选择性扫描", "set_as_default": "设置为默认", "set_back_image": "设置背面图…", "set_front_image": "设置正面图…", "set_image": "设置图片…", - "show": "展示", + "show": "显示", "show_configuration": "显示设定", "skip": "跳过", "split": "分割", @@ -124,14 +127,20 @@ "aliases": "别名", "all": "所有", "also_known_as": "又称作", + "appears_with": "显示方式", "ascending": "升序", "average_resolution": "平均分辨率", "between_and": "以及", "birth_year": "出生年份", "birthdate": "出生日期", "bitrate": "比特率", + "blobs_storage_type": { + "database": "数据库", + "filesystem": "文件系统" + }, "captions": "字幕", "career_length": "工龄", + "chapters": "章节", "component_tagger": { "config": { "active_instance": "目前使用的 Stash-box:", @@ -185,6 +194,7 @@ "latest_version": "最新版本", "latest_version_build_hash": "最新版本识别码:", "new_version_notice": "[新版本]", + "release_date": "发布日期:", "stash_discord": "加入我们的 {url} 频道", "stash_home": "Stash 主页在 {url}", "stash_open_collective": "通过 {url} 支持我们", @@ -224,7 +234,9 @@ "server_display_name": "服务器名称", "server_display_name_desc": "DLAN服务器的名称。如果为空,则默认为 {server_name}。", "successfully_cancelled_temporary_behaviour": "成功取消暂时的服务行为", - "until_restart": "直到服务重启" + "until_restart": "直到服务重启", + "video_sort_order": "默认视频排序", + "video_sort_order_desc": "默认情况下对视频进行排序。" }, "general": { "auth": { @@ -255,7 +267,15 @@ "description": "备份SQLite 数据库文件的目录路径", "heading": "备份用的路径" }, - "cache_location": "缓存目录的位置", + "blobs_path": { + "description": "在文件系统中存储二进制数据的位置。仅在使用 Filesystem blob 存储类型时适用。警告:更改此项需要手动移动现有数据。", + "heading": "二进制数据文件储存路径" + }, + "blobs_storage": { + "description": "存储二进制数据(如场景封面、表演者、工作室和标签图像)的地方。在改变这个值之后,必须使用迁移数据任务来迁移现有数据。参见迁移数据任务页面。", + "heading": "二进制数据存储类型" + }, + "cache_location": "缓存的目录位置。如果使用 HLS(例如在 Apple 设备上)或 DASH 进行流传输,则需要该位置。", "cache_path_head": "缓存路径", "calculate_md5_and_ohash_desc": "除了快搜码外还计算 MD5 值。如果开启,初次扫描时速度会较慢。如果关闭 MD5 值计算,则必须将文件名识别码算法设置为快搜码.", "calculate_md5_and_ohash_label": "计算影片MD5值", @@ -263,8 +283,9 @@ "check_for_insecure_certificates_desc": "某些网站所使用的证书可能有安全问题。取消勾选之后挖掘器会跳过证书安全性检查,如果你遇到了证书错误的问题,可以取消此选项。", "chrome_cdp_path": "谷歌浏览器 CDP 路径", "chrome_cdp_path_desc": "谷歌浏览器 可执行文件的路径, 或者远端地址 (以 http:// 或 https:// 开头, 比如 http://localhost:9222/json/version)。", - "create_galleries_from_folders_desc": "如果勾选,则会从包含图片的文件夹建立图库。", + "create_galleries_from_folders_desc": "如果勾选,则默认从包含图片的文件夹中创建画廊。在文件夹中创建一个名为 .forcegallery 或 .nogallery 的文件来强制/防止这种情况。", "create_galleries_from_folders_label": "从包含图片的文件夹建立图库", + "database": "数据库", "db_path_head": "数据库所在路径", "directory_locations_to_your_content": "你的影片等收藏的路径", "excluded_image_gallery_patterns_desc": "要从扫描中排除并会被[清除]功能所移除的图片及图库文件/路径的正则表达式", @@ -272,6 +293,10 @@ "excluded_video_patterns_desc": "要从扫描中排除并会被[清除]功能所移除的视频文件/路径的正则表达式", "excluded_video_patterns_head": "视频排除规则", "ffmpeg": { + "hardware_acceleration": { + "desc": "使用可用的硬件对视频进行编码来用于实时转码。", + "heading": "FFmpeg 硬件编码" + }, "live_transcode": { "input_args": { "desc": "高级:当直播转码的视频时,在输入参数前要传给ffmpeg用的附加参数。", @@ -293,6 +318,10 @@ } } }, + "funscript_heatmap_draw_range": "在生成的热图中包括范围", + "funscript_heatmap_draw_range_desc": "在生成的热图的y轴上绘制运动范围。更改后需要重新生成现有热图。", + "gallery_cover_regex_desc": "正则表达式用于将图像识别为图库封面", + "gallery_cover_regex_label": "图库封面模式", "gallery_ext_desc": "逗号(半角)分隔的文件扩展名列表,将被标识为图库或图包。", "gallery_ext_head": "图库压缩包扩展名", "generated_file_naming_hash_desc": "使用 MD5 或快搜码为生成的文件命名。 更改此设置要求所有短片都有对应的 MD5/快搜码 值。 更改此值后,之前生成的数据需要迁移或重新生成。 请参阅 [迁移] 页面。", @@ -300,6 +329,7 @@ "generated_files_location": "生成数据的存储目录(短片标记,短片预览,预览图等)", "generated_path_head": "生成数据的存储路径", "hashing": "识别码设置", + "heatmap_generation": "Funscript 热图生成", "image_ext_desc": "逗号(半角)分隔的文件扩展名列表,将被标识为图片。", "image_ext_head": "图片扩展名", "include_audio_desc": "生成预览时包括音频流.", @@ -321,21 +351,21 @@ "description": "Python 执行程序的位置。给网页挖掘器和插件的源文件使用。如果没有,python会从环境变量找到", "heading": "Python 路径" }, - "scraper_user_agent": "挖掘器用户代理(User Agent)", - "scraper_user_agent_desc": "挖掘器运行时的用户代理名(User Agent)", + "scraper_user_agent": "刮削器用户代理 (User Agent)", + "scraper_user_agent_desc": "刮削器运行时的用户代理 (User Agent)", "scrapers_path": { - "description": "含有网路挖掘器配置文件的路径", - "heading": "网页挖掘器路径" + "description": "含有刮削器配置文件的路径", + "heading": "刮削器路径" }, - "scraping": "挖掘器设置", - "sqlite_location": "Sqlite 数据库的位置(需要重启)", + "scraping": "刮削器设置", + "sqlite_location": "SQLite 数据库的位置(需要重启)。警告:不支持将数据库放在与 Stash 服务器以外的系统上(即通过网络)!", "video_ext_desc": "逗号(半角)分隔的文件扩展名列表,将被标识为视频。", "video_ext_head": "视频扩展名", "video_head": "视频设置" }, "library": { "exclusions": "不包括", - "gallery_and_image_options": "图库和照片的选项", + "gallery_and_image_options": "图库和图片的选项", "media_content_extensions": "媒体的文件扩展名" }, "logs": { @@ -343,21 +373,21 @@ }, "plugins": { "hooks": "回调", - "triggers_on": "触发" + "triggers_on": "触发于" }, "scraping": { - "entity_metadata": "{entityType} 元数据", - "entity_scrapers": "{entityType} 挖掘器", - "excluded_tag_patterns_desc": "从抓取结果中排除的标签名称的正则表达式", + "entity_metadata": "{entityType}元数据", + "entity_scrapers": "{entityType}刮削器", + "excluded_tag_patterns_desc": "从刮削结果中排除的标签名称的正则表达式", "excluded_tag_patterns_head": "排除标签的正则表达式", - "scraper": "挖掘器", - "scrapers": "挖掘器", + "scraper": "刮削器", + "scrapers": "刮削器", "search_by_name": "按名称搜索", "supported_types": "支持类型", "supported_urls": "支持链接" }, "stashbox": { - "add_instance": "新增 stash-box 入口", + "add_instance": "新增 Stash-Box 入口", "api_key": "API 密钥", "description": "Stash-box 根据指纹和文件名自动标记短片和演员。\n入口和 API 密钥可以在您的帐户页面上的 stash-box 实例中找到。 添加多个实例时必须设置名称。", "endpoint": "入口", @@ -399,6 +429,7 @@ "generate_previews_during_scan_tooltip": "生成WebP动画预览,仅适用于预览类型设为动图的情况.", "generate_sprites_during_scan": "生成时间轴预览小图", "generate_thumbnails_during_scan": "生成图片的缩略图", + "generate_video_covers_during_scan": "生成短片封面", "generate_video_previews_during_scan": "生成预览", "generate_video_previews_during_scan_tooltip": "产生视频预览,用以鼠标移到短片上时播放", "generated_content": "生成的内容", @@ -426,7 +457,16 @@ "incremental_import": "从导出的 zip 文件增量导入。", "job_queue": "任务队列", "maintenance": "维护", + "migrate_blobs": { + "delete_old": "删除旧数据", + "description": "将 blob 迁移到当前 blob 存储系统。应在更改 blob 存储系统之后运行此迁移。可以选择在迁移后删除旧数据。" + }, "migrate_hash_files": "使用更改之后的识别码算法重新命名已经存在的文件到新的识别码格式。", + "migrate_scene_screenshots": { + "delete_files": "删除截图文件", + "description": "将短片屏幕截图迁移到新的 blob 存储系统中。应在将现有系统迁移到 0.20 之后运行此迁移。可以选择在迁移后删除旧的屏幕截图。", + "overwrite_existing": "用屏幕截图数据覆盖现有 Blob" + }, "migrations": "迁移", "only_dry_run": "仅模拟运行,不要删除任何东西", "plugin_tasks": "插件任务", @@ -498,13 +538,17 @@ "heading": "禁止下拉菜单建立" }, "heading": "编辑", + "max_options_shown": { + "label": "在选择下拉列表中显示的最大项数" + }, "rating_system": { "star_precision": { "label": "评分星的精度", "options": { "full": "完整", "half": "一半", - "quarter": "四分之一" + "quarter": "四分之一", + "tenth": "十分之一" } }, "type": { @@ -537,6 +581,11 @@ "image_lightbox": { "heading": "图片灯箱" }, + "image_wall": { + "direction": "方向", + "heading": "图片墙", + "margin": "边距(像素)" + }, "images": { "heading": "图片", "options": { @@ -634,8 +683,8 @@ "heading": "标签显示", "options": { "show_child_tagged_content": { - "description": "在标签显示里,显示副标签的内容", - "heading": "显示副标签的内容" + "description": "在标签页面中,同时显示子标签的内容", + "heading": "显示子标签内容" } } }, @@ -656,7 +705,7 @@ }, "country": "国家", "cover_image": "封面图片", - "created_at": "创建时间", + "created_at": "创建于", "criterion": { "greater_than": "大于", "less_than": "小于", @@ -666,7 +715,7 @@ "between": "介于…之间", "equals": "是", "excludes": "不包含", - "format_string": "{criterion} {modifierString} {valueString}", + "format_string": "{criterion}{modifierString}{valueString}", "greater_than": "大于", "includes": "包含", "includes_all": "包含所有", @@ -680,6 +729,8 @@ }, "custom": "自定义", "date": "日期", + "date_format": "YYYY-MM-DD", + "datetime_format": "YYYY-MM-DD HH:MM", "death_date": "去世日期", "death_year": "去世年份", "descending": "降序", @@ -688,13 +739,13 @@ "details": "简介", "developmentVersion": "开发版本", "dialogs": { - "create_new_entity": "创建新的 {entity}", - "delete_alert": "以下 {count, plural, one {{singularEntity}} other {{pluralEntity}}} 会被永久删除:", - "delete_confirm": "确定要删除 {entityName} 吗?", - "delete_entity_desc": "{count, plural, one {确定要删除{singularEntity}吗? 除非同时删除文件, 否则下次扫描时{singularEntity}会重新被添加到数据库中。} other {确定要删除{pluralEntity}吗? 除非同时删除文件, 否则下次扫描时{pluralEntity}会重新被添加到数据库中。}}", - "delete_entity_simple_desc": "{count, plural, one {你确定要删除这个 {singularEntity}?} other {你确定要删除这些 {pluralEntity}?}}", - "delete_entity_title": "{count, plural, one {删除 {singularEntity}} other {删除 {pluralEntity}}}", - "delete_galleries_extra": "...以及任何没有加入其它图库的图片.", + "create_new_entity": "创建新{entity}", + "delete_alert": "以下{count, plural, one {{singularEntity}} other {{pluralEntity}}}将被永久删除:", + "delete_confirm": "确定要删除{entityName}吗?", + "delete_entity_desc": "{count, plural, one {确定要删除{singularEntity}吗?除非同时删除文件?否则下次扫描时{singularEntity}会重新被添加到数据库中。} other {确定要删除{pluralEntity}吗?除非同时删除文件?否则下次扫描时{pluralEntity}会重新被添加到数据库中。}}", + "delete_entity_simple_desc": "{count, plural, one {你确定要删除这个{singularEntity}?} other {你确定要删除这些{pluralEntity}?}}", + "delete_entity_title": "{count, plural, one {删除{singularEntity}} other {删除{pluralEntity}}}", + "delete_galleries_extra": "...以及任何没有加入其它图库的图片。", "delete_gallery_files": "删除图库的文件夹/压缩包和任何没有加入其它图库的图片.", "delete_object_desc": "确定要删除{count, plural, one {这个{singularEntity}} other {这些{pluralEntity}}}?", "delete_object_overflow": "…以及 {count} 个其他 {count, plural, one {{singularEntity}} other {{pluralEntity}}}.", @@ -703,6 +754,14 @@ "edit_entity_title": "编辑 {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "export_include_related_objects": "导出时包含相关的数据", "export_title": "导出", + "imagewall": { + "direction": { + "column": "列", + "description": "基于列或行的布局。", + "row": "行" + }, + "margin_desc": "每个完整图像周围的边距像素数。" + }, "lightbox": { "delay": "延迟(秒)", "display_mode": { @@ -712,6 +771,7 @@ "original": "原图" }, "options": "选项", + "page_header": "第 {page} 页,共 {total} 页", "reset_zoom_on_nav": "图片改动时重设缩放度", "scale_up": { "description": "放大小图到整屏", @@ -739,6 +799,7 @@ "destination": "重新指定至" }, "scene_gen": { + "covers": "短片封面", "force_transcodes": "强制生成转码文件", "force_transcodes_tooltip": "默认情况下,转码文件只会在浏览器不支持的情况下生成。如果开启此选项,即使浏览器支持该视频,也会生成转码文件。", "image_previews": "动图预览", @@ -752,7 +813,7 @@ "markers_tooltip": "从给出的时间码开始20秒的视频.", "override_preview_generation_options": "凌驾预览生成选项", "override_preview_generation_options_desc": "在本次操作中凌驾于预览生成设定的选项。默认的选项在 系统 -> 预览生成。", - "overwrite": "覆盖已经生成的文件", + "overwrite": "覆盖现有文件", "phash": "感知识别码PHash(用于检测短片是否重复)", "preview_exclude_end_time_desc": "从短片预览中排除最后 x 秒。可以是一个以秒为单位的值,也可以是百分比(比如2%)。", "preview_exclude_end_time_head": "排除结束时间", @@ -794,6 +855,11 @@ "donate": "赞助", "dupe_check": { "description": "低于“精确”的准确度需要更长的时间来计算,但使用较低的准确度可能会产生误报。", + "duration_diff": "最大持续时间差异", + "duration_options": { + "any": "任意", + "equal": "相等" + }, "found_sets": "{setCount, plural, one{# 个发现的重复数据。} other {# 个发现的重复数据。}}", "options": { "exact": "精确", @@ -828,11 +894,16 @@ "warmth": "色温" }, "empty_server": "增加一些短片到服务器以看到本页面的推荐。", + "errors": { + "image_index_greater_than_zero": "图像索引必须大于 0", + "lazy_component_error_help": "如果您最近升级了 Stash,请重新加载页面或清除浏览器缓存。", + "something_went_wrong": "出了些问题。" + }, "ethnicity": "人种", "existing_value": "现值", "eye_color": "瞳孔颜色", "fake_tits": "假奶", - "false": "假", + "false": "否", "favourite": "收藏", "file": "文件", "file_count": "文件数量", @@ -875,6 +946,7 @@ "syncing": "正在和服务器同步", "uploading": "上传脚本中" }, + "hasChapters": "已有章节", "hasMarkers": "含有章节标记", "height": "身高", "height_cm": "高(cm)", @@ -882,6 +954,7 @@ "ignore_auto_tag": "忽略自动标签", "image": "图片", "image_count": "图片数量", + "image_index": "图像 #", "images": "图片", "include_parent_tags": "包含母标签", "include_sub_studios": "包含子工作室", @@ -1004,17 +1077,23 @@ "scenes": "短片", "scenes_updated_at": "短片更新时间", "search_filter": { + "edit_filter": "编辑筛选器", "name": "过滤", "saved_filters": "保存过滤器", "update_filter": "更新过滤器" }, + "second": "第二", "seconds": "秒", "settings": "设置", "setup": { "confirm": { "almost_ready": "设置就快完成,请确认以下设定。你可以点“回去”去改变任何不正确的东西,如果一切看来都好,请按“确定”去建立你的系统。", + "blobs_directory": "二进制文件目录", + "cache_directory": "缓存目录", "configuration_file_location": "配置文件的路径:", "database_file_path": "数据库文件的路径", + "default_blobs_location": "<用户数据库>", + "default_cache_location": "<包含配置文件的路径>/cache", "default_db_location": "<含有设置文件的路径>/stash-go.sqlite", "default_generated_content_location": "<含有设置文件的路径>/generated", "generated_directory": "生成资料的路径", @@ -1050,12 +1129,20 @@ }, "paths": { "database_filename_empty_for_default": "数据库文件名 (留空则用默认名)", - "description": "接下来,我们需要决定哪里找到你的收藏,哪里存放stash数据库和产生资料文件。如果需要,这些设定可在以后再修改。", + "description": "接下来,我们需要决定哪里找到你的收藏,哪里存放 stash 数据库和产生资料文件。如果需要,这些设定可在以后再修改。", + "path_to_blobs_directory_empty_for_database": "Blob 目录的路径(为空以使用数据库)", + "path_to_cache_directory_empty_for_default": "缓存目录的路径(默认为空)", "path_to_generated_directory_empty_for_default": "生成资料的文件夹路径 (留空则使用默认路径)", "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_addendum": "或者,如果要将此数据存储在数据库中,可以将此字段留空注意:这将增加数据库文件的大小,并增加数据库迁移时间。", + "where_can_stash_store_cache_files": "Stash可以在哪里存储缓存文件?", + "where_can_stash_store_cache_files_description": "为了使 HLS/DASH 实时转码等功能发挥作用,Stash 需要一个临时文件的缓存目录。默认情况下,Stash将在包含配置文件的目录中创建一个缓存目录。如果要更改此路径,请输入绝对或相对(相对于当前工作目录)路径。如果该目录不存在,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_description": "Stash 使用 sqlite 数据库来存放你的收藏的元数据。默认情况下,会建立stash-go.sqlite在包含有你配置文件的目录里。如果你想改动,请输入一个绝对,或者相对(对于当前目录)的文件名。", + "where_can_stash_store_its_database_warning": "警告:不支持将数据库存储在运行 Stash 的不同系统上(例如,在另一台计算机上运行 Stash 服务器时将数据库存储到 NAS 上)!SQLite 不适合在网络上使用,尝试这样做很容易导致整个数据库损坏。", "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": "你的收藏在哪里?", @@ -1090,14 +1177,14 @@ }, "welcome_to_stash": "欢迎使用Stash" }, - "stash_id": "Stash 号", - "stash_id_endpoint": "Stash 号的终端", - "stash_ids": "Stash号", + "stash_id": "Stash ID", + "stash_id_endpoint": "Stash ID 端点", + "stash_ids": "Stash IDs", "stashbox": { - "go_review_draft": "去 {endpoint_name} 检阅草稿。", - "selected_stash_box": "选择的 Stash-Box 终端", + "go_review_draft": "到 {endpoint_name} 预览草稿。", + "selected_stash_box": "已选择的 Stash-Box 端点", "submission_failed": "提交失败", - "submission_successful": "成功提交", + "submission_successful": "提交成功", "submit_update": "已存在于 {endpoint_name}" }, "statistics": "统计", @@ -1108,49 +1195,52 @@ }, "status": "状态:{statusText}", "studio": "工作室", - "studio_depth": "深度(为空时显示所有)", + "studio_depth": "深度 (为空时显示全部)", "studios": "工作室", - "sub_tag_count": "副标签 数量", - "sub_tag_of": "{parent}的副标签", - "sub_tags": "副标签", - "subsidiary_studios": "旗下的工作室", - "synopsis": "影片概要", + "sub_tag_count": "子标签数量", + "sub_tag_of": "{parent}的子标签", + "sub_tags": "子标签", + "subsidiary_studios": "子工作室", + "synopsis": "概要", "tag": "标签", "tag_count": "标签数量", "tags": "标签", "tattoos": "纹身", "title": "标题", "toast": { - "added_entity": "已添加 {count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "added_entity": "已添加{count, plural, one {{singularEntity}} other {{pluralEntity}}}", "added_generation_job_to_queue": "已添加生成工作至队列", - "created_entity": "已经创建{entity}", + "created_entity": "已创建{entity}", "default_filter_set": "默认过滤器", - "delete_past_tense": "已经删除 {count, plural, one {{singularEntity}} other {{pluralEntity}}}", - "generating_screenshot": "正在生成截图…", - "merged_scenes": "拼合的短片", - "merged_tags": "已经合并标签", - "reassign_past_tense": "文件重新指定了", - "removed_entity": "已移除 {count, plural, one {{singularEntity}} other {{pluralEntity}}}", - "rescanning_entity": "正在重新扫描 {count, plural, one {{singularEntity}} other {{pluralEntity}}}…", - "saved_entity": "已经保存 {entity}", - "started_auto_tagging": "已经开始自动生成标签", - "started_generating": "开始产生资料", - "started_importing": "开始导入中", - "updated_entity": "已经更新 {entity}" + "delete_past_tense": "已删除{count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "generating_screenshot": "生成截图中…", + "image_index_too_large": "错误:图像索引大于库中的图像数", + "merged_scenes": "合并的短片", + "merged_tags": "已合并的标签", + "reassign_past_tense": "已重新指定文件", + "removed_entity": "已删除{count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "rescanning_entity": "重新扫描{count, plural, one {{singularEntity}} other {{pluralEntity}}}中…", + "saved_entity": "已保存{entity}", + "started_auto_tagging": "自动生成标签中", + "started_generating": "生成资料中", + "started_importing": "导入中", + "updated_entity": "已更新{entity}" }, - "total": "总共", - "true": "真", - "twitter": "推特", - "type": "类别", - "updated_at": "更新时间", + "total": "总计", + "true": "是", + "twitter": "Twitter", + "type": "种类", + "updated_at": "更新于", "url": "链接", "validation": { - "aliases_must_be_unique": "别名必须是唯一的" + "aliases_must_be_unique": "别名必须是唯一的", + "date_invalid_form": "${path} 的格式必须为 YYYY-MM-DD", + "required": "${path} 是必填字段" }, "videos": "视频", "view_all": "查看全部", "weight": "体重", - "weight_kg": "重量(kg)", + "weight_kg": "体重 (kg)", "years_old": "岁", "zip_file_count": "压缩文件数量" } diff --git a/ui/v2.5/src/locales/zh-TW.json b/ui/v2.5/src/locales/zh-TW.json index 432b4dcf358..e8685a94fc7 100644 --- a/ui/v2.5/src/locales/zh-TW.json +++ b/ui/v2.5/src/locales/zh-TW.json @@ -193,7 +193,7 @@ "latest_version": "最新版本", "latest_version_build_hash": "最新版本的雜湊值:", "new_version_notice": "[新版本]", - "release_date": "上映日期:", + "release_date": "發布日期:", "stash_discord": "加入我們的 {url} 頻道", "stash_home": "Stash 的 {url} 專案", "stash_open_collective": "透過 {url} 來支持本計畫的開發", @@ -486,8 +486,8 @@ }, "custom_javascript": { "description": "必須重新整理頁面才能使更改生效。", - "heading": "自定義 JavaScript", - "option_label": "已啟用自定義 JavaScript" + "heading": "自訂 JavaScript", + "option_label": "已啟用自訂 JavaScript" }, "custom_locales": { "description": "強制使用特定翻譯字串。主列表請參閱 https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json。必須重新整理頁面才能使更改生效。", From 06e924d01094a816ab73d59fd15037a1f028e067 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 19 May 2023 12:33:53 +1000 Subject: [PATCH 027/135] Change modifier selector to pills (#3598) * Use pills for modifier selector * Fix caption default modifier * Increase clickable area for criterion remove If the area becomes too large, we can use half margin half padding. Reduces the amount of pixel hunting required to click. * Use pill-styled buttons --- .../src/components/List/CriterionEditor.tsx | 28 +++++++++-------- ui/v2.5/src/components/List/styles.scss | 30 ++++++++++++++++--- ui/v2.5/src/index.scss | 3 +- .../models/list-filter/criteria/captions.ts | 1 + 4 files changed, 44 insertions(+), 18 deletions(-) diff --git a/ui/v2.5/src/components/List/CriterionEditor.tsx b/ui/v2.5/src/components/List/CriterionEditor.tsx index aac0c00f84a..763d7c4f08f 100644 --- a/ui/v2.5/src/components/List/CriterionEditor.tsx +++ b/ui/v2.5/src/components/List/CriterionEditor.tsx @@ -1,6 +1,6 @@ import cloneDeep from "lodash-es/cloneDeep"; import React, { useCallback, useMemo } from "react"; -import { Form } from "react-bootstrap"; +import { Button, Form } from "react-bootstrap"; import { CriterionModifier } from "src/core/generated-graphql"; import { DurationCriterion, @@ -40,6 +40,7 @@ import { OptionsListFilter } from "./Filters/OptionsListFilter"; import { PathFilter } from "./Filters/PathFilter"; import { PhashCriterion } from "src/models/list-filter/criteria/phash"; import { PhashFilter } from "./Filters/PhashFilter"; +import cx from "classnames"; interface IGenericCriterionEditor { criterion: Criterion; @@ -55,9 +56,9 @@ const GenericCriterionEditor: React.FC = ({ const { options, modifierOptions } = criterion.criterionOption; const onChangedModifierSelect = useCallback( - (event: React.ChangeEvent) => { + (m: CriterionModifier) => { const newCriterion = cloneDeep(criterion); - newCriterion.modifier = event.target.value as CriterionModifier; + newCriterion.modifier = m; setCriterion(newCriterion); }, [criterion, setCriterion] @@ -69,18 +70,21 @@ const GenericCriterionEditor: React.FC = ({ } return ( - + {modifierOptions.map((c) => ( - + ); }, [modifierOptions, onChangedModifierSelect, criterion.modifier, intl]); diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 40a9ead9123..38b61606cfe 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -183,11 +183,33 @@ input[type="range"].zoom-slider { } } -.modifier-selector { - margin-bottom: 1rem; +.modifier-options { + display: flex; + flex-wrap: wrap; + justify-content: center; +} - // to accommodate for caret - padding-right: 2rem; +.modifier-options .modifier-option { + background-color: $secondary; + border: none; + border-radius: 10rem; + cursor: pointer; + display: inline-block; + font-size: 100%; + font-weight: 700; + line-height: 1; + margin-bottom: 0.5rem; + margin-right: 0.25rem; + padding: 0.25em 0.6em; + text-align: center; + transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, + border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + vertical-align: baseline; + white-space: nowrap; + + &.selected { + background-color: $primary; + } } .filter-tags .clear-all-button { diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index 5e405368434..2aa3a0c65bc 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -375,9 +375,8 @@ div.dropdown-menu { color: $dark-text; font-size: 12px; line-height: 1rem; - margin-left: 0.5rem; opacity: 0.5; - padding: 0; + padding: 0 0 0 0.5rem; position: relative; &:active, diff --git a/ui/v2.5/src/models/list-filter/criteria/captions.ts b/ui/v2.5/src/models/list-filter/criteria/captions.ts index 0ba4fc8f459..13c72dc7756 100644 --- a/ui/v2.5/src/models/list-filter/criteria/captions.ts +++ b/ui/v2.5/src/models/list-filter/criteria/captions.ts @@ -17,6 +17,7 @@ class CaptionsCriterionOptionType extends CriterionOption { CriterionModifier.IsNull, CriterionModifier.NotNull, ], + defaultModifier: CriterionModifier.Includes, options: languageStrings, }); } From 0a143941132cb130b51de1123003b0b715e85901 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 19 May 2023 12:36:28 +1000 Subject: [PATCH 028/135] Allow filter header to be tabbable (#3739) --- .../src/components/List/EditFilterDialog.tsx | 2 +- ui/v2.5/src/components/List/styles.scss | 19 ++++++++++++++++--- ui/v2.5/src/styles/_theme.scss | 1 + 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/ui/v2.5/src/components/List/EditFilterDialog.tsx b/ui/v2.5/src/components/List/EditFilterDialog.tsx index 8a41b4a2d9d..4443ad805fd 100644 --- a/ui/v2.5/src/components/List/EditFilterDialog.tsx +++ b/ui/v2.5/src/components/List/EditFilterDialog.tsx @@ -130,7 +130,7 @@ const CriterionOptionList: React.FC = ({ function renderCard(c: CriterionOption, isPin: boolean) { return ( - + Date: Fri, 19 May 2023 12:36:53 +1000 Subject: [PATCH 029/135] Filter query (#3740) * Add search field to filter dialog * Add / shortcut to focus query * Fix f keybind typing f into query field * Document keyboard shortcut --- .../src/components/List/EditFilterDialog.tsx | 51 ++++++++++++++++--- ui/v2.5/src/components/List/ItemList.tsx | 8 ++- ui/v2.5/src/components/List/styles.scss | 9 ++++ .../src/docs/en/Manual/KeyboardShortcuts.md | 2 +- ui/v2.5/src/utils/focus.ts | 17 ++++++- 5 files changed, 78 insertions(+), 9 deletions(-) diff --git a/ui/v2.5/src/components/List/EditFilterDialog.tsx b/ui/v2.5/src/components/List/EditFilterDialog.tsx index 4443ad805fd..581fd31fb87 100644 --- a/ui/v2.5/src/components/List/EditFilterDialog.tsx +++ b/ui/v2.5/src/components/List/EditFilterDialog.tsx @@ -7,7 +7,7 @@ import React, { useRef, useState, } from "react"; -import { Accordion, Button, Card, Modal } from "react-bootstrap"; +import { Accordion, Button, Card, Form, Modal } from "react-bootstrap"; import cx from "classnames"; import { CriterionValue, @@ -34,6 +34,8 @@ import { useToast } from "src/hooks/Toast"; import { useConfigureUI } from "src/core/StashService"; import { IUIConfig } from "src/core/config"; import { FilterMode } from "src/core/generated-graphql"; +import { useFocusOnce } from "src/utils/focus"; +import Mousetrap from "mousetrap"; interface ICriterionList { criteria: string[]; @@ -222,11 +224,14 @@ export const EditFilterDialog: React.FC = ({ const { configuration } = useContext(ConfigurationContext); + const [searchValue, setSearchValue] = useState(""); const [currentFilter, setCurrentFilter] = useState( cloneDeep(filter) ); const [criterion, setCriterion] = useState>(); + const [searchRef, setSearchFocus] = useFocusOnce(); + const { criteria } = currentFilter; const criteriaList = useMemo(() => { @@ -275,17 +280,31 @@ export const EditFilterDialog: React.FC = ({ const ui = (configuration?.ui ?? {}) as IUIConfig; const [saveUI] = useConfigureUI(); + const filteredOptions = useMemo(() => { + const trimmedSearch = searchValue.trim().toLowerCase(); + if (!trimmedSearch) { + return criterionOptions; + } + + return criterionOptions.filter((c) => { + return intl + .formatMessage({ id: c.messageID }) + .toLowerCase() + .includes(trimmedSearch); + }); + }, [intl, searchValue, criterionOptions]); + const pinnedFilters = useMemo( () => ui.pinnedFilters?.[filterModeToConfigKey(currentFilter.mode)] ?? [], [currentFilter.mode, ui.pinnedFilters] ); const pinnedElements = useMemo( - () => criterionOptions.filter((c) => pinnedFilters.includes(c.messageID)), - [pinnedFilters, criterionOptions] + () => filteredOptions.filter((c) => pinnedFilters.includes(c.messageID)), + [pinnedFilters, filteredOptions] ); const unpinnedElements = useMemo( - () => criterionOptions.filter((c) => !pinnedFilters.includes(c.messageID)), - [pinnedFilters, criterionOptions] + () => filteredOptions.filter((c) => !pinnedFilters.includes(c.messageID)), + [pinnedFilters, filteredOptions] ); const editingCriterionChanged = useCompare(editingCriterion); @@ -304,6 +323,17 @@ export const EditFilterDialog: React.FC = ({ editingCriterionChanged, ]); + useEffect(() => { + Mousetrap.bind("/", (e) => { + setSearchFocus(); + e.preventDefault(); + }); + + return () => { + Mousetrap.unbind("/"); + }; + }); + async function updatePinnedFilters(filters: string[]) { const configKey = filterModeToConfigKey(currentFilter.mode); try { @@ -403,7 +433,16 @@ export const EditFilterDialog: React.FC = ({ <> onCancel()} className="edit-filter-dialog"> - +
+ +
+ setSearchValue(e.target.value)} + value={searchValue} + placeholder={`${intl.formatMessage({ id: "actions.search" })}…`} + ref={searchRef} + />
({ // set up hotkeys useEffect(() => { - Mousetrap.bind("f", () => setShowEditFilter(true)); + Mousetrap.bind("f", (e) => { + setShowEditFilter(true); + // prevent default behavior of typing f in a text field + // otherwise the filter dialog closes, the query field is focused and + // f is typed. + e.preventDefault(); + }); return () => { Mousetrap.unbind("f"); diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index cec9da69e49..8b4c678273f 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -118,6 +118,15 @@ input[type="range"].zoom-slider { } .edit-filter-dialog { + .modal-header { + align-items: center; + padding: 0.5rem 1rem; + + .search-input { + width: auto; + } + } + .modal-body { padding-left: 0; padding-right: 0; diff --git a/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md b/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md index 7eee67ef495..98c13cad0ff 100644 --- a/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md +++ b/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md @@ -24,7 +24,7 @@ | Keyboard sequence | Action | |-------------------|--------| -| `/` | Focus search field | +| `/` | Focus search field / focus query field in filter dialog | | `f` | Show Add Filter dialog | | `r` | Reshuffle if sorted by random | | `v g` | Set view to grid | diff --git a/ui/v2.5/src/utils/focus.ts b/ui/v2.5/src/utils/focus.ts index 0ab1e3b68b3..189920752c8 100644 --- a/ui/v2.5/src/utils/focus.ts +++ b/ui/v2.5/src/utils/focus.ts @@ -1,4 +1,4 @@ -import { useRef } from "react"; +import { useRef, useEffect } from "react"; const useFocus = () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -14,4 +14,19 @@ const useFocus = () => { return [htmlElRef, setFocus] as const; }; +// focuses on the element only once on mount +export const useFocusOnce = () => { + const [htmlElRef, setFocus] = useFocus(); + const focused = useRef(false); + + useEffect(() => { + if (!focused.current) { + setFocus(); + focused.current = true; + } + }, [setFocus]); + + return [htmlElRef, setFocus] as const; +}; + export default useFocus; From 124adb3f5bf0b4f41f8bdcd2f09e3d9c04f2ab7b Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Tue, 23 May 2023 03:40:27 +0200 Subject: [PATCH 030/135] Fix bulk performer update plugin hook (#3754) --- internal/api/resolver_mutation_performer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/resolver_mutation_performer.go b/internal/api/resolver_mutation_performer.go index 88aab07d094..5b9304ba316 100644 --- a/internal/api/resolver_mutation_performer.go +++ b/internal/api/resolver_mutation_performer.go @@ -418,7 +418,7 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe // execute post hooks outside of txn var newRet []*models.Performer for _, performer := range ret { - r.hookExecutor.ExecutePostHooks(ctx, performer.ID, plugin.ImageUpdatePost, input, translator.getFields()) + r.hookExecutor.ExecutePostHooks(ctx, performer.ID, plugin.PerformerUpdatePost, input, translator.getFields()) performer, err = r.getPerformer(ctx, performer.ID) if err != nil { From 58a6c2207240f1479568cea0b95d0e72d4730bb2 Mon Sep 17 00:00:00 2001 From: CJ <72030708+Teda1@users.noreply.github.com> Date: Tue, 23 May 2023 00:07:06 -0500 Subject: [PATCH 031/135] honor dlna sort order to content exceeding the first page (#3747) --- internal/dlna/cds.go | 18 +++++++++++++----- internal/dlna/paging.go | 10 +++++----- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/internal/dlna/cds.go b/internal/dlna/cds.go index cf5deaa7c1e..22cc17718d2 100644 --- a/internal/dlna/cds.go +++ b/internal/dlna/cds.go @@ -440,15 +440,21 @@ func getRootObjects() []interface{} { return objs } +func getSortDirection(sceneFilter *models.SceneFilterType, sort string) models.SortDirectionEnum { + direction := models.SortDirectionEnumDesc + if sort == "title" { + direction = models.SortDirectionEnumAsc + } + + return direction +} + func (me *contentDirectoryService) getVideos(sceneFilter *models.SceneFilterType, parentID string, host string) []interface{} { var objs []interface{} if err := txn.WithReadTxn(context.TODO(), me.txnManager, func(ctx context.Context) error { sort := me.VideoSortOrder - direction := models.SortDirectionEnumDesc - if sort == "title" { - direction = models.SortDirectionEnumAsc - } + direction := getSortDirection(sceneFilter, sort) findFilter := &models.FindFilterType{ PerPage: &pageSize, Sort: &sort, @@ -497,8 +503,10 @@ func (me *contentDirectoryService) getPageVideos(sceneFilter *models.SceneFilter parentID: parentID, } + sort := me.VideoSortOrder + direction := getSortDirection(sceneFilter, sort) var err error - objs, err = pager.getPageVideos(ctx, me.repository.SceneFinder, me.repository.FileFinder, page, host) + objs, err = pager.getPageVideos(ctx, me.repository.SceneFinder, me.repository.FileFinder, page, host, sort, direction) if err != nil { return err } diff --git a/internal/dlna/paging.go b/internal/dlna/paging.go index d5643da885f..bd1b0028375 100644 --- a/internal/dlna/paging.go +++ b/internal/dlna/paging.go @@ -60,14 +60,14 @@ func (p *scenePager) getPages(ctx context.Context, r scene.Queryer, total int) ( return objs, nil } -func (p *scenePager) getPageVideos(ctx context.Context, r SceneFinder, f file.Finder, page int, host string) ([]interface{}, error) { +func (p *scenePager) getPageVideos(ctx context.Context, r SceneFinder, f file.Finder, page int, host string, sort string, direction models.SortDirectionEnum) ([]interface{}, error) { var objs []interface{} - sort := "title" findFilter := &models.FindFilterType{ - PerPage: &pageSize, - Page: &page, - Sort: &sort, + PerPage: &pageSize, + Page: &page, + Sort: &sort, + Direction: &direction, } scenes, err := scene.Query(ctx, r, p.sceneFilter, findFilter) From 776c7e6c35ec96f47ab24ba5f5019f0d3abf8f1b Mon Sep 17 00:00:00 2001 From: departure18 <92104199+departure18@users.noreply.github.com> Date: Wed, 24 May 2023 04:19:35 +0100 Subject: [PATCH 032/135] Add penis length and circumcision stats to performers. (#3627) * Add penis length stat to performers. * Modified the UI to display and edit the stat. * Added the ability to filter floats to allow filtering by penis length. * Add circumcision stat to performer. * Refactor enum filtering * Change boolean filter to radio buttons * Return null for empty enum values --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/documents/data/performer-slim.graphql | 2 + graphql/documents/data/performer.graphql | 2 + graphql/documents/data/scrapers.graphql | 4 + graphql/schema/types/filters.graphql | 15 ++ graphql/schema/types/performer.graphql | 13 ++ .../schema/types/scraped-performer.graphql | 4 + internal/api/images.go | 9 +- internal/api/resolver_mutation_performer.go | 28 +++- internal/identify/performer.go | 13 +- internal/identify/performer_test.go | 6 +- internal/manager/task_stash_box_tag.go | 6 +- pkg/models/filter.go | 14 ++ pkg/models/jsonschema/performer.go | 2 + pkg/models/model_performer.go | 44 ++--- pkg/models/model_scraped_item.go | 2 + pkg/models/performer.go | 50 ++++++ pkg/performer/export.go | 13 +- pkg/performer/export_test.go | 20 ++- pkg/performer/import.go | 15 +- pkg/scraper/autotag.go | 2 +- pkg/scraper/performer.go | 2 + pkg/scraper/stash.go | 2 + pkg/scraper/stashbox/stash_box.go | 2 +- pkg/sqlite/database.go | 2 +- pkg/sqlite/filter.go | 32 ++++ pkg/sqlite/migrations/46_penis_stats.up.sql | 2 + pkg/sqlite/performer.go | 31 +++- pkg/sqlite/performer_test.go | 150 ++++++++++++++++-- pkg/sqlite/record.go | 11 +- pkg/sqlite/setup_test.go | 25 +++ pkg/sqlite/sql.go | 52 ++++-- pkg/sqlite/values.go | 9 ++ pkg/utils/strings.go | 10 ++ .../src/components/List/CriterionEditor.tsx | 23 ++- .../components/List/Filters/BooleanFilter.tsx | 4 +- .../components/List/Filters/OptionFilter.tsx | 85 ++++++++++ .../components/List/Filters/OptionsFilter.tsx | 45 ------ .../List/Filters/OptionsListFilter.tsx | 45 ------ .../Performers/EditPerformersDialog.tsx | 54 +++++++ .../PerformerDetailsPanel.tsx | 56 ++++++- .../PerformerDetails/PerformerEditPanel.tsx | 64 ++++++++ .../PerformerScrapeDialog.tsx | 101 ++++++++++++ ui/v2.5/src/components/Performers/styles.scss | 28 ++-- ui/v2.5/src/core/StashService.ts | 5 + ui/v2.5/src/locales/en-GB.json | 8 + .../list-filter/criteria/circumcised.ts | 36 +++++ .../models/list-filter/criteria/criterion.ts | 19 +++ .../models/list-filter/criteria/factory.ts | 5 + ui/v2.5/src/models/list-filter/performers.ts | 4 + ui/v2.5/src/models/list-filter/types.ts | 2 + ui/v2.5/src/utils/circumcised.ts | 51 ++++++ ui/v2.5/src/utils/units.ts | 6 + 52 files changed, 1051 insertions(+), 184 deletions(-) create mode 100644 pkg/sqlite/migrations/46_penis_stats.up.sql create mode 100644 ui/v2.5/src/components/List/Filters/OptionFilter.tsx delete mode 100644 ui/v2.5/src/components/List/Filters/OptionsFilter.tsx delete mode 100644 ui/v2.5/src/components/List/Filters/OptionsListFilter.tsx create mode 100644 ui/v2.5/src/models/list-filter/criteria/circumcised.ts create mode 100644 ui/v2.5/src/utils/circumcised.ts diff --git a/graphql/documents/data/performer-slim.graphql b/graphql/documents/data/performer-slim.graphql index 4bac5d90b59..65019b98b52 100644 --- a/graphql/documents/data/performer-slim.graphql +++ b/graphql/documents/data/performer-slim.graphql @@ -16,6 +16,8 @@ fragment SlimPerformerData on Performer { eye_color height_cm fake_tits + penis_length + circumcised career_length tattoos piercings diff --git a/graphql/documents/data/performer.graphql b/graphql/documents/data/performer.graphql index ed469f01ebd..c89ce1e13ed 100644 --- a/graphql/documents/data/performer.graphql +++ b/graphql/documents/data/performer.graphql @@ -14,6 +14,8 @@ fragment PerformerData on Performer { height_cm measurements fake_tits + penis_length + circumcised career_length tattoos piercings diff --git a/graphql/documents/data/scrapers.graphql b/graphql/documents/data/scrapers.graphql index 8d02b3362aa..1d4553a97c2 100644 --- a/graphql/documents/data/scrapers.graphql +++ b/graphql/documents/data/scrapers.graphql @@ -13,6 +13,8 @@ fragment ScrapedPerformerData on ScrapedPerformer { height measurements fake_tits + penis_length + circumcised career_length tattoos piercings @@ -43,6 +45,8 @@ fragment ScrapedScenePerformerData on ScrapedPerformer { height measurements fake_tits + penis_length + circumcised career_length tattoos piercings diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index a635eaf5168..0b18cbfee64 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -76,6 +76,10 @@ input PerformerFilterType { measurements: StringCriterionInput """Filter by fake tits value""" fake_tits: StringCriterionInput + """Filter by penis length value""" + penis_length: FloatCriterionInput + """Filter by ciricumcision""" + circumcised: CircumcisionCriterionInput """Filter by career length""" career_length: StringCriterionInput """Filter by tattoos""" @@ -505,6 +509,12 @@ input IntCriterionInput { modifier: CriterionModifier! } +input FloatCriterionInput { + value: Float! + value2: Float + modifier: CriterionModifier! +} + input MultiCriterionInput { value: [ID!] modifier: CriterionModifier! @@ -514,6 +524,11 @@ input GenderCriterionInput { value: GenderEnum modifier: CriterionModifier! } + +input CircumcisionCriterionInput { + value: [CircumisedEnum!] + modifier: CriterionModifier! +} input HierarchicalMultiCriterionInput { value: [ID!] diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index 401f3b7c608..6cbe6ed323f 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -6,6 +6,11 @@ enum GenderEnum { INTERSEX NON_BINARY } + +enum CircumisedEnum { + CUT + UNCUT +} type Performer { id: ID! @@ -24,6 +29,8 @@ type Performer { height_cm: Int measurements: String fake_tits: String + penis_length: Float + circumcised: CircumisedEnum career_length: String tattoos: String piercings: String @@ -69,6 +76,8 @@ input PerformerCreateInput { height_cm: Int measurements: String fake_tits: String + penis_length: Float + circumcised: CircumisedEnum career_length: String tattoos: String piercings: String @@ -107,6 +116,8 @@ input PerformerUpdateInput { height_cm: Int measurements: String fake_tits: String + penis_length: Float + circumcised: CircumisedEnum career_length: String tattoos: String piercings: String @@ -150,6 +161,8 @@ input BulkPerformerUpdateInput { height_cm: Int measurements: String fake_tits: String + penis_length: Float + circumcised: CircumisedEnum career_length: String tattoos: String piercings: String diff --git a/graphql/schema/types/scraped-performer.graphql b/graphql/schema/types/scraped-performer.graphql index 518e5abca41..a23b04fed9e 100644 --- a/graphql/schema/types/scraped-performer.graphql +++ b/graphql/schema/types/scraped-performer.graphql @@ -15,6 +15,8 @@ type ScrapedPerformer { height: String measurements: String fake_tits: String + penis_length: String + circumcised: String career_length: String tattoos: String piercings: String @@ -48,6 +50,8 @@ input ScrapedPerformerInput { height: String measurements: String fake_tits: String + penis_length: String + circumcised: String career_length: String tattoos: String piercings: String diff --git a/internal/api/images.go b/internal/api/images.go index ddcaee62971..7ddbbfc1051 100644 --- a/internal/api/images.go +++ b/internal/api/images.go @@ -87,7 +87,7 @@ func initialiseCustomImages() { } } -func getRandomPerformerImageUsingName(name string, gender models.GenderEnum, customPath string) ([]byte, error) { +func getRandomPerformerImageUsingName(name string, gender *models.GenderEnum, customPath string) ([]byte, error) { var box *imageBox // If we have a custom path, we should return a new box in the given path. @@ -95,8 +95,13 @@ func getRandomPerformerImageUsingName(name string, gender models.GenderEnum, cus box = performerBoxCustom } + var g models.GenderEnum + if gender != nil { + g = *gender + } + if box == nil { - switch gender { + switch g { case models.GenderEnumFemale: box = performerBox case models.GenderEnumMale: diff --git a/internal/api/resolver_mutation_performer.go b/internal/api/resolver_mutation_performer.go index 5b9304ba316..2f3e9e01be1 100644 --- a/internal/api/resolver_mutation_performer.go +++ b/internal/api/resolver_mutation_performer.go @@ -67,7 +67,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input PerformerC newPerformer.URL = *input.URL } if input.Gender != nil { - newPerformer.Gender = *input.Gender + newPerformer.Gender = input.Gender } if input.Birthdate != nil { d := models.NewDate(*input.Birthdate) @@ -98,6 +98,12 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input PerformerC if input.FakeTits != nil { newPerformer.FakeTits = *input.FakeTits } + if input.PenisLength != nil { + newPerformer.PenisLength = input.PenisLength + } + if input.Circumcised != nil { + newPerformer.Circumcised = input.Circumcised + } if input.CareerLength != nil { newPerformer.CareerLength = *input.CareerLength } @@ -222,6 +228,16 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input PerformerU updatedPerformer.Ethnicity = translator.optionalString(input.Ethnicity, "ethnicity") updatedPerformer.FakeTits = translator.optionalString(input.FakeTits, "fake_tits") + updatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, "penis_length") + + if translator.hasField("circumcised") { + if input.Circumcised != nil { + updatedPerformer.Circumcised = models.NewOptionalString(input.Circumcised.String()) + } else { + updatedPerformer.Circumcised = models.NewOptionalStringPtr(nil) + } + } + updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length") updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings") @@ -339,6 +355,16 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe updatedPerformer.Measurements = translator.optionalString(input.Measurements, "measurements") updatedPerformer.FakeTits = translator.optionalString(input.FakeTits, "fake_tits") + updatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, "penis_length") + + if translator.hasField("circumcised") { + if input.Circumcised != nil { + updatedPerformer.Circumcised = models.NewOptionalString(input.Circumcised.String()) + } else { + updatedPerformer.Circumcised = models.NewOptionalStringPtr(nil) + } + } + updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length") updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings") diff --git a/internal/identify/performer.go b/internal/identify/performer.go index a78a0ce6c79..cb16f2a83d2 100644 --- a/internal/identify/performer.go +++ b/internal/identify/performer.go @@ -65,7 +65,8 @@ func scrapedToPerformerInput(performer *models.ScrapedPerformer) models.Performe ret.DeathDate = &d } if performer.Gender != nil { - ret.Gender = models.GenderEnum(*performer.Gender) + v := models.GenderEnum(*performer.Gender) + ret.Gender = &v } if performer.Ethnicity != nil { ret.Ethnicity = *performer.Ethnicity @@ -97,6 +98,16 @@ func scrapedToPerformerInput(performer *models.ScrapedPerformer) models.Performe if performer.FakeTits != nil { ret.FakeTits = *performer.FakeTits } + if performer.PenisLength != nil { + h, err := strconv.ParseFloat(*performer.PenisLength, 64) + if err == nil { + ret.PenisLength = &h + } + } + if performer.Circumcised != nil { + v := models.CircumisedEnum(*performer.Circumcised) + ret.Circumcised = &v + } if performer.CareerLength != nil { ret.CareerLength = *performer.CareerLength } diff --git a/internal/identify/performer_test.go b/internal/identify/performer_test.go index 0a78ea17358..9ba1018c783 100644 --- a/internal/identify/performer_test.go +++ b/internal/identify/performer_test.go @@ -228,6 +228,10 @@ func Test_scrapedToPerformerInput(t *testing.T) { return &d } + genderPtr := func(g models.GenderEnum) *models.GenderEnum { + return &g + } + tests := []struct { name string performer *models.ScrapedPerformer @@ -259,7 +263,7 @@ func Test_scrapedToPerformerInput(t *testing.T) { Name: name, Birthdate: dateToDatePtr(models.NewDate(*nextVal())), DeathDate: dateToDatePtr(models.NewDate(*nextVal())), - Gender: models.GenderEnum(*nextVal()), + Gender: genderPtr(models.GenderEnum(*nextVal())), Ethnicity: *nextVal(), Country: *nextVal(), EyeColor: *nextVal(), diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index e927a033518..dd31b4899ad 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -131,7 +131,6 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) { EyeColor: getString(performer.EyeColor), HairColor: getString(performer.HairColor), FakeTits: getString(performer.FakeTits), - Gender: models.GenderEnum(getString(performer.Gender)), Height: getIntPtr(performer.Height), Weight: getIntPtr(performer.Weight), Instagram: getString(performer.Instagram), @@ -150,6 +149,11 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) { UpdatedAt: currentTime, } + if performer.Gender != nil { + v := models.GenderEnum(getString(performer.Gender)) + newPerformer.Gender = &v + } + err := txn.WithTxn(ctx, instance.Repository, func(ctx context.Context) error { r := instance.Repository err := r.Performer.Create(ctx, &newPerformer) diff --git a/pkg/models/filter.go b/pkg/models/filter.go index 47e93f237d2..42cff1118d3 100644 --- a/pkg/models/filter.go +++ b/pkg/models/filter.go @@ -109,6 +109,20 @@ func (i IntCriterionInput) ValidModifier() bool { return false } +type FloatCriterionInput struct { + Value float64 `json:"value"` + Value2 *float64 `json:"value2"` + Modifier CriterionModifier `json:"modifier"` +} + +func (i FloatCriterionInput) ValidModifier() bool { + switch i.Modifier { + case CriterionModifierEquals, CriterionModifierNotEquals, CriterionModifierGreaterThan, CriterionModifierLessThan, CriterionModifierIsNull, CriterionModifierNotNull, CriterionModifierBetween, CriterionModifierNotBetween: + return true + } + return false +} + type ResolutionCriterionInput struct { Value ResolutionEnum `json:"value"` Modifier CriterionModifier `json:"modifier"` diff --git a/pkg/models/jsonschema/performer.go b/pkg/models/jsonschema/performer.go index c0996a1a580..248cf955736 100644 --- a/pkg/models/jsonschema/performer.go +++ b/pkg/models/jsonschema/performer.go @@ -48,6 +48,8 @@ type Performer struct { Height string `json:"height,omitempty"` Measurements string `json:"measurements,omitempty"` FakeTits string `json:"fake_tits,omitempty"` + PenisLength float64 `json:"penis_length,omitempty"` + Circumcised string `json:"circumcised,omitempty"` CareerLength string `json:"career_length,omitempty"` Tattoos string `json:"tattoos,omitempty"` Piercings string `json:"piercings,omitempty"` diff --git a/pkg/models/model_performer.go b/pkg/models/model_performer.go index fd52a767454..134d46783cd 100644 --- a/pkg/models/model_performer.go +++ b/pkg/models/model_performer.go @@ -6,26 +6,28 @@ import ( ) type Performer struct { - ID int `json:"id"` - Name string `json:"name"` - Disambiguation string `json:"disambiguation"` - Gender GenderEnum `json:"gender"` - URL string `json:"url"` - Twitter string `json:"twitter"` - Instagram string `json:"instagram"` - Birthdate *Date `json:"birthdate"` - Ethnicity string `json:"ethnicity"` - Country string `json:"country"` - EyeColor string `json:"eye_color"` - Height *int `json:"height"` - Measurements string `json:"measurements"` - FakeTits string `json:"fake_tits"` - CareerLength string `json:"career_length"` - Tattoos string `json:"tattoos"` - Piercings string `json:"piercings"` - Favorite bool `json:"favorite"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int `json:"id"` + Name string `json:"name"` + Disambiguation string `json:"disambiguation"` + Gender *GenderEnum `json:"gender"` + URL string `json:"url"` + Twitter string `json:"twitter"` + Instagram string `json:"instagram"` + Birthdate *Date `json:"birthdate"` + Ethnicity string `json:"ethnicity"` + Country string `json:"country"` + EyeColor string `json:"eye_color"` + Height *int `json:"height"` + Measurements string `json:"measurements"` + FakeTits string `json:"fake_tits"` + PenisLength *float64 `json:"penis_length"` + Circumcised *CircumisedEnum `json:"circumcised"` + CareerLength string `json:"career_length"` + Tattoos string `json:"tattoos"` + Piercings string `json:"piercings"` + Favorite bool `json:"favorite"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // Rating expressed in 1-100 scale Rating *int `json:"rating"` Details string `json:"details"` @@ -90,6 +92,8 @@ type PerformerPartial struct { Height OptionalInt Measurements OptionalString FakeTits OptionalString + PenisLength OptionalFloat64 + Circumcised OptionalString CareerLength OptionalString Tattoos OptionalString Piercings OptionalString diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index fa25bcb7eb2..9d497b0433f 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -32,6 +32,8 @@ type ScrapedPerformer struct { Height *string `json:"height"` Measurements *string `json:"measurements"` FakeTits *string `json:"fake_tits"` + PenisLength *string `json:"penis_length"` + Circumcised *string `json:"circumcised"` CareerLength *string `json:"career_length"` Tattoos *string `json:"tattoos"` Piercings *string `json:"piercings"` diff --git a/pkg/models/performer.go b/pkg/models/performer.go index aa6ea3af660..23b70b0dade 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -61,6 +61,52 @@ type GenderCriterionInput struct { Modifier CriterionModifier `json:"modifier"` } +type CircumisedEnum string + +const ( + CircumisedEnumCut CircumisedEnum = "CUT" + CircumisedEnumUncut CircumisedEnum = "UNCUT" +) + +var AllCircumcisionEnum = []CircumisedEnum{ + CircumisedEnumCut, + CircumisedEnumUncut, +} + +func (e CircumisedEnum) IsValid() bool { + switch e { + case CircumisedEnumCut, CircumisedEnumUncut: + return true + } + return false +} + +func (e CircumisedEnum) String() string { + return string(e) +} + +func (e *CircumisedEnum) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = CircumisedEnum(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid CircumisedEnum", str) + } + return nil +} + +func (e CircumisedEnum) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +type CircumcisionCriterionInput struct { + Value []CircumisedEnum `json:"value"` + Modifier CriterionModifier `json:"modifier"` +} + type PerformerFilterType struct { And *PerformerFilterType `json:"AND"` Or *PerformerFilterType `json:"OR"` @@ -88,6 +134,10 @@ type PerformerFilterType struct { Measurements *StringCriterionInput `json:"measurements"` // Filter by fake tits value FakeTits *StringCriterionInput `json:"fake_tits"` + // Filter by penis length value + PenisLength *FloatCriterionInput `json:"penis_length"` + // Filter by circumcision + Circumcised *CircumcisionCriterionInput `json:"circumcised"` // Filter by career length CareerLength *StringCriterionInput `json:"career_length"` // Filter by tattoos diff --git a/pkg/performer/export.go b/pkg/performer/export.go index 4b46fd901cd..9aec8b34e56 100644 --- a/pkg/performer/export.go +++ b/pkg/performer/export.go @@ -23,7 +23,6 @@ func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *mode newPerformerJSON := jsonschema.Performer{ Name: performer.Name, Disambiguation: performer.Disambiguation, - Gender: performer.Gender.String(), URL: performer.URL, Ethnicity: performer.Ethnicity, Country: performer.Country, @@ -43,6 +42,14 @@ func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *mode UpdatedAt: json.JSONTime{Time: performer.UpdatedAt}, } + if performer.Gender != nil { + newPerformerJSON.Gender = performer.Gender.String() + } + + if performer.Circumcised != nil { + newPerformerJSON.Circumcised = performer.Circumcised.String() + } + if performer.Birthdate != nil { newPerformerJSON.Birthdate = performer.Birthdate.String() } @@ -61,6 +68,10 @@ func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *mode newPerformerJSON.Weight = *performer.Weight } + if performer.PenisLength != nil { + newPerformerJSON.PenisLength = *performer.PenisLength + } + if err := performer.LoadAliases(ctx, reader); err != nil { return nil, fmt.Errorf("loading performer aliases: %w", err) } diff --git a/pkg/performer/export_test.go b/pkg/performer/export_test.go index f65693e3fff..c5965404a4f 100644 --- a/pkg/performer/export_test.go +++ b/pkg/performer/export_test.go @@ -29,7 +29,6 @@ const ( ethnicity = "ethnicity" eyeColor = "eyeColor" fakeTits = "fakeTits" - gender = "gender" instagram = "instagram" measurements = "measurements" piercings = "piercings" @@ -42,10 +41,15 @@ const ( ) var ( - aliases = []string{"alias1", "alias2"} - rating = 5 - height = 123 - weight = 60 + genderEnum = models.GenderEnumFemale + gender = genderEnum.String() + aliases = []string{"alias1", "alias2"} + rating = 5 + height = 123 + weight = 60 + penisLength = 1.23 + circumcisedEnum = models.CircumisedEnumCut + circumcised = circumcisedEnum.String() ) var imageBytes = []byte("imageBytes") @@ -81,8 +85,10 @@ func createFullPerformer(id int, name string) *models.Performer { Ethnicity: ethnicity, EyeColor: eyeColor, FakeTits: fakeTits, + PenisLength: &penisLength, + Circumcised: &circumcisedEnum, Favorite: true, - Gender: gender, + Gender: &genderEnum, Height: &height, Instagram: instagram, Measurements: measurements, @@ -125,6 +131,8 @@ func createFullJSONPerformer(name string, image string) *jsonschema.Performer { Ethnicity: ethnicity, EyeColor: eyeColor, FakeTits: fakeTits, + PenisLength: penisLength, + Circumcised: circumcised, Favorite: true, Gender: gender, Height: strconv.Itoa(height), diff --git a/pkg/performer/import.go b/pkg/performer/import.go index beebab35d52..4ca27ce55eb 100644 --- a/pkg/performer/import.go +++ b/pkg/performer/import.go @@ -189,7 +189,6 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform newPerformer := models.Performer{ Name: performerJSON.Name, Disambiguation: performerJSON.Disambiguation, - Gender: models.GenderEnum(performerJSON.Gender), URL: performerJSON.URL, Ethnicity: performerJSON.Ethnicity, Country: performerJSON.Country, @@ -213,6 +212,16 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform StashIDs: models.NewRelatedStashIDs(performerJSON.StashIDs), } + if performerJSON.Gender != "" { + v := models.GenderEnum(performerJSON.Gender) + newPerformer.Gender = &v + } + + if performerJSON.Circumcised != "" { + v := models.CircumisedEnum(performerJSON.Circumcised) + newPerformer.Circumcised = &v + } + if performerJSON.Birthdate != "" { d, err := utils.ParseDateStringAsTime(performerJSON.Birthdate) if err == nil { @@ -237,6 +246,10 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform newPerformer.Weight = &performerJSON.Weight } + if performerJSON.PenisLength != 0 { + newPerformer.PenisLength = &performerJSON.PenisLength + } + if performerJSON.Height != "" { h, err := strconv.Atoi(performerJSON.Height) if err == nil { diff --git a/pkg/scraper/autotag.go b/pkg/scraper/autotag.go index 53aedc749c8..786cd024d7d 100644 --- a/pkg/scraper/autotag.go +++ b/pkg/scraper/autotag.go @@ -41,7 +41,7 @@ func autotagMatchPerformers(ctx context.Context, path string, performerReader ma Name: &pp.Name, StoredID: &id, } - if pp.Gender.IsValid() { + if pp.Gender != nil && pp.Gender.IsValid() { v := pp.Gender.String() sp.Gender = &v } diff --git a/pkg/scraper/performer.go b/pkg/scraper/performer.go index 48f6ce3186d..26936882366 100644 --- a/pkg/scraper/performer.go +++ b/pkg/scraper/performer.go @@ -16,6 +16,8 @@ type ScrapedPerformerInput struct { Height *string `json:"height"` Measurements *string `json:"measurements"` FakeTits *string `json:"fake_tits"` + PenisLength *string `json:"penis_length"` + Circumcised *string `json:"circumcised"` CareerLength *string `json:"career_length"` Tattoos *string `json:"tattoos"` Piercings *string `json:"piercings"` diff --git a/pkg/scraper/stash.go b/pkg/scraper/stash.go index 9267bad0c0f..652a9de0ac4 100644 --- a/pkg/scraper/stash.go +++ b/pkg/scraper/stash.go @@ -69,6 +69,8 @@ type scrapedPerformerStash struct { Height *string `graphql:"height" json:"height"` Measurements *string `graphql:"measurements" json:"measurements"` FakeTits *string `graphql:"fake_tits" json:"fake_tits"` + PenisLength *string `graphql:"penis_length" json:"penis_length"` + Circumcised *string `graphql:"circumcised" json:"circumcised"` CareerLength *string `graphql:"career_length" json:"career_length"` Tattoos *string `graphql:"tattoos" json:"tattoos"` Piercings *string `graphql:"piercings" json:"piercings"` diff --git a/pkg/scraper/stashbox/stash_box.go b/pkg/scraper/stashbox/stash_box.go index b8eadfd1b1b..713265e7ca1 100644 --- a/pkg/scraper/stashbox/stash_box.go +++ b/pkg/scraper/stashbox/stash_box.go @@ -1009,7 +1009,7 @@ func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Perf if performer.FakeTits != "" { draft.BreastType = &performer.FakeTits } - if performer.Gender.IsValid() { + if performer.Gender != nil && performer.Gender.IsValid() { v := performer.Gender.String() draft.Gender = &v } diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index d8e8b5e0dab..c18b323ee75 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -32,7 +32,7 @@ const ( dbConnTimeout = 30 ) -var appSchemaVersion uint = 45 +var appSchemaVersion uint = 46 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index 057fec179a1..d0c74772df5 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -426,6 +426,29 @@ func stringCriterionHandler(c *models.StringCriterionInput, column string) crite } } +func enumCriterionHandler(modifier models.CriterionModifier, values []string, column string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if modifier.IsValid() { + switch modifier { + case models.CriterionModifierIncludes, models.CriterionModifierEquals: + if len(values) > 0 { + f.whereClauses = append(f.whereClauses, getEnumSearchClause(column, values, false)) + } + case models.CriterionModifierExcludes, models.CriterionModifierNotEquals: + if len(values) > 0 { + f.whereClauses = append(f.whereClauses, getEnumSearchClause(column, values, true)) + } + case models.CriterionModifierIsNull: + f.addWhere("(" + column + " IS NULL OR TRIM(" + column + ") = '')") + case models.CriterionModifierNotNull: + f.addWhere("(" + column + " IS NOT NULL AND TRIM(" + column + ") != '')") + default: + panic("unsupported string filter modifier") + } + } + } +} + func pathCriterionHandler(c *models.StringCriterionInput, pathColumn string, basenameColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if c != nil { @@ -525,6 +548,15 @@ func intCriterionHandler(c *models.IntCriterionInput, column string, addJoinFn f } } +func floatCriterionHandler(c *models.FloatCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + clause, args := getFloatCriterionWhereClause(column, *c) + f.addWhere(clause, args...) + } + } +} + func boolCriterionHandler(c *bool, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if c != nil { diff --git a/pkg/sqlite/migrations/46_penis_stats.up.sql b/pkg/sqlite/migrations/46_penis_stats.up.sql new file mode 100644 index 00000000000..2e9e3165406 --- /dev/null +++ b/pkg/sqlite/migrations/46_penis_stats.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE `performers` ADD COLUMN `penis_length` float; +ALTER TABLE `performers` ADD COLUMN `circumcised` varchar[10]; \ No newline at end of file diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index a197b2ce58c..7468db8be03 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -42,6 +42,8 @@ type performerRow struct { Height null.Int `db:"height"` Measurements zero.String `db:"measurements"` FakeTits zero.String `db:"fake_tits"` + PenisLength null.Float `db:"penis_length"` + Circumcised zero.String `db:"circumcised"` CareerLength zero.String `db:"career_length"` Tattoos zero.String `db:"tattoos"` Piercings zero.String `db:"piercings"` @@ -64,7 +66,7 @@ func (r *performerRow) fromPerformer(o models.Performer) { r.ID = o.ID r.Name = o.Name r.Disambigation = zero.StringFrom(o.Disambiguation) - if o.Gender.IsValid() { + if o.Gender != nil && o.Gender.IsValid() { r.Gender = zero.StringFrom(o.Gender.String()) } r.URL = zero.StringFrom(o.URL) @@ -79,6 +81,10 @@ func (r *performerRow) fromPerformer(o models.Performer) { r.Height = intFromPtr(o.Height) r.Measurements = zero.StringFrom(o.Measurements) r.FakeTits = zero.StringFrom(o.FakeTits) + r.PenisLength = null.FloatFromPtr(o.PenisLength) + if o.Circumcised != nil && o.Circumcised.IsValid() { + r.Circumcised = zero.StringFrom(o.Circumcised.String()) + } r.CareerLength = zero.StringFrom(o.CareerLength) r.Tattoos = zero.StringFrom(o.Tattoos) r.Piercings = zero.StringFrom(o.Piercings) @@ -100,7 +106,6 @@ func (r *performerRow) resolve() *models.Performer { ID: r.ID, Name: r.Name, Disambiguation: r.Disambigation.String, - Gender: models.GenderEnum(r.Gender.String), URL: r.URL.String, Twitter: r.Twitter.String, Instagram: r.Instagram.String, @@ -111,6 +116,7 @@ func (r *performerRow) resolve() *models.Performer { Height: nullIntPtr(r.Height), Measurements: r.Measurements.String, FakeTits: r.FakeTits.String, + PenisLength: nullFloatPtr(r.PenisLength), CareerLength: r.CareerLength.String, Tattoos: r.Tattoos.String, Piercings: r.Piercings.String, @@ -126,6 +132,16 @@ func (r *performerRow) resolve() *models.Performer { IgnoreAutoTag: r.IgnoreAutoTag, } + if r.Gender.ValueOrZero() != "" { + v := models.GenderEnum(r.Gender.String) + ret.Gender = &v + } + + if r.Circumcised.ValueOrZero() != "" { + v := models.CircumisedEnum(r.Circumcised.String) + ret.Circumcised = &v + } + return ret } @@ -147,6 +163,8 @@ func (r *performerRowRecord) fromPartial(o models.PerformerPartial) { r.setNullInt("height", o.Height) r.setNullString("measurements", o.Measurements) r.setNullString("fake_tits", o.FakeTits) + r.setNullFloat64("penis_length", o.PenisLength) + r.setNullString("circumcised", o.Circumcised) r.setNullString("career_length", o.CareerLength) r.setNullString("tattoos", o.Tattoos) r.setNullString("piercings", o.Piercings) @@ -597,6 +615,15 @@ func (qb *PerformerStore) makeFilter(ctx context.Context, filter *models.Perform query.handleCriterion(ctx, stringCriterionHandler(filter.Measurements, tableName+".measurements")) query.handleCriterion(ctx, stringCriterionHandler(filter.FakeTits, tableName+".fake_tits")) + query.handleCriterion(ctx, floatCriterionHandler(filter.PenisLength, tableName+".penis_length", nil)) + + query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if circumcised := filter.Circumcised; circumcised != nil { + v := utils.StringerSliceToStringSlice(circumcised.Value) + enumCriterionHandler(circumcised.Modifier, v, tableName+".circumcised")(ctx, f) + } + })) + query.handleCriterion(ctx, stringCriterionHandler(filter.CareerLength, tableName+".career_length")) query.handleCriterion(ctx, stringCriterionHandler(filter.Tattoos, tableName+".tattoos")) query.handleCriterion(ctx, stringCriterionHandler(filter.Piercings, tableName+".piercings")) diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index 2b24d645586..a874f3967e3 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -52,6 +52,8 @@ func Test_PerformerStore_Create(t *testing.T) { height = 134 measurements = "measurements" fakeTits = "fakeTits" + penisLength = 1.23 + circumcised = models.CircumisedEnumCut careerLength = "careerLength" tattoos = "tattoos" piercings = "piercings" @@ -81,7 +83,7 @@ func Test_PerformerStore_Create(t *testing.T) { models.Performer{ Name: name, Disambiguation: disambiguation, - Gender: gender, + Gender: &gender, URL: url, Twitter: twitter, Instagram: instagram, @@ -92,6 +94,8 @@ func Test_PerformerStore_Create(t *testing.T) { Height: &height, Measurements: measurements, FakeTits: fakeTits, + PenisLength: &penisLength, + Circumcised: &circumcised, CareerLength: careerLength, Tattoos: tattoos, Piercings: piercings, @@ -196,6 +200,8 @@ func Test_PerformerStore_Update(t *testing.T) { height = 134 measurements = "measurements" fakeTits = "fakeTits" + penisLength = 1.23 + circumcised = models.CircumisedEnumCut careerLength = "careerLength" tattoos = "tattoos" piercings = "piercings" @@ -226,7 +232,7 @@ func Test_PerformerStore_Update(t *testing.T) { ID: performerIDs[performerIdxWithGallery], Name: name, Disambiguation: disambiguation, - Gender: gender, + Gender: &gender, URL: url, Twitter: twitter, Instagram: instagram, @@ -237,6 +243,8 @@ func Test_PerformerStore_Update(t *testing.T) { Height: &height, Measurements: measurements, FakeTits: fakeTits, + PenisLength: &penisLength, + Circumcised: &circumcised, CareerLength: careerLength, Tattoos: tattoos, Piercings: piercings, @@ -327,6 +335,7 @@ func clearPerformerPartial() models.PerformerPartial { nullString := models.OptionalString{Set: true, Null: true} nullDate := models.OptionalDate{Set: true, Null: true} nullInt := models.OptionalInt{Set: true, Null: true} + nullFloat := models.OptionalFloat64{Set: true, Null: true} // leave mandatory fields return models.PerformerPartial{ @@ -342,6 +351,8 @@ func clearPerformerPartial() models.PerformerPartial { Height: nullInt, Measurements: nullString, FakeTits: nullString, + PenisLength: nullFloat, + Circumcised: nullString, CareerLength: nullString, Tattoos: nullString, Piercings: nullString, @@ -372,6 +383,8 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { height = 143 measurements = "measurements" fakeTits = "fakeTits" + penisLength = 1.23 + circumcised = models.CircumisedEnumCut careerLength = "careerLength" tattoos = "tattoos" piercings = "piercings" @@ -415,6 +428,8 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { Height: models.NewOptionalInt(height), Measurements: models.NewOptionalString(measurements), FakeTits: models.NewOptionalString(fakeTits), + PenisLength: models.NewOptionalFloat64(penisLength), + Circumcised: models.NewOptionalString(circumcised.String()), CareerLength: models.NewOptionalString(careerLength), Tattoos: models.NewOptionalString(tattoos), Piercings: models.NewOptionalString(piercings), @@ -453,7 +468,7 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { ID: performerIDs[performerIdxWithDupName], Name: name, Disambiguation: disambiguation, - Gender: gender, + Gender: &gender, URL: url, Twitter: twitter, Instagram: instagram, @@ -464,6 +479,8 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { Height: &height, Measurements: measurements, FakeTits: fakeTits, + PenisLength: &penisLength, + Circumcised: &circumcised, CareerLength: careerLength, Tattoos: tattoos, Piercings: piercings, @@ -957,16 +974,30 @@ func TestPerformerQuery(t *testing.T) { false, }, { - "alias", + "circumcised (cut)", nil, &models.PerformerFilterType{ - Aliases: &models.StringCriterionInput{ - Value: getPerformerStringValue(performerIdxWithGallery, "alias"), - Modifier: models.CriterionModifierEquals, + Circumcised: &models.CircumcisionCriterionInput{ + Value: []models.CircumisedEnum{models.CircumisedEnumCut}, + Modifier: models.CriterionModifierIncludes, }, }, - []int{performerIdxWithGallery}, - []int{performerIdxWithScene}, + []int{performerIdx1WithScene}, + []int{performerIdxWithScene, performerIdx2WithScene}, + false, + }, + { + "circumcised (excludes cut)", + nil, + &models.PerformerFilterType{ + Circumcised: &models.CircumcisionCriterionInput{ + Value: []models.CircumisedEnum{models.CircumisedEnumCut}, + Modifier: models.CriterionModifierExcludes, + }, + }, + []int{performerIdx2WithScene}, + // performerIdxWithScene has null value + []int{performerIdx1WithScene, performerIdxWithScene}, false, }, } @@ -995,6 +1026,107 @@ func TestPerformerQuery(t *testing.T) { } } +func TestPerformerQueryPenisLength(t *testing.T) { + var upper = 4.0 + + tests := []struct { + name string + modifier models.CriterionModifier + value float64 + value2 *float64 + }{ + { + "equals", + models.CriterionModifierEquals, + 1, + nil, + }, + { + "not equals", + models.CriterionModifierNotEquals, + 1, + nil, + }, + { + "greater than", + models.CriterionModifierGreaterThan, + 1, + nil, + }, + { + "between", + models.CriterionModifierBetween, + 2, + &upper, + }, + { + "greater than", + models.CriterionModifierNotBetween, + 2, + &upper, + }, + { + "null", + models.CriterionModifierIsNull, + 0, + nil, + }, + { + "not null", + models.CriterionModifierNotNull, + 0, + nil, + }, + } + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + filter := &models.PerformerFilterType{ + PenisLength: &models.FloatCriterionInput{ + Modifier: tt.modifier, + Value: tt.value, + Value2: tt.value2, + }, + } + + performers, _, err := db.Performer.Query(ctx, filter, nil) + if err != nil { + t.Errorf("PerformerStore.Query() error = %v", err) + return + } + + for _, p := range performers { + verifyFloat(t, p.PenisLength, *filter.PenisLength) + } + }) + } +} + +func verifyFloat(t *testing.T, value *float64, criterion models.FloatCriterionInput) bool { + t.Helper() + assert := assert.New(t) + switch criterion.Modifier { + case models.CriterionModifierEquals: + return assert.NotNil(value) && assert.Equal(criterion.Value, *value) + case models.CriterionModifierNotEquals: + return assert.NotNil(value) && assert.NotEqual(criterion.Value, *value) + case models.CriterionModifierGreaterThan: + return assert.NotNil(value) && assert.Greater(*value, criterion.Value) + case models.CriterionModifierLessThan: + return assert.NotNil(value) && assert.Less(*value, criterion.Value) + case models.CriterionModifierBetween: + return assert.NotNil(value) && assert.GreaterOrEqual(*value, criterion.Value) && assert.LessOrEqual(*value, *criterion.Value2) + case models.CriterionModifierNotBetween: + return assert.NotNil(value) && assert.True(*value < criterion.Value || *value > *criterion.Value2) + case models.CriterionModifierIsNull: + return assert.Nil(value) + case models.CriterionModifierNotNull: + return assert.NotNil(value) + } + + return false +} + func TestPerformerQueryForAutoTag(t *testing.T) { withTxn(func(ctx context.Context) error { tqb := db.Performer diff --git a/pkg/sqlite/record.go b/pkg/sqlite/record.go index fbee73e8646..5f4d31b55dd 100644 --- a/pkg/sqlite/record.go +++ b/pkg/sqlite/record.go @@ -3,6 +3,7 @@ package sqlite import ( "github.com/doug-martin/goqu/v9/exp" "github.com/stashapp/stash/pkg/models" + "gopkg.in/guregu/null.v4" "gopkg.in/guregu/null.v4/zero" ) @@ -77,11 +78,11 @@ func (r *updateRecord) setFloat64(destField string, v models.OptionalFloat64) { } } -// func (r *updateRecord) setNullFloat64(destField string, v models.OptionalFloat64) { -// if v.Set { -// r.set(destField, null.FloatFromPtr(v.Ptr())) -// } -// } +func (r *updateRecord) setNullFloat64(destField string, v models.OptionalFloat64) { + if v.Set { + r.set(destField, null.FloatFromPtr(v.Ptr())) + } +} func (r *updateRecord) setSQLiteTimestamp(destField string, v models.OptionalTime) { if v.Set { diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index affe3cd723d..94c92035b86 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1331,6 +1331,29 @@ func getPerformerCareerLength(index int) *string { return &ret } +func getPerformerPenisLength(index int) *float64 { + if index%5 == 0 { + return nil + } + + ret := float64(index) + return &ret +} + +func getPerformerCircumcised(index int) *models.CircumisedEnum { + var ret models.CircumisedEnum + switch { + case index%3 == 0: + return nil + case index%3 == 1: + ret = models.CircumisedEnumCut + default: + ret = models.CircumisedEnumUncut + } + + return &ret +} + func getIgnoreAutoTag(index int) bool { return index%5 == 0 } @@ -1372,6 +1395,8 @@ func createPerformers(ctx context.Context, n int, o int) error { DeathDate: getPerformerDeathDate(i), Details: getPerformerStringValue(i, "Details"), Ethnicity: getPerformerStringValue(i, "Ethnicity"), + PenisLength: getPerformerPenisLength(i), + Circumcised: getPerformerCircumcised(i), Rating: getIntPtr(getRating(i)), IgnoreAutoTag: getIgnoreAutoTag(i), TagIDs: models.NewRelatedIDs(tids), diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index a410bac28d0..90b922520f5 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -159,6 +159,22 @@ func getStringSearchClause(columns []string, q string, not bool) sqlClause { return makeClause("("+likes+")", args...) } +func getEnumSearchClause(column string, enumVals []string, not bool) sqlClause { + var args []interface{} + + notStr := "" + if not { + notStr = " NOT" + } + + clause := fmt.Sprintf("(%s%s IN %s)", column, notStr, getInBinding(len(enumVals))) + for _, enumVal := range enumVals { + args = append(args, enumVal) + } + + return makeClause(clause, args...) +} + func getInBinding(length int) string { bindings := strings.Repeat("?, ", length) bindings = strings.TrimRight(bindings, ", ") @@ -175,8 +191,26 @@ func getIntWhereClause(column string, modifier models.CriterionModifier, value i upper = &u } - args := []interface{}{value} - betweenArgs := []interface{}{value, *upper} + args := []interface{}{value, *upper} + return getNumericWhereClause(column, modifier, args) +} + +func getFloatCriterionWhereClause(column string, input models.FloatCriterionInput) (string, []interface{}) { + return getFloatWhereClause(column, input.Modifier, input.Value, input.Value2) +} + +func getFloatWhereClause(column string, modifier models.CriterionModifier, value float64, upper *float64) (string, []interface{}) { + if upper == nil { + u := 0.0 + upper = &u + } + + args := []interface{}{value, *upper} + return getNumericWhereClause(column, modifier, args) +} + +func getNumericWhereClause(column string, modifier models.CriterionModifier, args []interface{}) (string, []interface{}) { + singleArgs := args[0:1] switch modifier { case models.CriterionModifierIsNull: @@ -184,20 +218,20 @@ func getIntWhereClause(column string, modifier models.CriterionModifier, value i case models.CriterionModifierNotNull: return fmt.Sprintf("%s IS NOT NULL", column), nil case models.CriterionModifierEquals: - return fmt.Sprintf("%s = ?", column), args + return fmt.Sprintf("%s = ?", column), singleArgs case models.CriterionModifierNotEquals: - return fmt.Sprintf("%s != ?", column), args + return fmt.Sprintf("%s != ?", column), singleArgs case models.CriterionModifierBetween: - return fmt.Sprintf("%s BETWEEN ? AND ?", column), betweenArgs + return fmt.Sprintf("%s BETWEEN ? AND ?", column), args case models.CriterionModifierNotBetween: - return fmt.Sprintf("%s NOT BETWEEN ? AND ?", column), betweenArgs + return fmt.Sprintf("%s NOT BETWEEN ? AND ?", column), args case models.CriterionModifierLessThan: - return fmt.Sprintf("%s < ?", column), args + return fmt.Sprintf("%s < ?", column), singleArgs case models.CriterionModifierGreaterThan: - return fmt.Sprintf("%s > ?", column), args + return fmt.Sprintf("%s > ?", column), singleArgs } - panic("unsupported int modifier type " + modifier) + panic("unsupported numeric modifier type " + modifier) } func getDateCriterionWhereClause(column string, input models.DateCriterionInput) (string, []interface{}) { diff --git a/pkg/sqlite/values.go b/pkg/sqlite/values.go index eafb8e462f5..be812275f89 100644 --- a/pkg/sqlite/values.go +++ b/pkg/sqlite/values.go @@ -24,6 +24,15 @@ func nullIntPtr(i null.Int) *int { return &v } +func nullFloatPtr(i null.Float) *float64 { + if !i.Valid { + return nil + } + + v := float64(i.Float64) + return &v +} + func nullIntFolderIDPtr(i null.Int) *file.FolderID { if !i.Valid { return nil diff --git a/pkg/utils/strings.go b/pkg/utils/strings.go index 02e1fe67b50..0b57f5f6e1d 100644 --- a/pkg/utils/strings.go +++ b/pkg/utils/strings.go @@ -31,3 +31,13 @@ func StrFormat(format string, m StrFormatMap) string { return strings.NewReplacer(args...).Replace(format) } + +// StringerSliceToStringSlice converts a slice of fmt.Stringers to a slice of strings. +func StringerSliceToStringSlice[V fmt.Stringer](v []V) []string { + ret := make([]string, len(v)) + for i, vv := range v { + ret[i] = vv.String() + } + + return ret +} diff --git a/ui/v2.5/src/components/List/CriterionEditor.tsx b/ui/v2.5/src/components/List/CriterionEditor.tsx index 763d7c4f08f..dd099cacdfb 100644 --- a/ui/v2.5/src/components/List/CriterionEditor.tsx +++ b/ui/v2.5/src/components/List/CriterionEditor.tsx @@ -36,7 +36,7 @@ import { StashIDFilter } from "./Filters/StashIDFilter"; import { RatingCriterion } from "../../models/list-filter/criteria/rating"; import { RatingFilter } from "./Filters/RatingFilter"; import { BooleanFilter } from "./Filters/BooleanFilter"; -import { OptionsListFilter } from "./Filters/OptionsListFilter"; +import { OptionFilter, OptionListFilter } from "./Filters/OptionFilter"; import { PathFilter } from "./Filters/PathFilter"; import { PhashCriterion } from "src/models/list-filter/criteria/phash"; import { PhashFilter } from "./Filters/PhashFilter"; @@ -132,18 +132,17 @@ const GenericCriterionEditor: React.FC = ({ !criterionIsNumberValue(criterion.value) && !criterionIsStashIDValue(criterion.value) && !criterionIsDateValue(criterion.value) && - !criterionIsTimestampValue(criterion.value) && - !Array.isArray(criterion.value) + !criterionIsTimestampValue(criterion.value) ) { - // if (!modifierOptions || modifierOptions.length === 0) { - return ( - - ); - // } - - // return ( - // - // ); + if (!Array.isArray(criterion.value)) { + return ( + + ); + } else { + return ( + + ); + } } if (criterion.criterionOption instanceof PathCriterionOption) { return ( diff --git a/ui/v2.5/src/components/List/Filters/BooleanFilter.tsx b/ui/v2.5/src/components/List/Filters/BooleanFilter.tsx index 0a04a4fc657..e9e2da08404 100644 --- a/ui/v2.5/src/components/List/Filters/BooleanFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/BooleanFilter.tsx @@ -30,14 +30,14 @@ export const BooleanFilter: React.FC = ({ id={`${criterion.getId()}-true`} onChange={() => onSelect(true)} checked={criterion.value === "true"} - type="checkbox" + type="radio" label={} /> onSelect(false)} checked={criterion.value === "false"} - type="checkbox" + type="radio" label={} />
diff --git a/ui/v2.5/src/components/List/Filters/OptionFilter.tsx b/ui/v2.5/src/components/List/Filters/OptionFilter.tsx new file mode 100644 index 00000000000..dad0e38cc99 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/OptionFilter.tsx @@ -0,0 +1,85 @@ +import cloneDeep from "lodash-es/cloneDeep"; +import React from "react"; +import { Form } from "react-bootstrap"; +import { + CriterionValue, + Criterion, +} from "src/models/list-filter/criteria/criterion"; + +interface IOptionsFilter { + criterion: Criterion; + setCriterion: (c: Criterion) => void; +} + +export const OptionFilter: React.FC = ({ + criterion, + setCriterion, +}) => { + function onSelect(v: string) { + const c = cloneDeep(criterion); + if (c.value === v) { + c.value = ""; + } else { + c.value = v; + } + + setCriterion(c); + } + + const { options } = criterion.criterionOption; + + return ( +
+ {options?.map((o) => ( + onSelect(o.toString())} + checked={criterion.value === o.toString()} + type="radio" + label={o.toString()} + /> + ))} +
+ ); +}; + +interface IOptionsListFilter { + criterion: Criterion; + setCriterion: (c: Criterion) => void; +} + +export const OptionListFilter: React.FC = ({ + criterion, + setCriterion, +}) => { + function onSelect(v: string) { + const c = cloneDeep(criterion); + const cv = c.value as string[]; + if (cv.includes(v)) { + c.value = cv.filter((x) => x !== v); + } else { + c.value = [...cv, v]; + } + + setCriterion(c); + } + + const { options } = criterion.criterionOption; + const value = criterion.value as string[]; + + return ( +
+ {options?.map((o) => ( + onSelect(o.toString())} + checked={value.includes(o.toString())} + type="checkbox" + label={o.toString()} + /> + ))} +
+ ); +}; diff --git a/ui/v2.5/src/components/List/Filters/OptionsFilter.tsx b/ui/v2.5/src/components/List/Filters/OptionsFilter.tsx deleted file mode 100644 index 2f6f40bdc37..00000000000 --- a/ui/v2.5/src/components/List/Filters/OptionsFilter.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React, { useMemo } from "react"; -import { Form } from "react-bootstrap"; -import { - Criterion, - CriterionValue, -} from "../../../models/list-filter/criteria/criterion"; - -interface IOptionsFilterProps { - criterion: Criterion; - onValueChanged: (value: CriterionValue) => void; -} - -export const OptionsFilter: React.FC = ({ - criterion, - onValueChanged, -}) => { - function onChanged(event: React.ChangeEvent) { - onValueChanged(event.target.value); - } - - const options = useMemo(() => { - const ret = criterion.criterionOption.options?.slice() ?? []; - - ret.unshift(""); - - return ret; - }, [criterion.criterionOption.options]); - - return ( - - - {options.map((c) => ( - - ))} - - - ); -}; diff --git a/ui/v2.5/src/components/List/Filters/OptionsListFilter.tsx b/ui/v2.5/src/components/List/Filters/OptionsListFilter.tsx deleted file mode 100644 index b84cf8bd129..00000000000 --- a/ui/v2.5/src/components/List/Filters/OptionsListFilter.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import cloneDeep from "lodash-es/cloneDeep"; -import React from "react"; -import { Form } from "react-bootstrap"; -import { - CriterionValue, - Criterion, -} from "src/models/list-filter/criteria/criterion"; - -interface IOptionsListFilter { - criterion: Criterion; - setCriterion: (c: Criterion) => void; -} - -export const OptionsListFilter: React.FC = ({ - criterion, - setCriterion, -}) => { - function onSelect(v: string) { - const c = cloneDeep(criterion); - if (c.value === v) { - c.value = ""; - } else { - c.value = v; - } - - setCriterion(c); - } - - const { options } = criterion.criterionOption; - - return ( -
- {options?.map((o) => ( - onSelect(o.toString())} - checked={criterion.value === o.toString()} - type="checkbox" - label={o.toString()} - /> - ))} -
- ); -}; diff --git a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx index aff7fa268e2..892ac098948 100644 --- a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx +++ b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx @@ -17,6 +17,11 @@ import { genderToString, stringToGender, } from "src/utils/gender"; +import { + circumcisedStrings, + circumcisedToString, + stringToCircumcised, +} from "src/utils/circumcised"; import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; @@ -45,6 +50,8 @@ const performerFields = [ // "weight", "measurements", "fake_tits", + "penis_length", + "circumcised", "hair_color", "tattoos", "piercings", @@ -64,10 +71,12 @@ export const EditPerformersDialog: React.FC = ( useState({}); // weight needs conversion to/from number const [weight, setWeight] = useState(); + const [penis_length, setPenisLength] = useState(); const [updateInput, setUpdateInput] = useState( {} ); const genderOptions = [""].concat(genderStrings); + const circumcisedOptions = [""].concat(circumcisedStrings); const [updatePerformers] = useBulkPerformerUpdate(getPerformerInput()); @@ -100,11 +109,19 @@ export const EditPerformersDialog: React.FC = ( updateInput.gender, aggregateState.gender ); + performerInput.circumcised = getAggregateInputValue( + updateInput.circumcised, + aggregateState.circumcised + ); if (weight !== undefined) { performerInput.weight = parseFloat(weight); } + if (penis_length !== undefined) { + performerInput.penis_length = parseFloat(penis_length); + } + return performerInput; } @@ -135,6 +152,7 @@ export const EditPerformersDialog: React.FC = ( const state = props.selected; let updateTagIds: string[] = []; let updateWeight: string | undefined | null = undefined; + let updatePenisLength: string | undefined | null = undefined; let first = true; state.forEach((performer: GQL.SlimPerformerDataFragment) => { @@ -151,6 +169,16 @@ export const EditPerformersDialog: React.FC = ( : performer.weight; updateWeight = getAggregateState(updateWeight, thisWeight, first); + const thisPenisLength = + performer.penis_length !== undefined && performer.penis_length !== null + ? performer.penis_length.toString() + : performer.penis_length; + updatePenisLength = getAggregateState( + updatePenisLength, + thisPenisLength, + first + ); + first = false; }); @@ -270,6 +298,32 @@ export const EditPerformersDialog: React.FC = ( {renderTextField("measurements", updateInput.measurements, (v) => setUpdateField({ measurements: v }) )} + {renderTextField("penis_length", penis_length, (v) => + setPenisLength(v) + )} + + + + + + + setUpdateField({ + circumcised: stringToCircumcised(event.currentTarget.value), + }) + } + > + {circumcisedOptions.map((opt) => ( + + ))} + + + {renderTextField("fake_tits", updateInput.fake_tits, (v) => setUpdateField({ fake_tits: v }) )} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx index 9a0aa9f07ec..514258a3811 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx @@ -6,7 +6,7 @@ import TextUtils from "src/utils/text"; import { getStashboxBase } from "src/utils/stashbox"; import { getCountryByISO } from "src/utils/country"; import { TextField, URLField } from "src/utils/field"; -import { cmToImperial, kgToLbs } from "src/utils/units"; +import { cmToImperial, cmToInches, kgToLbs } from "src/utils/units"; interface IPerformerDetails { performer: GQL.PerformerDataFragment; @@ -133,6 +133,49 @@ export const PerformerDetailsPanel: React.FC = ({ ); }; + const formatPenisLength = (penis_length?: number | null) => { + if (!penis_length) { + return ""; + } + + const inches = cmToInches(penis_length); + + return ( + + + {intl.formatNumber(penis_length, { + style: "unit", + unit: "centimeter", + unitDisplay: "short", + maximumFractionDigits: 2, + })} + + + {intl.formatNumber(inches, { + style: "unit", + unit: "inch", + unitDisplay: "narrow", + maximumFractionDigits: 2, + })} + + + ); + }; + + const formatCircumcised = (circumcised?: GQL.CircumisedEnum | null) => { + if (!circumcised) { + return ""; + } + + return ( + + {intl.formatMessage({ + id: "circumcised_types." + performer.circumcised, + })} + + ); + }; + return (
= ({ )} + {(performer.penis_length || performer.circumcised) && ( + <> +
+ : +
+
+ {formatPenisLength(performer.penis_length)} + {formatCircumcised(performer.circumcised)} +
+ + )} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index e8c2ef028c7..03f2dd12838 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -31,6 +31,11 @@ import { stringGenderMap, stringToGender, } from "src/utils/gender"; +import { + circumcisedToString, + stringCircumMap, + stringToCircumcised, +} from "src/utils/circumcised"; import { ConfigurationContext } from "src/hooks/Config"; import { PerformerScrapeDialog } from "./PerformerScrapeDialog"; import PerformerScrapeModal from "./PerformerScrapeModal"; @@ -153,6 +158,8 @@ export const PerformerEditPanel: React.FC = ({ weight: yup.number().nullable().defined().default(null), measurements: yup.string().ensure(), fake_tits: yup.string().ensure(), + penis_length: yup.number().nullable().defined().default(null), + circumcised: yup.string().ensure(), tattoos: yup.string().ensure(), piercings: yup.string().ensure(), career_length: yup.string().ensure(), @@ -181,6 +188,8 @@ export const PerformerEditPanel: React.FC = ({ weight: performer.weight ?? null, measurements: performer.measurements ?? "", fake_tits: performer.fake_tits ?? "", + penis_length: performer.penis_length ?? null, + circumcised: (performer.circumcised as GQL.CircumisedEnum) ?? "", tattoos: performer.tattoos ?? "", piercings: performer.piercings ?? "", career_length: performer.career_length ?? "", @@ -219,6 +228,21 @@ export const PerformerEditPanel: React.FC = ({ } } + function translateScrapedCircumcised(scrapedCircumcised?: string) { + if (!scrapedCircumcised) { + return; + } + + const upperCircumcised = scrapedCircumcised.toUpperCase(); + const asEnum = circumcisedToString(upperCircumcised); + if (asEnum) { + return stringToCircumcised(asEnum); + } else { + const caseInsensitive = true; + return stringToCircumcised(scrapedCircumcised, caseInsensitive); + } + } + function renderNewTags() { if (!newTags || newTags.length === 0) { return; @@ -355,6 +379,13 @@ export const PerformerEditPanel: React.FC = ({ formik.setFieldValue("gender", newGender); } } + if (state.circumcised) { + // circumcised is a string in the scraper data + const newCircumcised = translateScrapedCircumcised(state.circumcised); + if (newCircumcised) { + formik.setFieldValue("circumcised", newCircumcised); + } + } if (state.tags) { // map tags to their ids and filter out those not found const newTagIds = state.tags.map((t) => t.stored_id).filter((t) => t); @@ -387,6 +418,9 @@ export const PerformerEditPanel: React.FC = ({ if (state.weight) { formik.setFieldValue("weight", state.weight); } + if (state.penis_length) { + formik.setFieldValue("penis_length", state.penis_length); + } const remoteSiteID = state.remote_site_id; if (remoteSiteID && (scraper as IStashBox).endpoint) { @@ -431,6 +465,8 @@ export const PerformerEditPanel: React.FC = ({ gender: input.gender || null, height_cm: input.height_cm || null, weight: input.weight || null, + penis_length: input.penis_length || null, + circumcised: input.circumcised || null, }, }, }); @@ -446,6 +482,8 @@ export const PerformerEditPanel: React.FC = ({ gender: input.gender || null, height_cm: input.height_cm || null, weight: input.weight || null, + penis_length: input.penis_length || null, + circumcised: input.circumcised || null, }, }, }); @@ -663,6 +701,7 @@ export const PerformerEditPanel: React.FC = ({ const currentPerformer = { ...formik.values, gender: formik.values.gender || null, + circumcised: formik.values.circumcised || null, image: formik.values.image ?? performer.image_path, }; @@ -990,6 +1029,31 @@ export const PerformerEditPanel: React.FC = ({ type: "number", messageID: "weight_kg", })} + {renderField("penis_length", { + type: "number", + messageID: "penis_length_cm", + })} + + + + + + + + + {Array.from(stringCircumMap.entries()).map(([name, value]) => ( + + ))} + + + + {renderField("measurements")} {renderField("fake_tits")} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx index 6a6a006f73d..90bd6f70c6f 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx @@ -20,6 +20,11 @@ import { genderToString, stringToGender, } from "src/utils/gender"; +import { + circumcisedStrings, + circumcisedToString, + stringToCircumcised, +} from "src/utils/circumcised"; import { IStashBox } from "./PerformerStashBoxModal"; function renderScrapedGender( @@ -120,6 +125,55 @@ function renderScrapedTagsRow( ); } +function renderScrapedCircumcised( + result: ScrapeResult, + isNew?: boolean, + onChange?: (value: string) => void +) { + const selectOptions = [""].concat(circumcisedStrings); + + return ( + { + if (isNew && onChange) { + onChange(e.currentTarget.value); + } + }} + > + {selectOptions.map((opt) => ( + + ))} + + ); +} + +function renderScrapedCircumcisedRow( + title: string, + result: ScrapeResult, + onChange: (value: ScrapeResult) => void +) { + return ( + renderScrapedCircumcised(result)} + renderNewField={() => + renderScrapedCircumcised(result, true, (value) => + onChange(result.cloneWithValue(value)) + ) + } + onChange={onChange} + /> + ); +} + interface IPerformerScrapeDialogProps { performer: Partial; scraped: GQL.ScrapedPerformer; @@ -165,6 +219,27 @@ export const PerformerScrapeDialog: React.FC = ( return genderToString(retEnum); } + function translateScrapedCircumcised(scrapedCircumcised?: string | null) { + if (!scrapedCircumcised) { + return; + } + + let retEnum: GQL.CircumisedEnum | undefined; + + // try to translate from enum values first + const upperCircumcised = scrapedCircumcised.toUpperCase(); + const asEnum = circumcisedToString(upperCircumcised); + if (asEnum) { + retEnum = stringToCircumcised(asEnum); + } else { + // try to match against circumcised strings + const caseInsensitive = true; + retEnum = stringToCircumcised(scrapedCircumcised, caseInsensitive); + } + + return circumcisedToString(retEnum); + } + const [name, setName] = useState>( new ScrapeResult(props.performer.name, props.scraped.name) ); @@ -216,6 +291,12 @@ export const PerformerScrapeDialog: React.FC = ( props.scraped.weight ) ); + const [penisLength, setPenisLength] = useState>( + new ScrapeResult( + props.performer.penis_length?.toString(), + props.scraped.penis_length + ) + ); const [measurements, setMeasurements] = useState>( new ScrapeResult( props.performer.measurements, @@ -252,6 +333,12 @@ export const PerformerScrapeDialog: React.FC = ( translateScrapedGender(props.scraped.gender) ) ); + const [circumcised, setCircumcised] = useState>( + new ScrapeResult( + circumcisedToString(props.performer.circumcised), + translateScrapedCircumcised(props.scraped.circumcised) + ) + ); const [details, setDetails] = useState>( new ScrapeResult(props.performer.details, props.scraped.details) ); @@ -338,6 +425,8 @@ export const PerformerScrapeDialog: React.FC = ( height, measurements, fakeTits, + penisLength, + circumcised, careerLength, tattoos, piercings, @@ -426,6 +515,8 @@ export const PerformerScrapeDialog: React.FC = ( death_date: deathDate.getNewValue(), hair_color: hairColor.getNewValue(), weight: weight.getNewValue(), + penis_length: penisLength.getNewValue(), + circumcised: circumcised.getNewValue(), remote_site_id: remoteSiteID.getNewValue(), }; } @@ -493,6 +584,16 @@ export const PerformerScrapeDialog: React.FC = ( result={height} onChange={(value) => setHeight(value)} /> + setPenisLength(value)} + /> + {renderScrapedCircumcisedRow( + intl.formatMessage({ id: "circumcised" }), + circumcised, + (value) => setCircumcised(value) + )} { death_date: toCreate.death_date, hair_color: toCreate.hair_color, weight: toCreate.weight ? Number(toCreate.weight) : undefined, + penis_length: toCreate.penis_length + ? Number(toCreate.penis_length) + : undefined, + circumcised: stringToCircumcised(toCreate.circumcised), }; return input; }; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 8827d38bc69..5a84cba9b15 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -141,6 +141,11 @@ "captions": "Captions", "career_length": "Career Length", "chapters": "Chapters", + "circumcised": "Circumcised", + "circumcised_types": { + "UNCUT": "Uncut", + "CUT": "Cut" + }, "component_tagger": { "config": { "active_instance": "Active stash-box instance:", @@ -1016,6 +1021,9 @@ "parent_tags": "Parent Tags", "part_of": "Part of {parent}", "path": "Path", + "penis": "Penis", + "penis_length": "Penis Length", + "penis_length_cm": "Penis Length (cm)", "perceptual_similarity": "Perceptual Similarity (phash)", "performer": "Performer", "performerTags": "Performer Tags", diff --git a/ui/v2.5/src/models/list-filter/criteria/circumcised.ts b/ui/v2.5/src/models/list-filter/criteria/circumcised.ts new file mode 100644 index 00000000000..c18aa1b017f --- /dev/null +++ b/ui/v2.5/src/models/list-filter/criteria/circumcised.ts @@ -0,0 +1,36 @@ +import { + CircumcisionCriterionInput, + CircumisedEnum, + CriterionModifier, +} from "src/core/generated-graphql"; +import { circumcisedStrings, stringToCircumcised } from "src/utils/circumcised"; +import { CriterionOption, MultiStringCriterion } from "./criterion"; + +export const CircumcisedCriterionOption = new CriterionOption({ + messageID: "circumcised", + type: "circumcised", + options: circumcisedStrings, + modifierOptions: [ + CriterionModifier.Includes, + CriterionModifier.Excludes, + CriterionModifier.IsNull, + CriterionModifier.NotNull, + ], +}); + +export class CircumcisedCriterion extends MultiStringCriterion { + constructor() { + super(CircumcisedCriterionOption); + } + + protected toCriterionInput(): CircumcisionCriterionInput { + const value = this.value.map((v) => + stringToCircumcised(v) + ) as CircumisedEnum[]; + + return { + value, + modifier: this.modifier, + }; + } +} diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index 7dc299a779e..642fe733601 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -28,6 +28,7 @@ import { export type Option = string | number | IOptionType; export type CriterionValue = | string + | string[] | ILabeledId[] | IHierarchicalLabelValue | INumberValue @@ -243,6 +244,24 @@ export class StringCriterion extends Criterion { } } +export class MultiStringCriterion extends Criterion { + constructor(type: CriterionOption) { + super(type, []); + } + + public getLabelValue(_intl: IntlShape) { + return this.value.join(", "); + } + + public isValid(): boolean { + return ( + this.modifier === CriterionModifier.IsNull || + this.modifier === CriterionModifier.NotNull || + this.value.length > 0 + ); + } +} + export class MandatoryStringCriterionOption extends CriterionOption { constructor( messageID: string, diff --git a/ui/v2.5/src/models/list-filter/criteria/factory.ts b/ui/v2.5/src/models/list-filter/criteria/factory.ts index f6c96cab8fd..311b7872821 100644 --- a/ui/v2.5/src/models/list-filter/criteria/factory.ts +++ b/ui/v2.5/src/models/list-filter/criteria/factory.ts @@ -44,6 +44,7 @@ import { TagsCriterionOption, } from "./tags"; import { GenderCriterion } from "./gender"; +import { CircumcisedCriterion } from "./circumcised"; import { MoviesCriterionOption } from "./movies"; import { GalleriesCriterion } from "./galleries"; import { CriterionType } from "../types"; @@ -155,12 +156,16 @@ export function makeCriteria( case "death_year": case "weight": return new NumberCriterion(new NumberCriterionOption(type, type)); + case "penis_length": + return new NumberCriterion(new NumberCriterionOption(type, type)); case "age": return new NumberCriterion( new MandatoryNumberCriterionOption(type, type) ); case "gender": return new GenderCriterion(); + case "circumcised": + return new CircumcisedCriterion(); case "sceneChecksum": case "galleryChecksum": return new StringCriterion( diff --git a/ui/v2.5/src/models/list-filter/performers.ts b/ui/v2.5/src/models/list-filter/performers.ts index 5a628ca2a0e..2995aebb747 100644 --- a/ui/v2.5/src/models/list-filter/performers.ts +++ b/ui/v2.5/src/models/list-filter/performers.ts @@ -10,6 +10,7 @@ import { } from "./criteria/criterion"; import { FavoriteCriterionOption } from "./criteria/favorite"; import { GenderCriterionOption } from "./criteria/gender"; +import { CircumcisedCriterionOption } from "./criteria/circumcised"; import { PerformerIsMissingCriterionOption } from "./criteria/is-missing"; import { StashIDCriterionOption } from "./criteria/stash-ids"; import { StudiosCriterionOption } from "./criteria/studios"; @@ -25,6 +26,7 @@ const sortByOptions = [ "tag_count", "random", "rating", + "penis_length", ] .map(ListFilterOptions.createSortBy) .concat([ @@ -57,6 +59,7 @@ const numberCriteria: CriterionType[] = [ "death_year", "age", "weight", + "penis_length", ]; const stringCriteria: CriterionType[] = [ @@ -78,6 +81,7 @@ const stringCriteria: CriterionType[] = [ const criterionOptions = [ FavoriteCriterionOption, GenderCriterionOption, + CircumcisedCriterionOption, PerformerIsMissingCriterionOption, TagsCriterionOption, StudiosCriterionOption, diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index e105e8ab8fe..548adc59f02 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -134,6 +134,8 @@ export type CriterionType = | "weight" | "measurements" | "fake_tits" + | "penis_length" + | "circumcised" | "career_length" | "tattoos" | "piercings" diff --git a/ui/v2.5/src/utils/circumcised.ts b/ui/v2.5/src/utils/circumcised.ts new file mode 100644 index 00000000000..b922a779559 --- /dev/null +++ b/ui/v2.5/src/utils/circumcised.ts @@ -0,0 +1,51 @@ +import * as GQL from "../core/generated-graphql"; + +export const stringCircumMap = new Map([ + ["Uncut", GQL.CircumisedEnum.Uncut], + ["Cut", GQL.CircumisedEnum.Cut], +]); + +export const circumcisedToString = ( + value?: GQL.CircumisedEnum | String | null +) => { + if (!value) { + return undefined; + } + + const foundEntry = Array.from(stringCircumMap.entries()).find((e) => { + return e[1] === value; + }); + + if (foundEntry) { + return foundEntry[0]; + } +}; + +export const stringToCircumcised = ( + value?: string | null, + caseInsensitive?: boolean +): GQL.CircumisedEnum | undefined => { + if (!value) { + return undefined; + } + + const existing = Object.entries(GQL.CircumisedEnum).find( + (e) => e[1] === value + ); + if (existing) return existing[1]; + + const ret = stringCircumMap.get(value); + if (ret || !caseInsensitive) { + return ret; + } + const asUpper = value.toUpperCase(); + const foundEntry = Array.from(stringCircumMap.entries()).find((e) => { + return e[0].toUpperCase() === asUpper; + }); + + if (foundEntry) { + return foundEntry[1]; + } +}; + +export const circumcisedStrings = Array.from(stringCircumMap.keys()); diff --git a/ui/v2.5/src/utils/units.ts b/ui/v2.5/src/utils/units.ts index 3115eed5fef..f0cae7e5215 100644 --- a/ui/v2.5/src/utils/units.ts +++ b/ui/v2.5/src/utils/units.ts @@ -9,3 +9,9 @@ export function cmToImperial(cm: number) { export function kgToLbs(kg: number) { return Math.round(kg * 2.20462262185); } + +export function cmToInches(cm: number) { + const cmInInches = 0.393700787; + const inches = cm * cmInInches; + return inches; +} From 94dda493525e3e15e5508e28e838cc5a70aecdad Mon Sep 17 00:00:00 2001 From: Bawdy Ink Slinger <51732963+BawdyInkSlinger@users.noreply.github.com> Date: Wed, 24 May 2023 16:27:37 -0700 Subject: [PATCH 033/135] Updated the English auto_tag_based_on_filenames message (#3682) * Updated the English auto_tag_based_on_filenames message --- ui/v2.5/src/locales/en-GB.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 5a84cba9b15..d314780e505 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -412,7 +412,7 @@ "auto_tagging_all_paths": "Auto Tagging all paths", "auto_tagging_paths": "Auto Tagging the following paths" }, - "auto_tag_based_on_filenames": "Auto-tag content based on filenames.", + "auto_tag_based_on_filenames": "Auto-tag content based on file paths.", "auto_tagging": "Auto Tagging", "backing_up_database": "Backing up database", "backup_and_download": "Performs a backup of the database and downloads the resulting file.", From ed7640b7b1a986169e2be147233f24c4f7897557 Mon Sep 17 00:00:00 2001 From: yoshnopa <40072150+yoshnopa@users.noreply.github.com> Date: Thu, 25 May 2023 01:29:05 +0200 Subject: [PATCH 034/135] Update Metadata Bugfix (#3757) --- pkg/file/image/scan.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/pkg/file/image/scan.go b/pkg/file/image/scan.go index afe4210e047..ec4ce542b21 100644 --- a/pkg/file/image/scan.go +++ b/pkg/file/image/scan.go @@ -69,10 +69,16 @@ func (d *Decorator) IsMissingMetadata(ctx context.Context, fs file.FS, f file.Fi unsetNumber = -1 ) - imf, ok := f.(*file.ImageFile) - if !ok { + imf, isImage := f.(*file.ImageFile) + vf, isVideo := f.(*file.VideoFile) + + switch { + case isImage: + return imf.Format == unsetString || imf.Width == unsetNumber || imf.Height == unsetNumber + case isVideo: + videoFileDecorator := video.Decorator{FFProbe: d.FFProbe} + return videoFileDecorator.IsMissingMetadata(ctx, fs, vf) + default: return true } - - return imf.Format == unsetString || imf.Width == unsetNumber || imf.Height == unsetNumber } From 2a85d512f4e9d75bad1062cfc6deded233ea1a25 Mon Sep 17 00:00:00 2001 From: yoshnopa <40072150+yoshnopa@users.noreply.github.com> Date: Thu, 25 May 2023 03:42:02 +0200 Subject: [PATCH 035/135] Clip Preview Generation Fix (#3764) --- .../manager/task_generate_clip_preview.go | 8 +---- pkg/image/thumbnail.go | 34 +++++++------------ 2 files changed, 14 insertions(+), 28 deletions(-) diff --git a/internal/manager/task_generate_clip_preview.go b/internal/manager/task_generate_clip_preview.go index b43ca7514dc..c0ecfeedfdb 100644 --- a/internal/manager/task_generate_clip_preview.go +++ b/internal/manager/task_generate_clip_preview.go @@ -35,18 +35,12 @@ func (t *GenerateClipPreviewTask) Start(ctx context.Context) { } encoder := image.NewThumbnailEncoder(GetInstance().FFMPEG, GetInstance().FFProbe, clipPreviewOptions) - data, err := encoder.GetPreview(t.Image.Files.Primary(), models.DefaultGthumbWidth) + err := encoder.GetPreview(filePath, prevPath, models.DefaultGthumbWidth) if err != nil { logger.Errorf("getting preview for image %s: %w", filePath, err) return } - err = fsutil.WriteFile(prevPath, data) - if err != nil { - logger.Errorf("writing preview for image %s: %w", filePath, err) - return - } - } func (t *GenerateClipPreviewTask) required() bool { diff --git a/pkg/image/thumbnail.go b/pkg/image/thumbnail.go index ca6fd40b9f3..dc07b0f5537 100644 --- a/pkg/image/thumbnail.go +++ b/pkg/image/thumbnail.go @@ -6,12 +6,14 @@ import ( "errors" "fmt" "os/exec" + "path/filepath" "runtime" "sync" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/ffmpeg/transcoder" "github.com/stashapp/stash/pkg/file" + "github.com/stashapp/stash/pkg/fsutil" ) const ffmpegImageQuality = 5 @@ -110,24 +112,11 @@ func (e *ThumbnailEncoder) GetThumbnail(f file.File, maxSize int) ([]byte, error // GetPreview returns the preview clip of the provided image clip resized to // the provided max size. It resizes based on the largest X/Y direction. -// It returns nil and an error if an error occurs reading, decoding or encoding -// the image, or if the image is not suitable for thumbnails. // It is hardcoded to 30 seconds maximum right now -func (e *ThumbnailEncoder) GetPreview(f file.File, maxSize int) ([]byte, error) { - reader, err := f.Open(&file.OsFS{}) - if err != nil { - return nil, err - } - defer reader.Close() - - buf := new(bytes.Buffer) - if _, err := buf.ReadFrom(reader); err != nil { - return nil, err - } - - fileData, err := e.FFProbe.NewVideoFile(f.Base().Path) +func (e *ThumbnailEncoder) GetPreview(inPath string, outPath string, maxSize int) error { + fileData, err := e.FFProbe.NewVideoFile(inPath) if err != nil { - return nil, err + return err } if fileData.Width <= maxSize { maxSize = fileData.Width @@ -136,7 +125,7 @@ func (e *ThumbnailEncoder) GetPreview(f file.File, maxSize int) ([]byte, error) if clipDuration > 30.0 { clipDuration = 30.0 } - return e.getClipPreview(buf, maxSize, clipDuration, fileData.FrameRate) + return e.getClipPreview(inPath, outPath, maxSize, clipDuration, fileData.FrameRate) } func (e *ThumbnailEncoder) ffmpegImageThumbnail(image *bytes.Buffer, maxSize int) ([]byte, error) { @@ -150,7 +139,7 @@ func (e *ThumbnailEncoder) ffmpegImageThumbnail(image *bytes.Buffer, maxSize int return e.FFMpeg.GenerateOutput(context.TODO(), args, image) } -func (e *ThumbnailEncoder) getClipPreview(image *bytes.Buffer, maxSize int, clipDuration float64, frameRate float64) ([]byte, error) { +func (e *ThumbnailEncoder) getClipPreview(inPath string, outPath string, maxSize int, clipDuration float64, frameRate float64) error { var thumbFilter ffmpeg.VideoFilter thumbFilter = thumbFilter.ScaleMaxSize(maxSize) @@ -173,7 +162,7 @@ func (e *ThumbnailEncoder) getClipPreview(image *bytes.Buffer, maxSize int, clip } thumbOptions := transcoder.TranscodeOptions{ - OutputPath: "-", + OutputPath: outPath, StartTime: 0, Duration: clipDuration, @@ -187,6 +176,9 @@ func (e *ThumbnailEncoder) getClipPreview(image *bytes.Buffer, maxSize int, clip ExtraOutputArgs: o.OutputArgs, } - args := transcoder.Transcode("-", thumbOptions) - return e.FFMpeg.GenerateOutput(context.TODO(), args, image) + if err := fsutil.EnsureDirAll(filepath.Dir(outPath)); err != nil { + return err + } + args := transcoder.Transcode(inPath, thumbOptions) + return e.FFMpeg.Generate(context.TODO(), args) } From 3eb805ca2df3b716ffc09f9be5c800c82c640f03 Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Thu, 25 May 2023 03:48:32 +0200 Subject: [PATCH 036/135] Fix performer image display (#3767) * Fix displayed performer image sticking after save * Reset URL before showing dialog in ImageInput --- .../components/Performers/PerformerDetails/Performer.tsx | 5 +++++ ui/v2.5/src/components/Shared/ImageInput.tsx | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index ddd74cff43a..197556c8b5c 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -122,6 +122,11 @@ const PerformerPage: React.FC = ({ performer }) => { setRating ); + // reset image if performer changed + useEffect(() => { + setImage(undefined); + }, [performer]); + // set up hotkeys useEffect(() => { Mousetrap.bind("a", () => setActiveTabKey("details")); diff --git a/ui/v2.5/src/components/Shared/ImageInput.tsx b/ui/v2.5/src/components/Shared/ImageInput.tsx index cf25aa887b3..05b5eb26412 100644 --- a/ui/v2.5/src/components/Shared/ImageInput.tsx +++ b/ui/v2.5/src/components/Shared/ImageInput.tsx @@ -53,6 +53,11 @@ export const ImageInput: React.FC = ({ ); } + function showDialog() { + setURL(""); + setIsShowDialog(true); + } + function onConfirmURL() { if (!onImageURL) { return; @@ -112,7 +117,7 @@ export const ImageInput: React.FC = ({
- From 45e61b922866f80822d52d92a07c60422343d32f Mon Sep 17 00:00:00 2001 From: yoshnopa <40072150+yoshnopa@users.noreply.github.com> Date: Thu, 25 May 2023 04:02:32 +0200 Subject: [PATCH 037/135] fix Clip Gif Support (#3765) --- ui/v2.5/src/components/Images/ImageDetails/Image.tsx | 4 ++-- ui/v2.5/src/hooks/Lightbox/Lightbox.tsx | 3 ++- ui/v2.5/src/hooks/Lightbox/types.ts | 1 + ui/v2.5/src/utils/visualFile.ts | 9 +++++++++ 4 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 ui/v2.5/src/utils/visualFile.ts diff --git a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx index dda47e9d2f6..b9485767c2e 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx @@ -25,6 +25,7 @@ import { ImageDetailPanel } from "./ImageDetailPanel"; import { DeleteImagesDialog } from "../DeleteImagesDialog"; import { faEllipsisV } from "@fortawesome/free-solid-svg-icons"; import { objectPath, objectTitle } from "src/core/files"; +import { isVideo } from "src/utils/visualFile"; interface IImageParams { id?: string; @@ -260,8 +261,7 @@ export const Image: React.FC = () => { } const title = objectTitle(image); - const ImageView = - image.visual_files[0].__typename == "VideoFile" ? "video" : "img"; + const ImageView = isVideo(image.visual_files[0]) ? "video" : "img"; return (
diff --git a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx index 8cadd2d5457..cfe1d5db34d 100644 --- a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx +++ b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx @@ -47,6 +47,7 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { useDebounce } from "../debounce"; +import { isVideo } from "src/utils/visualFile"; const CLASSNAME = "Lightbox"; const CLASSNAME_HEADER = `${CLASSNAME}-header`; @@ -850,7 +851,7 @@ export const LightboxComponent: React.FC = ({ scrollAttemptsBeforeChange={scrollAttemptsBeforeChange} setZoom={(v) => setZoom(v)} resetPosition={resetPosition} - isVideo={image.visual_files?.[0]?.__typename == "VideoFile"} + isVideo={isVideo(image.visual_files?.[0] ?? {})} /> ) : undefined}
diff --git a/ui/v2.5/src/hooks/Lightbox/types.ts b/ui/v2.5/src/hooks/Lightbox/types.ts index f955a060a78..e98fb48f4db 100644 --- a/ui/v2.5/src/hooks/Lightbox/types.ts +++ b/ui/v2.5/src/hooks/Lightbox/types.ts @@ -10,6 +10,7 @@ interface IFiles { __typename?: string; width: number; height: number; + video_codec?: GQL.Maybe; } export interface ILightboxImage { diff --git a/ui/v2.5/src/utils/visualFile.ts b/ui/v2.5/src/utils/visualFile.ts new file mode 100644 index 00000000000..c88aa83ec51 --- /dev/null +++ b/ui/v2.5/src/utils/visualFile.ts @@ -0,0 +1,9 @@ +import { Maybe } from "src/core/generated-graphql"; + +// returns true if the file should be treated as a video in the UI +export function isVideo(o: { + __typename?: string; + video_codec?: Maybe; +}) { + return o.__typename == "VideoFile" && o.video_codec != "gif"; +} From 62b6457f4eb3b470ee376d679c67652a33232019 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 25 May 2023 12:03:49 +1000 Subject: [PATCH 038/135] Improve studio/tag/performer filtering (#3619) * Support excludes field * Refactor studio filter * Refactor tags filter * Support excludes in tags --------- Co-authored-by: Kermie --- graphql/schema/types/filters.graphql | 2 + pkg/models/filter.go | 2 + pkg/sqlite/filter.go | 201 +++++++--- pkg/sqlite/gallery.go | 2 +- pkg/sqlite/image.go | 2 +- pkg/sqlite/scene.go | 2 +- pkg/sqlite/scene_marker.go | 4 +- pkg/sqlite/tag.go | 4 +- .../src/components/List/CriterionEditor.tsx | 33 ++ .../List/Filters/PerformersFilter.tsx | 44 +++ .../List/Filters/SelectableFilter.tsx | 342 ++++++++++++++++++ .../components/List/Filters/StudiosFilter.tsx | 44 +++ .../components/List/Filters/TagsFilter.tsx | 41 +++ ui/v2.5/src/components/List/styles.scss | 101 ++++++ .../src/components/Shared/ClearableInput.tsx | 54 +++ ui/v2.5/src/components/Shared/styles.scss | 27 ++ .../StudioDetails/StudioPerformersPanel.tsx | 1 + .../Tags/TagDetails/TagMarkersPanel.tsx | 1 + ui/v2.5/src/core/performers.ts | 8 +- ui/v2.5/src/core/studios.ts | 1 + ui/v2.5/src/core/tags.ts | 1 + ui/v2.5/src/locales/en-GB.json | 1 + .../models/list-filter/criteria/criterion.ts | 80 +++- .../models/list-filter/criteria/performers.ts | 108 +++++- .../models/list-filter/criteria/studios.ts | 19 +- .../src/models/list-filter/criteria/tags.ts | 52 ++- ui/v2.5/src/models/list-filter/filter.ts | 2 +- ui/v2.5/src/models/list-filter/types.ts | 6 + ui/v2.5/src/utils/keyboard.ts | 9 + ui/v2.5/src/utils/navigation.ts | 28 +- 30 files changed, 1105 insertions(+), 117 deletions(-) create mode 100644 ui/v2.5/src/components/List/Filters/PerformersFilter.tsx create mode 100644 ui/v2.5/src/components/List/Filters/SelectableFilter.tsx create mode 100644 ui/v2.5/src/components/List/Filters/StudiosFilter.tsx create mode 100644 ui/v2.5/src/components/List/Filters/TagsFilter.tsx create mode 100644 ui/v2.5/src/components/Shared/ClearableInput.tsx create mode 100644 ui/v2.5/src/utils/keyboard.ts diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 0b18cbfee64..55724cc4243 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -518,6 +518,7 @@ input FloatCriterionInput { input MultiCriterionInput { value: [ID!] modifier: CriterionModifier! + excludes: [ID!] } input GenderCriterionInput { @@ -534,6 +535,7 @@ input HierarchicalMultiCriterionInput { value: [ID!] modifier: CriterionModifier! depth: Int + excludes: [ID!] } input DateCriterionInput { diff --git a/pkg/models/filter.go b/pkg/models/filter.go index 42cff1118d3..e0f9b7a5492 100644 --- a/pkg/models/filter.go +++ b/pkg/models/filter.go @@ -132,11 +132,13 @@ type HierarchicalMultiCriterionInput struct { Value []string `json:"value"` Modifier CriterionModifier `json:"modifier"` Depth *int `json:"depth"` + Excludes []string `json:"excludes"` } type MultiCriterionInput struct { Value []string `json:"value"` Modifier CriterionModifier `json:"modifier"` + Excludes []string `json:"excludes"` } type DateCriterionInput struct { diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index d0c74772df5..d670dc1a781 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -629,9 +629,12 @@ type joinedMultiCriterionHandlerBuilder struct { addJoinTable func(f *filterBuilder) } -func (m *joinedMultiCriterionHandlerBuilder) handler(criterion *models.MultiCriterionInput) criterionHandlerFunc { +func (m *joinedMultiCriterionHandlerBuilder) handler(c *models.MultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { - if criterion != nil { + if c != nil { + // make local copy so we can modify it + criterion := *c + joinAlias := m.joinAs if joinAlias == "" { joinAlias = m.joinTable @@ -653,37 +656,68 @@ func (m *joinedMultiCriterionHandlerBuilder) handler(criterion *models.MultiCrit return } - if len(criterion.Value) == 0 { + if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { return } - var args []interface{} - for _, tagID := range criterion.Value { - args = append(args, tagID) + // combine excludes if excludes modifier is selected + if criterion.Modifier == models.CriterionModifierExcludes { + criterion.Modifier = models.CriterionModifierIncludesAll + criterion.Excludes = append(criterion.Excludes, criterion.Value...) + criterion.Value = nil } - whereClause := "" - havingClause := "" + if len(criterion.Value) > 0 { + whereClause := "" + havingClause := "" + + var args []interface{} + for _, tagID := range criterion.Value { + args = append(args, tagID) + } + + switch criterion.Modifier { + case models.CriterionModifierIncludes: + // includes any of the provided ids + m.addJoinTable(f) + whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) + case models.CriterionModifierEquals: + // includes only the provided ids + m.addJoinTable(f) + whereClause = utils.StrFormat("{joinAlias}.{foreignFK} IN {inBinding} AND (SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.id) = ?", utils.StrFormatMap{ + "joinAlias": joinAlias, + "foreignFK": m.foreignFK, + "inBinding": getInBinding(len(criterion.Value)), + "joinTable": m.joinTable, + "primaryFK": m.primaryFK, + "primaryTable": m.primaryTable, + }) + havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value)) + args = append(args, len(criterion.Value)) + case models.CriterionModifierIncludesAll: + // includes all of the provided ids + m.addJoinTable(f) + whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) + havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value)) + } + + f.addWhere(whereClause, args...) + f.addHaving(havingClause) + } + + if len(criterion.Excludes) > 0 { + var args []interface{} + for _, tagID := range criterion.Excludes { + args = append(args, tagID) + } - switch criterion.Modifier { - case models.CriterionModifierIncludes: - // includes any of the provided ids - m.addJoinTable(f) - whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) - case models.CriterionModifierIncludesAll: - // includes all of the provided ids - m.addJoinTable(f) - whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) - havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value)) - case models.CriterionModifierExcludes: // excludes all of the provided ids // need to use actual join table name for this // .id NOT IN (select . from where . in ) - whereClause = fmt.Sprintf("%[1]s.id NOT IN (SELECT %[3]s.%[2]s from %[3]s where %[3]s.%[4]s in %[5]s)", m.primaryTable, m.primaryFK, m.joinTable, m.foreignFK, getInBinding(len(criterion.Value))) - } + whereClause := fmt.Sprintf("%[1]s.id NOT IN (SELECT %[3]s.%[2]s from %[3]s where %[3]s.%[4]s in %[5]s)", m.primaryTable, m.primaryFK, m.joinTable, m.foreignFK, getInBinding(len(criterion.Excludes))) - f.addWhere(whereClause, args...) - f.addHaving(havingClause) + f.addWhere(whereClause, args...) + } } } } @@ -890,7 +924,7 @@ WHERE id in {inBinding} return valuesClause } -func addHierarchicalConditionClauses(f *filterBuilder, criterion *models.HierarchicalMultiCriterionInput, table, idColumn string) { +func addHierarchicalConditionClauses(f *filterBuilder, criterion models.HierarchicalMultiCriterionInput, table, idColumn string) { switch criterion.Modifier { case models.CriterionModifierIncludes: f.addWhere(fmt.Sprintf("%s.%s IS NOT NULL", table, idColumn)) @@ -902,9 +936,12 @@ func addHierarchicalConditionClauses(f *filterBuilder, criterion *models.Hierarc } } -func (m *hierarchicalMultiCriterionHandlerBuilder) handler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { +func (m *hierarchicalMultiCriterionHandlerBuilder) handler(c *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { - if criterion != nil { + if c != nil { + // make a copy so we don't modify the original + criterion := *c + if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { var notClause string if criterion.Modifier == models.CriterionModifierNotNull { @@ -919,19 +956,32 @@ func (m *hierarchicalMultiCriterionHandlerBuilder) handler(criterion *models.Hie return } - if len(criterion.Value) == 0 { + if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { return } - valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) + // combine excludes if excludes modifier is selected + if criterion.Modifier == models.CriterionModifierExcludes { + criterion.Modifier = models.CriterionModifierIncludesAll + criterion.Excludes = append(criterion.Excludes, criterion.Value...) + criterion.Value = nil + } + + if len(criterion.Value) > 0 { + valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) + + switch criterion.Modifier { + case models.CriterionModifierIncludes: + f.addWhere(fmt.Sprintf("%s.%s IN (SELECT column2 FROM (%s))", m.primaryTable, m.foreignFK, valuesClause)) + case models.CriterionModifierIncludesAll: + f.addWhere(fmt.Sprintf("%s.%s IN (SELECT column2 FROM (%s))", m.primaryTable, m.foreignFK, valuesClause)) + f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", m.primaryTable, m.foreignFK, len(criterion.Value))) + } + } + + if len(criterion.Excludes) > 0 { + valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) - switch criterion.Modifier { - case models.CriterionModifierIncludes: - f.addWhere(fmt.Sprintf("%s.%s IN (SELECT column2 FROM (%s))", m.primaryTable, m.foreignFK, valuesClause)) - case models.CriterionModifierIncludesAll: - f.addWhere(fmt.Sprintf("%s.%s IN (SELECT column2 FROM (%s))", m.primaryTable, m.foreignFK, valuesClause)) - f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", m.primaryTable, m.foreignFK, len(criterion.Value))) - case models.CriterionModifierExcludes: f.addWhere(fmt.Sprintf("%s.%s NOT IN (SELECT column2 FROM (%s)) OR %[1]s.%[2]s IS NULL", m.primaryTable, m.foreignFK, valuesClause)) } } @@ -953,9 +1003,26 @@ type joinedHierarchicalMultiCriterionHandlerBuilder struct { primaryFK string } -func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { +func (m *joinedHierarchicalMultiCriterionHandlerBuilder) addHierarchicalConditionClauses(f *filterBuilder, criterion models.HierarchicalMultiCriterionInput, table, idColumn string) { + if criterion.Modifier == models.CriterionModifierEquals { + // includes only the provided ids + f.addWhere(fmt.Sprintf("%s.%s IS NOT NULL", table, idColumn)) + f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", table, idColumn, len(criterion.Value))) + f.addWhere(utils.StrFormat("(SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.id) = ?", utils.StrFormatMap{ + "joinTable": m.joinTable, + "primaryFK": m.primaryFK, + "primaryTable": m.primaryTable, + }), len(criterion.Value)) + } else { + addHierarchicalConditionClauses(f, criterion, table, idColumn) + } +} + +func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { - if criterion != nil { + if c != nil { + // make a copy so we don't modify the original + criterion := *c joinAlias := m.joinAs if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { @@ -974,25 +1041,59 @@ func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(criterion *mode return } - if len(criterion.Value) == 0 { + // combine excludes if excludes modifier is selected + if criterion.Modifier == models.CriterionModifierExcludes { + criterion.Modifier = models.CriterionModifierIncludesAll + criterion.Excludes = append(criterion.Excludes, criterion.Value...) + criterion.Value = nil + } + + if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { return } - valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) + if len(criterion.Value) > 0 { + valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) - joinTable := utils.StrFormat(`( - SELECT j.*, d.column1 AS root_id, d.column2 AS item_id FROM {joinTable} AS j - INNER JOIN ({valuesClause}) AS d ON j.{foreignFK} = d.column2 -) -`, utils.StrFormatMap{ - "joinTable": m.joinTable, - "foreignFK": m.foreignFK, - "valuesClause": valuesClause, - }) + joinTable := utils.StrFormat(`( + SELECT j.*, d.column1 AS root_id, d.column2 AS item_id FROM {joinTable} AS j + INNER JOIN ({valuesClause}) AS d ON j.{foreignFK} = d.column2 + ) + `, utils.StrFormatMap{ + "joinTable": m.joinTable, + "foreignFK": m.foreignFK, + "valuesClause": valuesClause, + }) + + f.addLeftJoin(joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.id", joinAlias, m.primaryFK, m.primaryTable)) - f.addLeftJoin(joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.id", joinAlias, m.primaryFK, m.primaryTable)) + m.addHierarchicalConditionClauses(f, criterion, joinAlias, "root_id") + } - addHierarchicalConditionClauses(f, criterion, joinAlias, "root_id") + if len(criterion.Excludes) > 0 { + valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) + + joinTable := utils.StrFormat(`( + SELECT j2.*, e.column1 AS root_id, e.column2 AS item_id FROM {joinTable} AS j2 + INNER JOIN ({valuesClause}) AS e ON j2.{foreignFK} = e.column2 + ) + `, utils.StrFormatMap{ + "joinTable": m.joinTable, + "foreignFK": m.foreignFK, + "valuesClause": valuesClause, + }) + + joinAlias2 := joinAlias + "2" + + f.addLeftJoin(joinTable, joinAlias2, fmt.Sprintf("%s.%s = %s.id", joinAlias2, m.primaryFK, m.primaryTable)) + + // modify for exclusion + criterionCopy := criterion + criterionCopy.Modifier = models.CriterionModifierExcludes + criterionCopy.Value = c.Excludes + + m.addHierarchicalConditionClauses(f, criterionCopy, joinAlias2, "root_id") + } } } } diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index de840b28376..5f5291053f4 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -1011,7 +1011,7 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id f.addLeftJoin("performer_tags", "", "performer_tags.gallery_id = galleries.id") - addHierarchicalConditionClauses(f, tags, "performer_tags", "root_tag_id") + addHierarchicalConditionClauses(f, *tags, "performer_tags", "root_tag_id") } } } diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index f22cacf92f1..d42de9f85a7 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -989,7 +989,7 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id f.addLeftJoin("performer_tags", "", "performer_tags.image_id = images.id") - addHierarchicalConditionClauses(f, tags, "performer_tags", "root_tag_id") + addHierarchicalConditionClauses(f, *tags, "performer_tags", "root_tag_id") } } } diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 721a4d456e1..1a735bcd20c 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -1404,7 +1404,7 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id f.addLeftJoin("performer_tags", "", "performer_tags.scene_id = scenes.id") - addHierarchicalConditionClauses(f, tags, "performer_tags", "root_tag_id") + addHierarchicalConditionClauses(f, *tags, "performer_tags", "root_tag_id") } } } diff --git a/pkg/sqlite/scene_marker.go b/pkg/sqlite/scene_marker.go index df3c730302a..c4ae7dda720 100644 --- a/pkg/sqlite/scene_marker.go +++ b/pkg/sqlite/scene_marker.go @@ -221,7 +221,7 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = m.primary_tag_id f.addLeftJoin("marker_tags", "", "marker_tags.scene_marker_id = scene_markers.id") - addHierarchicalConditionClauses(f, tags, "marker_tags", "root_tag_id") + addHierarchicalConditionClauses(f, *tags, "marker_tags", "root_tag_id") } } } @@ -254,7 +254,7 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = st.tag_id f.addLeftJoin("scene_tags", "", "scene_tags.scene_id = scene_markers.scene_id") - addHierarchicalConditionClauses(f, tags, "scene_tags", "root_tag_id") + addHierarchicalConditionClauses(f, *tags, "scene_tags", "root_tag_id") } } } diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index c25f3b2673f..22f7bde1c9c 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -518,7 +518,7 @@ func tagParentsCriterionHandler(qb *tagQueryBuilder, tags *models.HierarchicalMu f.addLeftJoin("parents", "", "parents.item_id = tags.id") - addHierarchicalConditionClauses(f, tags, "parents", "root_id") + addHierarchicalConditionClauses(f, *tags, "parents", "root_id") } } } @@ -567,7 +567,7 @@ func tagChildrenCriterionHandler(qb *tagQueryBuilder, tags *models.HierarchicalM f.addLeftJoin("children", "", "children.item_id = tags.id") - addHierarchicalConditionClauses(f, tags, "children", "root_id") + addHierarchicalConditionClauses(f, *tags, "children", "root_id") } } } diff --git a/ui/v2.5/src/components/List/CriterionEditor.tsx b/ui/v2.5/src/components/List/CriterionEditor.tsx index dd099cacdfb..fdf5bcad7f1 100644 --- a/ui/v2.5/src/components/List/CriterionEditor.tsx +++ b/ui/v2.5/src/components/List/CriterionEditor.tsx @@ -38,6 +38,12 @@ import { RatingFilter } from "./Filters/RatingFilter"; import { BooleanFilter } from "./Filters/BooleanFilter"; import { OptionFilter, OptionListFilter } from "./Filters/OptionFilter"; import { PathFilter } from "./Filters/PathFilter"; +import { PerformersCriterion } from "src/models/list-filter/criteria/performers"; +import PerformersFilter from "./Filters/PerformersFilter"; +import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; +import StudiosFilter from "./Filters/StudiosFilter"; +import { TagsCriterion } from "src/models/list-filter/criteria/tags"; +import TagsFilter from "./Filters/TagsFilter"; import { PhashCriterion } from "src/models/list-filter/criteria/phash"; import { PhashFilter } from "./Filters/PhashFilter"; import cx from "classnames"; @@ -110,6 +116,33 @@ const GenericCriterionEditor: React.FC = ({ return; } + if (criterion instanceof PerformersCriterion) { + return ( + setCriterion(c)} + /> + ); + } + + if (criterion instanceof StudiosCriterion) { + return ( + setCriterion(c)} + /> + ); + } + + if (criterion instanceof TagsCriterion) { + return ( + setCriterion(c)} + /> + ); + } + if (criterion instanceof ILabeledIdCriterion) { return ( void; +} + +function usePerformerQuery(query: string) { + const results = useFindPerformersQuery({ + variables: { + filter: { + q: query, + per_page: 200, + }, + }, + }); + + return ( + results.data?.findPerformers.performers.map((p) => { + return { + id: p.id, + label: p.name, + }; + }) ?? [] + ); +} + +const PerformersFilter: React.FC = ({ + criterion, + setCriterion, +}) => { + return ( + + ); +}; + +export default PerformersFilter; diff --git a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx new file mode 100644 index 00000000000..d14997ef6f1 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx @@ -0,0 +1,342 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { Button, Form } from "react-bootstrap"; +import { Icon } from "src/components/Shared/Icon"; +import { + faCheckCircle, + faMinus, + faPlus, + faTimesCircle, +} from "@fortawesome/free-solid-svg-icons"; +import { faTimesCircle as faTimesCircleRegular } from "@fortawesome/free-regular-svg-icons"; +import { ClearableInput } from "src/components/Shared/ClearableInput"; +import { + IHierarchicalLabelValue, + ILabeledId, + ILabeledValueListValue, +} from "src/models/list-filter/types"; +import { cloneDeep, debounce } from "lodash-es"; +import { + Criterion, + IHierarchicalLabeledIdCriterion, +} from "src/models/list-filter/criteria/criterion"; +import { defineMessages, MessageDescriptor, useIntl } from "react-intl"; +import { CriterionModifier } from "src/core/generated-graphql"; +import { keyboardClickHandler } from "src/utils/keyboard"; + +interface ISelectedItem { + item: ILabeledId; + excluded?: boolean; + onClick: () => void; +} + +const SelectedItem: React.FC = ({ + item, + excluded = false, + onClick, +}) => { + const iconClassName = excluded ? "exclude-icon" : "include-button"; + const spanClassName = excluded + ? "excluded-object-label" + : "selected-object-label"; + const [hovered, setHovered] = useState(false); + + const icon = useMemo(() => { + if (!hovered) { + return excluded ? faTimesCircle : faCheckCircle; + } + + return faTimesCircleRegular; + }, [hovered, excluded]); + + function onMouseOver() { + setHovered(true); + } + + function onMouseOut() { + setHovered(false); + } + + return ( + onClick()} + onKeyDown={keyboardClickHandler(onClick)} + onMouseEnter={() => onMouseOver()} + onMouseLeave={() => onMouseOut()} + onFocus={() => onMouseOver()} + onBlur={() => onMouseOut()} + tabIndex={0} + > +
+ + {item.label} +
+
+
+ ); +}; + +interface ISelectableFilter { + query: string; + setQuery: (query: string) => void; + single: boolean; + includeOnly: boolean; + queryResults: ILabeledId[]; + selected: ILabeledId[]; + excluded: ILabeledId[]; + onSelect: (value: ILabeledId, include: boolean) => void; + onUnselect: (value: ILabeledId) => void; +} + +const SelectableFilter: React.FC = ({ + query, + setQuery, + single, + queryResults, + selected, + excluded, + includeOnly, + onSelect, + onUnselect, +}) => { + const [internalQuery, setInternalQuery] = useState(query); + + const onInputChange = useMemo(() => { + return debounce((input: string) => { + setQuery(input); + }, 250); + }, [setQuery]); + + function onInternalInputChange(input: string) { + setInternalQuery(input); + onInputChange(input); + } + + const objects = useMemo(() => { + return queryResults.filter( + (p) => + selected.find((s) => s.id === p.id) === undefined && + excluded.find((s) => s.id === p.id) === undefined + ); + }, [queryResults, selected, excluded]); + + const includingOnly = includeOnly || (selected.length > 0 && single); + const excludingOnly = excluded.length > 0 && single; + + const includeIcon = ; + const excludeIcon = ; + + return ( + + ); +}; + +interface IObjectsFilter> { + criterion: T; + single?: boolean; + setCriterion: (criterion: T) => void; + queryHook: (query: string) => ILabeledId[]; +} + +export const ObjectsFilter = < + T extends Criterion +>( + props: IObjectsFilter +) => { + const { criterion, setCriterion, queryHook, single = false } = props; + + const [query, setQuery] = useState(""); + + const queryResults = queryHook(query); + + function onSelect(value: ILabeledId, newInclude: boolean) { + let newCriterion: T = cloneDeep(criterion); + + if (newInclude) { + newCriterion.value.items.push(value); + } else { + if (newCriterion.value.excluded) { + newCriterion.value.excluded.push(value); + } else { + newCriterion.value.excluded = [value]; + } + } + + setCriterion(newCriterion); + } + + const onUnselect = useCallback( + (value: ILabeledId) => { + if (!criterion) return; + + let newCriterion: T = cloneDeep(criterion); + + newCriterion.value.items = criterion.value.items.filter( + (v) => v.id !== value.id + ); + newCriterion.value.excluded = criterion.value.excluded.filter( + (v) => v.id !== value.id + ); + + setCriterion(newCriterion); + }, + [criterion, setCriterion] + ); + + const sortedSelected = useMemo(() => { + const ret = criterion.value.items.slice(); + ret.sort((a, b) => a.label.localeCompare(b.label)); + return ret; + }, [criterion]); + + const sortedExcluded = useMemo(() => { + if (!criterion.value.excluded) return []; + const ret = criterion.value.excluded.slice(); + ret.sort((a, b) => a.label.localeCompare(b.label)); + return ret; + }, [criterion]); + + return ( + + ); +}; + +interface IHierarchicalObjectsFilter + extends IObjectsFilter {} + +export const HierarchicalObjectsFilter = < + T extends IHierarchicalLabeledIdCriterion +>( + props: IHierarchicalObjectsFilter +) => { + const intl = useIntl(); + const { criterion, setCriterion } = props; + + const messages = defineMessages({ + studio_depth: { + id: "studio_depth", + defaultMessage: "Levels (empty for all)", + }, + }); + + function onDepthChanged(depth: number) { + let newCriterion: T = cloneDeep(criterion); + newCriterion.value.depth = depth; + setCriterion(newCriterion); + } + + function criterionOptionTypeToIncludeID(): string { + if (criterion.criterionOption.type === "studios") { + return "include-sub-studios"; + } + if (criterion.criterionOption.type === "childTags") { + return "include-parent-tags"; + } + return "include-sub-tags"; + } + + function criterionOptionTypeToIncludeUIString(): MessageDescriptor { + const optionType = + criterion.criterionOption.type === "studios" + ? "include_sub_studios" + : criterion.criterionOption.type === "childTags" + ? "include_parent_tags" + : "include_sub_tags"; + return { + id: optionType, + }; + } + + return ( +
+ + onDepthChanged(criterion.value.depth !== 0 ? 0 : -1)} + /> + + + {criterion.value.depth !== 0 && ( + + + onDepthChanged(e.target.value ? parseInt(e.target.value, 10) : -1) + } + defaultValue={ + criterion.value && criterion.value.depth !== -1 + ? criterion.value.depth + : "" + } + min="1" + /> + + )} + + + ); +}; diff --git a/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx b/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx new file mode 100644 index 00000000000..15d300372b4 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { useFindStudiosQuery } from "src/core/generated-graphql"; +import { HierarchicalObjectsFilter } from "./SelectableFilter"; +import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; + +interface IStudiosFilter { + criterion: StudiosCriterion; + setCriterion: (c: StudiosCriterion) => void; +} + +function useStudioQuery(query: string) { + const results = useFindStudiosQuery({ + variables: { + filter: { + q: query, + per_page: 200, + }, + }, + }); + + return ( + results.data?.findStudios.studios.map((p) => { + return { + id: p.id, + label: p.name, + }; + }) ?? [] + ); +} + +const StudiosFilter: React.FC = ({ + criterion, + setCriterion, +}) => { + return ( + + ); +}; + +export default StudiosFilter; diff --git a/ui/v2.5/src/components/List/Filters/TagsFilter.tsx b/ui/v2.5/src/components/List/Filters/TagsFilter.tsx new file mode 100644 index 00000000000..719bada38f0 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/TagsFilter.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { useFindTagsQuery } from "src/core/generated-graphql"; +import { HierarchicalObjectsFilter } from "./SelectableFilter"; +import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; + +interface ITagsFilter { + criterion: StudiosCriterion; + setCriterion: (c: StudiosCriterion) => void; +} + +function useStudioQuery(query: string) { + const results = useFindTagsQuery({ + variables: { + filter: { + q: query, + per_page: 200, + }, + }, + }); + + return ( + results.data?.findTags.tags.map((p) => { + return { + id: p.id, + label: p.name, + }; + }) ?? [] + ); +} + +const TagsFilter: React.FC = ({ criterion, setCriterion }) => { + return ( + + ); +}; + +export default TagsFilter; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 8b4c678273f..1c6a390f411 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -255,6 +255,107 @@ input[type="range"].zoom-slider { } } +.filter-visible-button { + padding-left: 0.3rem; + padding-right: 0.3rem; + + &:focus:not(.active):not(:hover) { + background: none; + } + + &:focus, + &.active:focus { + box-shadow: none; + } +} + +.selectable-filter ul { + list-style-type: none; + margin-top: 0.5rem; + max-height: 300px; + overflow-y: auto; + // to prevent unnecessary vertical scrollbar + padding-bottom: 0.15rem; + padding-inline-start: 0; + + .unselected-object { + opacity: 0.8; + } + + .selected-object, + .excluded-object, + .unselected-object { + cursor: pointer; + height: 2em; + margin-bottom: 0.25rem; + + a { + align-items: center; + display: flex; + height: 2em; + justify-content: space-between; + outline: none; + + &:hover, + &:focus-visible { + background-color: rgba(138, 155, 168, 0.15); + } + + .selected-object-label, + .excluded-object-label { + font-size: 16px; + } + } + + .include-button { + color: $success; + } + + .exclude-icon { + color: $danger; + } + + .exclude-button { + align-items: center; + display: flex; + margin-left: 0.25rem; + padding-left: 0.25rem; + padding-right: 0.25rem; + + .exclude-button-text { + color: $danger; + display: none; + font-size: 12px; + font-weight: 600; + } + + &:hover { + background-color: inherit; + } + + &:hover .exclude-button-text, + &:focus .exclude-button-text { + display: inline; + } + } + + .object-count { + color: $text-muted; + font-size: 12px; + } + } + + .selected-object:hover, + .selected-object a:focus-visible, + .excluded-object:hover, + .excluded-object a:focus-visible { + .include-button, + .exclude-icon { + color: #fff; + } + } +} + .tilted { transform: rotate(45deg); } diff --git a/ui/v2.5/src/components/Shared/ClearableInput.tsx b/ui/v2.5/src/components/Shared/ClearableInput.tsx new file mode 100644 index 00000000000..4275b8ee8fe --- /dev/null +++ b/ui/v2.5/src/components/Shared/ClearableInput.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { Button, FormControl } from "react-bootstrap"; +import { faTimes } from "@fortawesome/free-solid-svg-icons"; +import { useIntl } from "react-intl"; +import { Icon } from "./Icon"; +import useFocus from "src/utils/focus"; + +interface IClearableInput { + value: string; + setValue: (value: string) => void; +} + +export const ClearableInput: React.FC = ({ + value, + setValue, +}) => { + const intl = useIntl(); + + const [queryRef, setQueryFocus] = useFocus(); + const queryClearShowing = !!value; + + function onChangeQuery(event: React.FormEvent) { + setValue(event.currentTarget.value); + } + + function onClearQuery() { + setValue(""); + setQueryFocus(); + } + + return ( +
+ + {queryClearShowing && ( + + )} +
+ ); +}; + +export default ClearableInput; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 067f8cf4bc8..4d166878f0a 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -414,3 +414,30 @@ div.react-datepicker { #date-picker-portal .react-datepicker-popper { z-index: 1600; } + +.clearable-input-group { + align-items: stretch; + display: flex; + flex-wrap: wrap; + position: relative; +} + +.clearable-text-field, +.clearable-text-field:active, +.clearable-text-field:focus { + background-color: #394b59; + border: 0; + border-color: #394b59; + color: #fff; +} + +.clearable-text-field-clear { + background-color: #394b59; + color: #bfccd6; + font-size: 0.875rem; + margin: 0.375rem 0.75rem; + padding: 0; + position: absolute; + right: 0; + z-index: 4; +} diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx index 396b3e79093..e13c8b2ecf9 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx @@ -16,6 +16,7 @@ export const StudioPerformersPanel: React.FC = ({ const studioCriterion = new StudiosCriterion(); studioCriterion.value = { items: [{ id: studio.id!, label: studio.name || `Studio ${studio.id}` }], + excluded: [], depth: 0, }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx index 37d33ea2cdf..0713f13d524 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx @@ -43,6 +43,7 @@ export const TagMarkersPanel: React.FC = ({ tagCriterion = new TagsCriterion(TagsCriterionOption); tagCriterion.value = { items: [tagValue], + excluded: [], depth: 0, }; filter.criteria.push(tagCriterion); diff --git a/ui/v2.5/src/core/performers.ts b/ui/v2.5/src/core/performers.ts index e13ac88859c..597a0be5492 100644 --- a/ui/v2.5/src/core/performers.ts +++ b/ui/v2.5/src/core/performers.ts @@ -22,21 +22,21 @@ export const usePerformerFilterHook = ( ) { // add the performer if not present if ( - !performerCriterion.value.find((p) => { + !performerCriterion.value.items.find((p) => { return p.id === performer.id; }) ) { - performerCriterion.value.push(performerValue); + performerCriterion.value.items.push(performerValue); } } else { // overwrite - performerCriterion.value = [performerValue]; + performerCriterion.value.items = [performerValue]; } performerCriterion.modifier = GQL.CriterionModifier.IncludesAll; } else { performerCriterion = new PerformersCriterion(); - performerCriterion.value = [performerValue]; + performerCriterion.value.items = [performerValue]; performerCriterion.modifier = GQL.CriterionModifier.IncludesAll; filter.criteria.push(performerCriterion); } diff --git a/ui/v2.5/src/core/studios.ts b/ui/v2.5/src/core/studios.ts index ef93f191c17..95649c1992e 100644 --- a/ui/v2.5/src/core/studios.ts +++ b/ui/v2.5/src/core/studios.ts @@ -22,6 +22,7 @@ export const useStudioFilterHook = (studio: GQL.StudioDataFragment) => { studioCriterion = new StudiosCriterion(); studioCriterion.value = { items: [studioValue], + excluded: [], depth: (config?.configuration?.ui as IUIConfig)?.showChildStudioContent ? -1 : 0, diff --git a/ui/v2.5/src/core/tags.ts b/ui/v2.5/src/core/tags.ts index 3ec042c840f..d4f6fc1bfaf 100644 --- a/ui/v2.5/src/core/tags.ts +++ b/ui/v2.5/src/core/tags.ts @@ -42,6 +42,7 @@ export const useTagFilterHook = (tag: GQL.TagDataFragment) => { tagCriterion = new TagsCriterion(TagsCriterionOption); tagCriterion.value = { items: [tagValue], + excluded: [], depth: (config?.configuration?.ui as IUIConfig)?.showChildTagContent ? -1 : 0, diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index d314780e505..d0727867650 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -726,6 +726,7 @@ "equals": "is", "excludes": "excludes", "format_string": "{criterion} {modifierString} {valueString}", + "format_string_excludes": "{criterion} {modifierString} {valueString} (excludes {excludedString})", "greater_than": "is greater than", "includes": "includes", "includes_all": "includes all", diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index 642fe733601..fdf12995b04 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -22,6 +22,7 @@ import { IStashIDValue, IDateValue, ITimestampValue, + ILabeledValueListValue, IPhashDistanceValue, } from "../types"; @@ -31,6 +32,7 @@ export type CriterionValue = | string[] | ILabeledId[] | IHierarchicalLabelValue + | ILabeledValueListValue | INumberValue | IStashIDValue | IDateValue @@ -138,6 +140,10 @@ export abstract class Criterion { return JSON.stringify(encodedCriterion); } + public setValueFromQueryString(v: V) { + this.value = v; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any public apply(outputFilter: Record) { // eslint-disable-next-line no-param-reassign @@ -531,11 +537,21 @@ export class ILabeledIdCriterion extends Criterion { } export class IHierarchicalLabeledIdCriterion extends Criterion { - protected toCriterionInput(): HierarchicalMultiCriterionInput { - return { - value: (this.value.items ?? []).map((v) => v.id), - modifier: this.modifier, - depth: this.value.depth, + constructor(type: CriterionOption) { + const value: IHierarchicalLabelValue = { + items: [], + excluded: [], + depth: 0, + }; + + super(type, value); + } + + public setValueFromQueryString(v: IHierarchicalLabelValue) { + this.value = { + items: v.items || [], + excluded: v.excluded || [], + depth: v.depth || 0, }; } @@ -549,24 +565,62 @@ export class IHierarchicalLabeledIdCriterion extends Criterion 0 ? this.value.depth : "all"})`; } + protected toCriterionInput(): HierarchicalMultiCriterionInput { + let excludes: string[] = []; + if (this.value.excluded) { + excludes = this.value.excluded.map((v) => v.id); + } + return { + value: this.value.items.map((v) => v.id), + excludes: excludes, + modifier: this.modifier, + depth: this.value.depth, + }; + } + public isValid(): boolean { if ( this.modifier === CriterionModifier.IsNull || - this.modifier === CriterionModifier.NotNull + this.modifier === CriterionModifier.NotNull || + this.modifier === CriterionModifier.Equals ) { return true; } - return this.value.items.length > 0; + return ( + this.value.items.length > 0 || + (this.value.excluded && this.value.excluded.length > 0) + ); } - constructor(type: CriterionOption) { - const value: IHierarchicalLabelValue = { - items: [], - depth: 0, - }; + public getLabel(intl: IntlShape): string { + const modifierString = Criterion.getModifierLabel(intl, this.modifier); + let valueString = ""; - super(type, value); + if ( + this.modifier !== CriterionModifier.IsNull && + this.modifier !== CriterionModifier.NotNull + ) { + valueString = this.value.items.map((v) => v.label).join(", "); + } + + let id = "criterion_modifier.format_string"; + let excludedString = ""; + + if (this.value.excluded && this.value.excluded.length > 0) { + id = "criterion_modifier.format_string_excludes"; + excludedString = this.value.excluded.map((v) => v.label).join(", "); + } + + return intl.formatMessage( + { id }, + { + criterion: intl.formatMessage({ id: this.criterionOption.messageID }), + modifierString, + valueString, + excludedString, + } + ); } } diff --git a/ui/v2.5/src/models/list-filter/criteria/performers.ts b/ui/v2.5/src/models/list-filter/criteria/performers.ts index 7b177d9399f..ef7fba0cb85 100644 --- a/ui/v2.5/src/models/list-filter/criteria/performers.ts +++ b/ui/v2.5/src/models/list-filter/criteria/performers.ts @@ -1,14 +1,104 @@ -import { ILabeledIdCriterion, ILabeledIdCriterionOption } from "./criterion"; +/* eslint @typescript-eslint/no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ +import { IntlShape } from "react-intl"; +import { + CriterionModifier, + MultiCriterionInput, +} from "src/core/generated-graphql"; +import { ILabeledId, ILabeledValueListValue } from "../types"; +import { Criterion, CriterionOption } from "./criterion"; -export const PerformersCriterionOption = new ILabeledIdCriterionOption( - "performers", - "performers", - "performers", - true -); +const modifierOptions = [ + CriterionModifier.IncludesAll, + CriterionModifier.Includes, + CriterionModifier.Equals, +]; -export class PerformersCriterion extends ILabeledIdCriterion { +const defaultModifier = CriterionModifier.IncludesAll; + +export const PerformersCriterionOption = new CriterionOption({ + messageID: "performers", + type: "performers", + parameterName: "performers", + modifierOptions, + defaultModifier, +}); + +export class PerformersCriterion extends Criterion { constructor() { - super(PerformersCriterionOption); + super(PerformersCriterionOption, { items: [], excluded: [] }); + } + + public setValueFromQueryString(v: ILabeledId[] | ILabeledValueListValue) { + // #3619 - the format of performer value was changed from an array + // to an object. Check for both formats. + if (Array.isArray(v)) { + this.value = { items: v, excluded: [] }; + } else { + this.value = { + items: v.items || [], + excluded: v.excluded || [], + }; + } + } + + public getLabelValue(_intl: IntlShape): string { + return this.value.items.map((v) => v.label).join(", "); + } + + protected toCriterionInput(): MultiCriterionInput { + let excludes: string[] = []; + if (this.value.excluded) { + excludes = this.value.excluded.map((v) => v.id); + } + return { + value: this.value.items.map((v) => v.id), + excludes: excludes, + modifier: this.modifier, + }; + } + + public isValid(): boolean { + if ( + this.modifier === CriterionModifier.IsNull || + this.modifier === CriterionModifier.NotNull || + this.modifier === CriterionModifier.Equals + ) { + return true; + } + + return ( + this.value.items.length > 0 || + (this.value.excluded && this.value.excluded.length > 0) + ); + } + + public getLabel(intl: IntlShape): string { + const modifierString = Criterion.getModifierLabel(intl, this.modifier); + let valueString = ""; + + if ( + this.modifier !== CriterionModifier.IsNull && + this.modifier !== CriterionModifier.NotNull + ) { + valueString = this.value.items.map((v) => v.label).join(", "); + } + + let id = "criterion_modifier.format_string"; + let excludedString = ""; + + if (this.value.excluded && this.value.excluded.length > 0) { + id = "criterion_modifier.format_string_excludes"; + excludedString = this.value.excluded.map((v) => v.label).join(", "); + } + + return intl.formatMessage( + { id }, + { + criterion: intl.formatMessage({ id: this.criterionOption.messageID }), + modifierString, + valueString, + excludedString, + } + ); } } diff --git a/ui/v2.5/src/models/list-filter/criteria/studios.ts b/ui/v2.5/src/models/list-filter/criteria/studios.ts index 455921543bf..a78e962006b 100644 --- a/ui/v2.5/src/models/list-filter/criteria/studios.ts +++ b/ui/v2.5/src/models/list-filter/criteria/studios.ts @@ -1,15 +1,22 @@ +import { CriterionModifier } from "src/core/generated-graphql"; import { + CriterionOption, IHierarchicalLabeledIdCriterion, ILabeledIdCriterion, ILabeledIdCriterionOption, } from "./criterion"; -export const StudiosCriterionOption = new ILabeledIdCriterionOption( - "studios", - "studios", - "studios", - false -); +const modifierOptions = [CriterionModifier.Includes]; + +const defaultModifier = CriterionModifier.Includes; + +export const StudiosCriterionOption = new CriterionOption({ + messageID: "studios", + type: "studios", + parameterName: "studios", + modifierOptions, + defaultModifier, +}); export class StudiosCriterion extends IHierarchicalLabeledIdCriterion { constructor() { diff --git a/ui/v2.5/src/models/list-filter/criteria/tags.ts b/ui/v2.5/src/models/list-filter/criteria/tags.ts index f3470beeec9..7266fcf3d5e 100644 --- a/ui/v2.5/src/models/list-filter/criteria/tags.ts +++ b/ui/v2.5/src/models/list-filter/criteria/tags.ts @@ -1,37 +1,51 @@ -import { - IHierarchicalLabeledIdCriterion, - ILabeledIdCriterionOption, -} from "./criterion"; +import { CriterionModifier } from "src/core/generated-graphql"; +import { CriterionType } from "../types"; +import { CriterionOption, IHierarchicalLabeledIdCriterion } from "./criterion"; export class TagsCriterion extends IHierarchicalLabeledIdCriterion {} -export const TagsCriterionOption = new ILabeledIdCriterionOption( - "tags", +class tagsCriterionOption extends CriterionOption { + constructor(messageID: string, value: CriterionType, parameterName: string) { + const modifierOptions = [ + CriterionModifier.Includes, + CriterionModifier.IncludesAll, + CriterionModifier.Equals, + ]; + + let defaultModifier = CriterionModifier.IncludesAll; + + super({ + messageID, + type: value, + parameterName, + modifierOptions, + defaultModifier, + }); + } +} + +export const TagsCriterionOption = new tagsCriterionOption( "tags", "tags", - true + "tags" ); -export const SceneTagsCriterionOption = new ILabeledIdCriterionOption( +export const SceneTagsCriterionOption = new tagsCriterionOption( "sceneTags", "sceneTags", - "scene_tags", - true + "scene_tags" ); -export const PerformerTagsCriterionOption = new ILabeledIdCriterionOption( +export const PerformerTagsCriterionOption = new tagsCriterionOption( "performerTags", "performerTags", - "performer_tags", - true + "performer_tags" ); -export const ParentTagsCriterionOption = new ILabeledIdCriterionOption( +export const ParentTagsCriterionOption = new tagsCriterionOption( "parent_tags", "parentTags", - "parents", - true + "parents" ); -export const ChildTagsCriterionOption = new ILabeledIdCriterionOption( +export const ChildTagsCriterionOption = new tagsCriterionOption( "sub_tags", "childTags", - "children", - true + "children" ); diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index 726c83b6fa5..def9ac6698f 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -131,7 +131,7 @@ export class ListFilterModel { // it's possible that we have unsupported criteria. Just skip if so. if (criterion) { if (encodedCriterion.value !== undefined) { - criterion.value = encodedCriterion.value; + criterion.setValueFromQueryString(encodedCriterion.value); } criterion.modifier = encodedCriterion.modifier; this.criteria.push(criterion); diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 548adc59f02..79731eb3bfe 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -18,8 +18,14 @@ export interface ILabeledValue { value: string; } +export interface ILabeledValueListValue { + items: ILabeledId[]; + excluded: ILabeledId[]; +} + export interface IHierarchicalLabelValue { items: ILabeledId[]; + excluded: ILabeledId[]; depth: number; } diff --git a/ui/v2.5/src/utils/keyboard.ts b/ui/v2.5/src/utils/keyboard.ts new file mode 100644 index 00000000000..1f02c55bce5 --- /dev/null +++ b/ui/v2.5/src/utils/keyboard.ts @@ -0,0 +1,9 @@ +export function keyboardClickHandler(onClick: () => void) { + function onKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter" || e.key === " ") { + onClick(); + } + } + + return onKeyDown; +} diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index a1ba4cf3391..7693ddb6928 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -38,12 +38,12 @@ const makePerformerScenesUrl = ( if (!performer.id) return "#"; const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined); const criterion = new PerformersCriterion(); - criterion.value = [ + criterion.value.items = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, ]; if (extraPerformer) { - criterion.value.push(extraPerformer); + criterion.value.items.push(extraPerformer); } filter.criteria.push(criterion); @@ -59,12 +59,12 @@ const makePerformerImagesUrl = ( if (!performer.id) return "#"; const filter = new ListFilterModel(GQL.FilterMode.Images, undefined); const criterion = new PerformersCriterion(); - criterion.value = [ + criterion.value.items = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, ]; if (extraPerformer) { - criterion.value.push(extraPerformer); + criterion.value.items.push(extraPerformer); } filter.criteria.push(criterion); @@ -80,12 +80,12 @@ const makePerformerGalleriesUrl = ( if (!performer.id) return "#"; const filter = new ListFilterModel(GQL.FilterMode.Galleries, undefined); const criterion = new PerformersCriterion(); - criterion.value = [ + criterion.value.items = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, ]; if (extraPerformer) { - criterion.value.push(extraPerformer); + criterion.value.items.push(extraPerformer); } filter.criteria.push(criterion); @@ -101,12 +101,12 @@ const makePerformerMoviesUrl = ( if (!performer.id) return "#"; const filter = new ListFilterModel(GQL.FilterMode.Movies, undefined); const criterion = new PerformersCriterion(); - criterion.value = [ + criterion.value.items = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, ]; if (extraPerformer) { - criterion.value.push(extraPerformer); + criterion.value.items.push(extraPerformer); } filter.criteria.push(criterion); @@ -131,6 +131,7 @@ const makeStudioScenesUrl = (studio: Partial) => { const criterion = new StudiosCriterion(); criterion.value = { items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -143,6 +144,7 @@ const makeStudioImagesUrl = (studio: Partial) => { const criterion = new StudiosCriterion(); criterion.value = { items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -155,6 +157,7 @@ const makeStudioGalleriesUrl = (studio: Partial) => { const criterion = new StudiosCriterion(); criterion.value = { items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -167,6 +170,7 @@ const makeStudioMoviesUrl = (studio: Partial) => { const criterion = new StudiosCriterion(); criterion.value = { items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -179,6 +183,7 @@ const makeStudioPerformersUrl = (studio: Partial) => { const criterion = new StudiosCriterion(); criterion.value = { items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -218,6 +223,7 @@ const makeParentTagsUrl = (tag: Partial) => { label: tag.name || `Tag ${tag.id}`, }, ], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -235,6 +241,7 @@ const makeChildTagsUrl = (tag: Partial) => { label: tag.name || `Tag ${tag.id}`, }, ], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -247,6 +254,7 @@ const makeTagScenesUrl = (tag: Partial) => { const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = { items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -259,6 +267,7 @@ const makeTagPerformersUrl = (tag: Partial) => { const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = { items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -271,6 +280,7 @@ const makeTagSceneMarkersUrl = (tag: Partial) => { const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = { items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -283,6 +293,7 @@ const makeTagGalleriesUrl = (tag: Partial) => { const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = { items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -295,6 +306,7 @@ const makeTagImagesUrl = (tag: Partial) => { const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = { items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); From cc9ded05a3a241692c3ac0877f6c1b3a3ad49bb3 Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Fri, 26 May 2023 01:49:00 +0200 Subject: [PATCH 039/135] Error logging improvements (#3768) * Improve auto-tag error messages * Ignore another context canceled error * Ignore more graphql context canceled errors --- internal/api/error.go | 38 +++--- internal/manager/task_autotag.go | 199 +++++++++++++++++------------- pkg/scraper/stashbox/stash_box.go | 2 +- 3 files changed, 136 insertions(+), 103 deletions(-) diff --git a/internal/api/error.go b/internal/api/error.go index 208b2521cc2..5b30a8c12a9 100644 --- a/internal/api/error.go +++ b/internal/api/error.go @@ -11,27 +11,29 @@ import ( ) func gqlErrorHandler(ctx context.Context, e error) *gqlerror.Error { - // log all errors - for now just log the error message - // we can potentially add more context later - fc := graphql.GetFieldContext(ctx) - if fc != nil && !errors.Is(e, context.Canceled) { - logger.Errorf("%s: %v", fc.Path(), e) + if !errors.Is(ctx.Err(), context.Canceled) { + // log all errors - for now just log the error message + // we can potentially add more context later + fc := graphql.GetFieldContext(ctx) + if fc != nil { + logger.Errorf("%s: %v", fc.Path(), e) - // log the args in debug level - logger.DebugFunc(func() (string, []interface{}) { - var args interface{} - args = fc.Args + // log the args in debug level + logger.DebugFunc(func() (string, []interface{}) { + var args interface{} + args = fc.Args - s, _ := json.Marshal(args) - if len(s) > 0 { - args = string(s) - } + s, _ := json.Marshal(args) + if len(s) > 0 { + args = string(s) + } - return "%s: %v", []interface{}{ - fc.Path(), - args, - } - }) + return "%s: %v", []interface{}{ + fc.Path(), + args, + } + }) + } } // we may also want to transform the error message for the response diff --git a/internal/manager/task_autotag.go b/internal/manager/task_autotag.go index 0dfe59dd37e..273e65f2894 100644 --- a/internal/manager/task_autotag.go +++ b/internal/manager/task_autotag.go @@ -37,7 +37,7 @@ func (j *autoTagJob) Execute(ctx context.Context, progress *job.Progress) { j.autoTagSpecific(ctx, progress) } - logger.Infof("Finished autotag after %s", time.Since(begin).String()) + logger.Infof("Finished auto-tag after %s", time.Since(begin).String()) } func (j *autoTagJob) isFileBasedAutoTag(input AutoTagMetadataInput) bool { @@ -84,32 +84,34 @@ func (j *autoTagJob) autoTagSpecific(ctx context.Context, progress *job.Progress if performerCount == 1 && performerIds[0] == wildcard { performerCount, err = performerQuery.Count(ctx) if err != nil { - return fmt.Errorf("error getting performer count: %v", err) + return fmt.Errorf("getting performer count: %v", err) } } if studioCount == 1 && studioIds[0] == wildcard { studioCount, err = studioQuery.Count(ctx) if err != nil { - return fmt.Errorf("error getting studio count: %v", err) + return fmt.Errorf("getting studio count: %v", err) } } if tagCount == 1 && tagIds[0] == wildcard { tagCount, err = tagQuery.Count(ctx) if err != nil { - return fmt.Errorf("error getting tag count: %v", err) + return fmt.Errorf("getting tag count: %v", err) } } return nil }); err != nil { - logger.Error(err.Error()) + if !job.IsCancelled(ctx) { + logger.Errorf("auto-tag error: %v", err) + } return } total := performerCount + studioCount + tagCount progress.SetTotal(total) - logger.Infof("Starting autotag of %d performers, %d studios, %d tags", performerCount, studioCount, tagCount) + logger.Infof("Starting auto-tag of %d performers, %d studios, %d tags", performerCount, studioCount, tagCount) j.autoTagPerformers(ctx, progress, input.Paths, performerIds) j.autoTagStudios(ctx, progress, input.Paths, studioIds) @@ -142,7 +144,7 @@ func (j *autoTagJob) autoTagPerformers(ctx context.Context, progress *job.Progre PerPage: &perPage, }) if err != nil { - return fmt.Errorf("error querying performers: %w", err) + return fmt.Errorf("querying performers: %w", err) } } else { performerIdInt, err := strconv.Atoi(performerId) @@ -167,11 +169,10 @@ func (j *autoTagJob) autoTagPerformers(ctx context.Context, progress *job.Progre for _, performer := range performers { if job.IsCancelled(ctx) { - logger.Info("Stopping due to user request") return nil } - if err := func() error { + err := func() error { r := j.txnManager if err := tagger.PerformerScenes(ctx, performer, paths, r.Scene); err != nil { return fmt.Errorf("processing scenes: %w", err) @@ -184,8 +185,14 @@ func (j *autoTagJob) autoTagPerformers(ctx context.Context, progress *job.Progre } return nil - }(); err != nil { - return fmt.Errorf("error auto-tagging performer '%s': %s", performer.Name, err.Error()) + }() + + if job.IsCancelled(ctx) { + return nil + } + + if err != nil { + return fmt.Errorf("tagging performer '%s': %s", performer.Name, err.Error()) } progress.Increment() @@ -193,8 +200,12 @@ func (j *autoTagJob) autoTagPerformers(ctx context.Context, progress *job.Progre return nil }); err != nil { - logger.Error(err.Error()) - continue + logger.Errorf("auto-tag error: %v", err) + } + + if job.IsCancelled(ctx) { + logger.Info("Stopping performer auto-tag due to user request") + return } } } @@ -225,17 +236,17 @@ func (j *autoTagJob) autoTagStudios(ctx context.Context, progress *job.Progress, PerPage: &perPage, }) if err != nil { - return fmt.Errorf("error querying studios: %v", err) + return fmt.Errorf("querying studios: %v", err) } } else { studioIdInt, err := strconv.Atoi(studioId) if err != nil { - return fmt.Errorf("error parsing studio id %s: %s", studioId, err.Error()) + return fmt.Errorf("parsing studio id %s: %s", studioId, err.Error()) } studio, err := studioQuery.Find(ctx, studioIdInt) if err != nil { - return fmt.Errorf("error finding studio id %s: %s", studioId, err.Error()) + return fmt.Errorf("finding studio id %s: %s", studioId, err.Error()) } if studio == nil { @@ -247,11 +258,10 @@ func (j *autoTagJob) autoTagStudios(ctx context.Context, progress *job.Progress, for _, studio := range studios { if job.IsCancelled(ctx) { - logger.Info("Stopping due to user request") return nil } - if err := func() error { + err := func() error { aliases, err := r.Studio.GetAliases(ctx, studio.ID) if err != nil { return fmt.Errorf("getting studio aliases: %w", err) @@ -268,8 +278,14 @@ func (j *autoTagJob) autoTagStudios(ctx context.Context, progress *job.Progress, } return nil - }(); err != nil { - return fmt.Errorf("error auto-tagging studio '%s': %s", studio.Name.String, err.Error()) + }() + + if job.IsCancelled(ctx) { + return nil + } + + if err != nil { + return fmt.Errorf("tagging studio '%s': %s", studio.Name.String, err.Error()) } progress.Increment() @@ -277,8 +293,12 @@ func (j *autoTagJob) autoTagStudios(ctx context.Context, progress *job.Progress, return nil }); err != nil { - logger.Error(err.Error()) - continue + logger.Errorf("auto-tag error: %v", err) + } + + if job.IsCancelled(ctx) { + logger.Info("Stopping studio auto-tag due to user request") + return } } } @@ -308,28 +328,27 @@ func (j *autoTagJob) autoTagTags(ctx context.Context, progress *job.Progress, pa PerPage: &perPage, }) if err != nil { - return fmt.Errorf("error querying tags: %v", err) + return fmt.Errorf("querying tags: %v", err) } } else { tagIdInt, err := strconv.Atoi(tagId) if err != nil { - return fmt.Errorf("error parsing tag id %s: %s", tagId, err.Error()) + return fmt.Errorf("parsing tag id %s: %s", tagId, err.Error()) } tag, err := tagQuery.Find(ctx, tagIdInt) if err != nil { - return fmt.Errorf("error finding tag id %s: %s", tagId, err.Error()) + return fmt.Errorf("finding tag id %s: %s", tagId, err.Error()) } tags = append(tags, tag) } for _, tag := range tags { if job.IsCancelled(ctx) { - logger.Info("Stopping due to user request") return nil } - if err := func() error { + err := func() error { aliases, err := r.Tag.GetAliases(ctx, tag.ID) if err != nil { return fmt.Errorf("getting tag aliases: %w", err) @@ -346,8 +365,14 @@ func (j *autoTagJob) autoTagTags(ctx context.Context, progress *job.Progress, pa } return nil - }(); err != nil { - return fmt.Errorf("error auto-tagging tag '%s': %s", tag.Name, err.Error()) + }() + + if job.IsCancelled(ctx) { + return nil + } + + if err != nil { + return fmt.Errorf("tagging tag '%s': %s", tag.Name, err.Error()) } progress.Increment() @@ -355,8 +380,12 @@ func (j *autoTagJob) autoTagTags(ctx context.Context, progress *job.Progress, pa return nil }); err != nil { - logger.Error(err.Error()) - continue + logger.Errorf("auto-tag error: %v", err) + } + + if job.IsCancelled(ctx) { + logger.Info("Stopping tag auto-tag due to user request") + return } } } @@ -488,11 +517,13 @@ func (t *autoTagFilesTask) getCount(ctx context.Context, r Repository) (int, err return sceneCount + imageCount + galleryCount, nil } -func (t *autoTagFilesTask) processScenes(ctx context.Context, r Repository) error { +func (t *autoTagFilesTask) processScenes(ctx context.Context, r Repository) { if job.IsCancelled(ctx) { - return nil + return } + logger.Info("Auto-tagging scenes...") + batchSize := 1000 findFilter := models.BatchFindFilter(batchSize) @@ -506,12 +537,16 @@ func (t *autoTagFilesTask) processScenes(ctx context.Context, r Repository) erro scenes, err = scene.Query(ctx, r.Scene, sceneFilter, findFilter) return err }); err != nil { - return fmt.Errorf("querying scenes: %w", err) + if !job.IsCancelled(ctx) { + logger.Errorf("error querying scenes for auto-tag: %w", err) + } + return } for _, ss := range scenes { if job.IsCancelled(ctx) { - return nil + logger.Info("Stopping auto-tag due to user request") + return } tt := autoTagSceneTask{ @@ -541,15 +576,15 @@ func (t *autoTagFilesTask) processScenes(ctx context.Context, r Repository) erro } } } - - return nil } -func (t *autoTagFilesTask) processImages(ctx context.Context, r Repository) error { +func (t *autoTagFilesTask) processImages(ctx context.Context, r Repository) { if job.IsCancelled(ctx) { - return nil + return } + logger.Info("Auto-tagging images...") + batchSize := 1000 findFilter := models.BatchFindFilter(batchSize) @@ -563,12 +598,16 @@ func (t *autoTagFilesTask) processImages(ctx context.Context, r Repository) erro images, err = image.Query(ctx, r.Image, imageFilter, findFilter) return err }); err != nil { - return fmt.Errorf("querying images: %w", err) + if !job.IsCancelled(ctx) { + logger.Errorf("error querying images for auto-tag: %w", err) + } + return } for _, ss := range images { if job.IsCancelled(ctx) { - return nil + logger.Info("Stopping auto-tag due to user request") + return } tt := autoTagImageTask{ @@ -598,15 +637,15 @@ func (t *autoTagFilesTask) processImages(ctx context.Context, r Repository) erro } } } - - return nil } -func (t *autoTagFilesTask) processGalleries(ctx context.Context, r Repository) error { +func (t *autoTagFilesTask) processGalleries(ctx context.Context, r Repository) { if job.IsCancelled(ctx) { - return nil + return } + logger.Info("Auto-tagging galleries...") + batchSize := 1000 findFilter := models.BatchFindFilter(batchSize) @@ -620,12 +659,16 @@ func (t *autoTagFilesTask) processGalleries(ctx context.Context, r Repository) e galleries, _, err = r.Gallery.Query(ctx, galleryFilter, findFilter) return err }); err != nil { - return fmt.Errorf("querying galleries: %w", err) + if !job.IsCancelled(ctx) { + logger.Errorf("error querying galleries for auto-tag: %w", err) + } + return } for _, ss := range galleries { if job.IsCancelled(ctx) { - return nil + logger.Info("Stopping auto-tag due to user request") + return } tt := autoTagGalleryTask{ @@ -655,8 +698,6 @@ func (t *autoTagFilesTask) processGalleries(ctx context.Context, r Repository) e } } } - - return nil } func (t *autoTagFilesTask) process(ctx context.Context) { @@ -668,35 +709,19 @@ func (t *autoTagFilesTask) process(ctx context.Context) { } t.progress.SetTotal(total) - logger.Infof("Starting autotag of %d files", total) + logger.Infof("Starting auto-tag of %d files", total) return nil }); err != nil { - logger.Errorf("error getting count for autotag task: %v", err) - return - } - - logger.Info("Autotagging scenes...") - if err := t.processScenes(ctx, r); err != nil { - logger.Errorf("error processing scenes: %w", err) - return - } - - logger.Info("Autotagging images...") - if err := t.processImages(ctx, r); err != nil { - logger.Errorf("error processing images: %w", err) - return - } - - logger.Info("Autotagging galleries...") - if err := t.processGalleries(ctx, r); err != nil { - logger.Errorf("error processing galleries: %w", err) + if !job.IsCancelled(ctx) { + logger.Errorf("error getting file count for auto-tag task: %v", err) + } return } - if job.IsCancelled(ctx) { - logger.Info("Stopping due to user request") - } + t.processScenes(ctx, r) + t.processImages(ctx, r) + t.processGalleries(ctx, r) } type autoTagSceneTask struct { @@ -721,23 +746,25 @@ func (t *autoTagSceneTask) Start(ctx context.Context, wg *sync.WaitGroup) { if t.performers { if err := autotag.ScenePerformers(ctx, t.scene, r.Scene, r.Performer, t.cache); err != nil { - return fmt.Errorf("error tagging scene performers for %s: %v", t.scene.DisplayName(), err) + return fmt.Errorf("tagging scene performers for %s: %v", t.scene.DisplayName(), err) } } if t.studios { if err := autotag.SceneStudios(ctx, t.scene, r.Scene, r.Studio, t.cache); err != nil { - return fmt.Errorf("error tagging scene studio for %s: %v", t.scene.DisplayName(), err) + return fmt.Errorf("tagging scene studio for %s: %v", t.scene.DisplayName(), err) } } if t.tags { if err := autotag.SceneTags(ctx, t.scene, r.Scene, r.Tag, t.cache); err != nil { - return fmt.Errorf("error tagging scene tags for %s: %v", t.scene.DisplayName(), err) + return fmt.Errorf("tagging scene tags for %s: %v", t.scene.DisplayName(), err) } } return nil }); err != nil { - logger.Error(err.Error()) + if !job.IsCancelled(ctx) { + logger.Errorf("auto-tag error: %v", err) + } } } @@ -758,23 +785,25 @@ func (t *autoTagImageTask) Start(ctx context.Context, wg *sync.WaitGroup) { if err := t.txnManager.WithTxn(ctx, func(ctx context.Context) error { if t.performers { if err := autotag.ImagePerformers(ctx, t.image, r.Image, r.Performer, t.cache); err != nil { - return fmt.Errorf("error tagging image performers for %s: %v", t.image.DisplayName(), err) + return fmt.Errorf("tagging image performers for %s: %v", t.image.DisplayName(), err) } } if t.studios { if err := autotag.ImageStudios(ctx, t.image, r.Image, r.Studio, t.cache); err != nil { - return fmt.Errorf("error tagging image studio for %s: %v", t.image.DisplayName(), err) + return fmt.Errorf("tagging image studio for %s: %v", t.image.DisplayName(), err) } } if t.tags { if err := autotag.ImageTags(ctx, t.image, r.Image, r.Tag, t.cache); err != nil { - return fmt.Errorf("error tagging image tags for %s: %v", t.image.DisplayName(), err) + return fmt.Errorf("tagging image tags for %s: %v", t.image.DisplayName(), err) } } return nil }); err != nil { - logger.Error(err.Error()) + if !job.IsCancelled(ctx) { + logger.Errorf("auto-tag error: %v", err) + } } } @@ -795,22 +824,24 @@ func (t *autoTagGalleryTask) Start(ctx context.Context, wg *sync.WaitGroup) { if err := t.txnManager.WithTxn(ctx, func(ctx context.Context) error { if t.performers { if err := autotag.GalleryPerformers(ctx, t.gallery, r.Gallery, r.Performer, t.cache); err != nil { - return fmt.Errorf("error tagging gallery performers for %s: %v", t.gallery.DisplayName(), err) + return fmt.Errorf("tagging gallery performers for %s: %v", t.gallery.DisplayName(), err) } } if t.studios { if err := autotag.GalleryStudios(ctx, t.gallery, r.Gallery, r.Studio, t.cache); err != nil { - return fmt.Errorf("error tagging gallery studio for %s: %v", t.gallery.DisplayName(), err) + return fmt.Errorf("tagging gallery studio for %s: %v", t.gallery.DisplayName(), err) } } if t.tags { if err := autotag.GalleryTags(ctx, t.gallery, r.Gallery, r.Tag, t.cache); err != nil { - return fmt.Errorf("error tagging gallery tags for %s: %v", t.gallery.DisplayName(), err) + return fmt.Errorf("tagging gallery tags for %s: %v", t.gallery.DisplayName(), err) } } return nil }); err != nil { - logger.Error(err.Error()) + if !job.IsCancelled(ctx) { + logger.Errorf("auto-tag error: %v", err) + } } } diff --git a/pkg/scraper/stashbox/stash_box.go b/pkg/scraper/stashbox/stash_box.go index 713265e7ca1..1a83c1ab6e1 100644 --- a/pkg/scraper/stashbox/stash_box.go +++ b/pkg/scraper/stashbox/stash_box.go @@ -664,7 +664,7 @@ func performerFragmentToScrapedScenePerformer(p graphql.PerformerFragment) *mode func getFirstImage(ctx context.Context, client *http.Client, images []*graphql.ImageFragment) *string { ret, err := fetchImage(ctx, client, images[0].URL) - if err != nil { + if err != nil && !errors.Is(err, context.Canceled) { logger.Warnf("Error fetching image %s: %s", images[0].URL, err.Error()) } From 1c59d91690e09e2391f25f7f65b32cd51b6792b8 Mon Sep 17 00:00:00 2001 From: hontheinternet <121332499+hontheinternet@users.noreply.github.com> Date: Fri, 26 May 2023 11:55:01 +0900 Subject: [PATCH 040/135] fix interactive heatmaps to match the length of the video (#3758) --- .../manager/generator_interactive_heatmap_speed.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/manager/generator_interactive_heatmap_speed.go b/internal/manager/generator_interactive_heatmap_speed.go index 3b3b98bf4cf..3cae5f5621e 100644 --- a/internal/manager/generator_interactive_heatmap_speed.go +++ b/internal/manager/generator_interactive_heatmap_speed.go @@ -73,10 +73,11 @@ func (g *InteractiveHeatmapSpeedGenerator) Generate(funscriptPath string, heatma return fmt.Errorf("no valid actions in funscript") } + sceneDurationMilli := int64(sceneDuration * 1000) g.Funscript = funscript g.Funscript.UpdateIntensityAndSpeed() - err = g.RenderHeatmap(heatmapPath) + err = g.RenderHeatmap(heatmapPath, sceneDurationMilli) if err != nil { return err @@ -155,8 +156,8 @@ func (funscript *Script) UpdateIntensityAndSpeed() { } // funscript needs to have intensity updated first -func (g *InteractiveHeatmapSpeedGenerator) RenderHeatmap(heatmapPath string) error { - gradient := g.Funscript.getGradientTable(g.NumSegments) +func (g *InteractiveHeatmapSpeedGenerator) RenderHeatmap(heatmapPath string, sceneDurationMilli int64) error { + gradient := g.Funscript.getGradientTable(g.NumSegments, sceneDurationMilli) img := image.NewRGBA(image.Rect(0, 0, g.Width, g.Height)) for x := 0; x < g.Width; x++ { @@ -179,7 +180,7 @@ func (g *InteractiveHeatmapSpeedGenerator) RenderHeatmap(heatmapPath string) err } // add 10 minute marks - maxts := g.Funscript.Actions[len(g.Funscript.Actions)-1].At + maxts := sceneDurationMilli const tick = 600000 var ts int64 = tick c, _ := colorful.Hex("#000000") @@ -242,7 +243,7 @@ func (gt GradientTable) GetYRange(t float64) [2]float64 { return gt[len(gt)-1].YRange } -func (funscript Script) getGradientTable(numSegments int) GradientTable { +func (funscript Script) getGradientTable(numSegments int, sceneDurationMilli int64) GradientTable { const windowSize = 15 const backfillThreshold = 500 @@ -255,7 +256,7 @@ func (funscript Script) getGradientTable(numSegments int) GradientTable { gradient := make(GradientTable, numSegments) posList := []int{} - maxts := funscript.Actions[len(funscript.Actions)-1].At + maxts := sceneDurationMilli for _, a := range funscript.Actions { posList = append(posList, a.Pos) From 241aae91001a9b3f3d82728174dd5e58e41fe8b2 Mon Sep 17 00:00:00 2001 From: dogwithakeyboard <128322708+dogwithakeyboard@users.noreply.github.com> Date: Mon, 29 May 2023 20:34:35 +0100 Subject: [PATCH 041/135] check for '0001-01-01' in death_date (#3784) --- pkg/sqlite/performer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 7468db8be03..d1079eac02f 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -758,7 +758,7 @@ func performerAgeFilterCriterionHandler(age *models.IntCriterionInput) criterion return func(ctx context.Context, f *filterBuilder) { if age != nil && age.Modifier.IsValid() { clause, args := getIntCriterionWhereClause( - "cast(IFNULL(strftime('%Y.%m%d', performers.death_date), strftime('%Y.%m%d', 'now')) - strftime('%Y.%m%d', performers.birthdate) as int)", + "cast(strftime('%Y.%m%d',CASE WHEN performers.death_date IS NULL OR performers.death_date = '0001-01-01' OR performers.death_date = '' THEN 'now' ELSE performers.death_date END) - strftime('%Y.%m%d', performers.birthdate) as int)", *age, ) f.addWhere(clause, args...) From fc53380310733ba832eb95f634cf58a98bdcf337 Mon Sep 17 00:00:00 2001 From: NodudeWasTaken <75137537+NodudeWasTaken@users.noreply.github.com> Date: Wed, 31 May 2023 02:27:45 +0200 Subject: [PATCH 042/135] Safari skip file transcodes (#3507) * Ignore file transcodes on safari --- ui/v2.5/package.json | 2 + .../components/ScenePlayer/ScenePlayer.tsx | 43 ++++++++++++------- ui/v2.5/yarn.lock | 10 +++++ 3 files changed, 40 insertions(+), 15 deletions(-) diff --git a/ui/v2.5/package.json b/ui/v2.5/package.json index d287b84374a..c1ce1575000 100644 --- a/ui/v2.5/package.json +++ b/ui/v2.5/package.json @@ -64,6 +64,7 @@ "slick-carousel": "^1.8.1", "string.prototype.replaceall": "^1.0.7", "thehandy": "^1.0.3", + "ua-parser-js": "^1.0.34", "universal-cookie": "^4.0.4", "video.js": "^7.21.3", "videojs-contrib-dash": "^5.1.1", @@ -89,6 +90,7 @@ "@types/react-helmet": "^6.1.6", "@types/react-router-bootstrap": "^0.24.5", "@types/react-router-hash-link": "^2.4.5", + "@types/ua-parser-js": "^0.7.36", "@types/video.js": "^7.3.51", "@types/videojs-mobile-ui": "^0.8.0", "@types/videojs-seek-buttons": "^2.1.0", diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index bb04eec3f99..c553fa3cf14 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -11,6 +11,7 @@ import videojs, { VideoJsPlayer, VideoJsPlayerOptions } from "video.js"; import "videojs-contrib-dash"; import "videojs-mobile-ui"; import "videojs-seek-buttons"; +import { UAParser } from "ua-parser-js"; import "./live"; import "./PlaylistButtons"; import "./source-selector"; @@ -487,24 +488,36 @@ export const ScenePlayer: React.FC = ({ }; player.mobileUi(mobileUiOptions); + function isDirect(src: URL) { + return ( + src.pathname.endsWith("/stream") || + src.pathname.endsWith("/stream.mpd") || + src.pathname.endsWith("/stream.m3u8") + ); + } + const { duration } = file; const sourceSelector = player.sourceSelector(); + const isSafari = UAParser().browser.name?.includes("Safari"); sourceSelector.setSources( - scene.sceneStreams.map((stream) => { - const src = new URL(stream.url); - const isDirect = - src.pathname.endsWith("/stream") || - src.pathname.endsWith("/stream.mpd") || - src.pathname.endsWith("/stream.m3u8"); - - return { - src: stream.url, - type: stream.mime_type ?? undefined, - label: stream.label ?? undefined, - offset: !isDirect, - duration, - }; - }) + scene.sceneStreams + .filter((stream) => { + const src = new URL(stream.url); + const isFileTranscode = !isDirect(src); + + return !(isFileTranscode && isSafari); + }) + .map((stream) => { + const src = new URL(stream.url); + + return { + src: stream.url, + type: stream.mime_type ?? undefined, + label: stream.label ?? undefined, + offset: !isDirect(src), + duration, + }; + }) ); function getDefaultLanguageCode() { diff --git a/ui/v2.5/yarn.lock b/ui/v2.5/yarn.lock index 9c82c29b858..8725ca14e02 100644 --- a/ui/v2.5/yarn.lock +++ b/ui/v2.5/yarn.lock @@ -2430,6 +2430,11 @@ resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.13.tgz#da4bfd73f49bd541d28920ab0e2bf0ee80f71c91" integrity sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw== +"@types/ua-parser-js@^0.7.36": + version "0.7.36" + resolved "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz" + integrity sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ== + "@types/unist@*", "@types/unist@^2.0.0", "@types/unist@^2.0.2", "@types/unist@^2.0.3": version "2.0.6" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d" @@ -7678,6 +7683,11 @@ ua-parser-js@^1.0.2: resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.33.tgz#f21f01233e90e7ed0f059ceab46eb190ff17f8f4" integrity sha512-RqshF7TPTE0XLYAqmjlu5cLLuGdKrNu9O1KLA/qp39QtbZwuzwv1dT46DZSopoUMsYgXpB3Cv8a03FI8b74oFQ== +ua-parser-js@^1.0.34: + version "1.0.34" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.34.tgz#b33f41c415325839f354005d25a2f588be296976" + integrity sha512-K9mwJm/DaB6mRLZfw6q8IMXipcrmuT6yfhYmwhAkuh+81sChuYstYA+znlgaflUPaYUa3odxKPKGw6Vw/lANew== + unbox-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" From d0847d1ebfd04085df7aa6d747efb2cd64cc6fcc Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Wed, 31 May 2023 02:39:22 +0200 Subject: [PATCH 043/135] Fix performer image display again and refactoring (#3782) * Fix the fix for displayed performer image sticking after save * Refactor for consistency * Fully extract entity create/update logic from edit pages * Fix submit hotkeys * Refactor scene cover preview * Fix atoi error on new scene page --- .../Galleries/GalleryDetails/Gallery.tsx | 18 +++++ .../GalleryDetails/GalleryCreate.tsx | 32 +++++++- .../GalleryDetails/GalleryEditPanel.tsx | 60 +++------------ .../components/Images/ImageDetails/Image.tsx | 14 ++++ .../Images/ImageDetails/ImageEditPanel.tsx | 29 +++----- .../components/Movies/MovieDetails/Movie.tsx | 43 ++++++----- .../Movies/MovieDetails/MovieCreate.tsx | 22 +++--- .../Movies/MovieDetails/MovieEditPanel.tsx | 29 +++++--- .../Performers/PerformerDetails/Performer.tsx | 57 +++++++++----- .../PerformerDetails/PerformerCreate.tsx | 29 +++++++- .../PerformerDetails/PerformerEditPanel.tsx | 74 ++++++------------- .../components/Scenes/SceneDetails/Scene.tsx | 18 +++++ .../Scenes/SceneDetails/SceneCreate.tsx | 29 +++++++- .../Scenes/SceneDetails/SceneEditPanel.tsx | 69 ++++++----------- .../Studios/StudioDetails/Studio.tsx | 43 ++++++----- .../Studios/StudioDetails/StudioCreate.tsx | 18 +++-- .../Studios/StudioDetails/StudioEditPanel.tsx | 39 +++++++--- .../src/components/Tags/TagDetails/Tag.tsx | 59 ++++++++------- .../components/Tags/TagDetails/TagCreate.tsx | 41 +++++----- .../Tags/TagDetails/TagEditPanel.tsx | 39 +++++++--- ui/v2.5/src/core/StashService.ts | 6 +- 21 files changed, 436 insertions(+), 332 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx index 057642b1ae6..f7d50da29de 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx @@ -64,6 +64,23 @@ export const GalleryPage: React.FC = ({ gallery }) => { const [organizedLoading, setOrganizedLoading] = useState(false); + async function onSave(input: GQL.GalleryCreateInput) { + await updateGallery({ + variables: { + input: { + id: gallery.id, + ...input, + }, + }, + }); + Toast.success({ + content: intl.formatMessage( + { id: "toast.updated_entity" }, + { entity: intl.formatMessage({ id: "gallery" }).toLocaleLowerCase() } + ), + }); + } + const onOrganizedClick = async () => { try { setOrganizedLoading(true); @@ -242,6 +259,7 @@ export const GalleryPage: React.FC = ({ gallery }) => { setIsDeleteAlertOpen(true)} /> diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryCreate.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryCreate.tsx index 62e80e23e82..d6519bcd2d2 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryCreate.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryCreate.tsx @@ -1,16 +1,39 @@ import React, { useMemo } from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import { useLocation } from "react-router-dom"; +import { useHistory, useLocation } from "react-router-dom"; +import * as GQL from "src/core/generated-graphql"; +import { useGalleryCreate } from "src/core/StashService"; +import { useToast } from "src/hooks/Toast"; import { GalleryEditPanel } from "./GalleryEditPanel"; const GalleryCreate: React.FC = () => { + const history = useHistory(); const intl = useIntl(); + const Toast = useToast(); + const location = useLocation(); const query = useMemo(() => new URLSearchParams(location.search), [location]); const gallery = { title: query.get("q") ?? undefined, }; + const [createGallery] = useGalleryCreate(); + + async function onSave(input: GQL.GalleryCreateInput) { + const result = await createGallery({ + variables: { input }, + }); + if (result.data?.galleryCreate) { + history.push(`/galleries/${result.data.galleryCreate.id}`); + Toast.success({ + content: intl.formatMessage( + { id: "toast.created_entity" }, + { entity: intl.formatMessage({ id: "gallery" }).toLocaleLowerCase() } + ), + }); + } + } + return (
@@ -20,7 +43,12 @@ const GalleryCreate: React.FC = () => { values={{ entityType: intl.formatMessage({ id: "gallery" }) }} /> - {}} /> + {}} + />
); diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx index d560127dc58..27f3fdb7814 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import { useHistory, Prompt } from "react-router-dom"; +import { Prompt } from "react-router-dom"; import { Button, Dropdown, @@ -15,8 +15,6 @@ import * as yup from "yup"; import { queryScrapeGallery, queryScrapeGalleryURL, - useGalleryCreate, - useGalleryUpdate, useListGalleryScrapers, mutateReloadScrapers, } from "src/core/StashService"; @@ -44,17 +42,18 @@ import { DateInput } from "src/components/Shared/DateInput"; interface IProps { gallery: Partial; isVisible: boolean; + onSubmit: (input: GQL.GalleryCreateInput) => Promise; onDelete: () => void; } export const GalleryEditPanel: React.FC = ({ gallery, isVisible, + onSubmit, onDelete, }) => { const intl = useIntl(); const Toast = useToast(); - const history = useHistory(); const [scenes, setScenes] = useState<{ id: string; title: string }[]>( (gallery?.scenes ?? []).map((s) => ({ id: s.id, @@ -74,9 +73,6 @@ export const GalleryEditPanel: React.FC = ({ // Network state const [isLoading, setIsLoading] = useState(false); - const [createGallery] = useGalleryCreate(); - const [updateGallery] = useGalleryUpdate(); - const titleRequired = isNew || (gallery?.files?.length === 0 && !gallery?.folder); @@ -151,7 +147,9 @@ export const GalleryEditPanel: React.FC = ({ useEffect(() => { if (isVisible) { Mousetrap.bind("s s", () => { - formik.handleSubmit(); + if (formik.dirty) { + formik.submitForm(); + } }); Mousetrap.bind("d d", () => { onDelete(); @@ -174,51 +172,11 @@ export const GalleryEditPanel: React.FC = ({ setQueryableScrapers(newQueryableScrapers); }, [Scrapers]); - async function onSave(input: GQL.GalleryCreateInput) { + async function onSave(input: InputValues) { setIsLoading(true); try { - if (isNew) { - const result = await createGallery({ - variables: { - input, - }, - }); - if (result.data?.galleryCreate) { - history.push(`/galleries/${result.data.galleryCreate.id}`); - Toast.success({ - content: intl.formatMessage( - { id: "toast.created_entity" }, - { - entity: intl - .formatMessage({ id: "gallery" }) - .toLocaleLowerCase(), - } - ), - }); - } - } else { - const result = await updateGallery({ - variables: { - input: { - id: gallery.id!, - ...input, - }, - }, - }); - if (result.data?.galleryUpdate) { - Toast.success({ - content: intl.formatMessage( - { id: "toast.updated_entity" }, - { - entity: intl - .formatMessage({ id: "gallery" }) - .toLocaleLowerCase(), - } - ), - }); - formik.resetForm(); - } - } + await onSubmit(input); + formik.resetForm(); } catch (e) { Toast.error(e); } diff --git a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx index b9485767c2e..c52ae22d7ba 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx @@ -17,6 +17,7 @@ import { Icon } from "src/components/Shared/Icon"; import { Counter } from "src/components/Shared/Counter"; import { useToast } from "src/hooks/Toast"; import * as Mousetrap from "mousetrap"; +import * as GQL from "src/core/generated-graphql"; import { OCounterButton } from "src/components/Scenes/SceneDetails/OCounterButton"; import { OrganizedButton } from "src/components/Scenes/SceneDetails/OrganizedButton"; import { ImageFileInfoPanel } from "./ImageFileInfoPanel"; @@ -51,6 +52,18 @@ export const Image: React.FC = () => { const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); + async function onSave(input: GQL.ImageUpdateInput) { + await updateImage({ + variables: { input }, + }); + Toast.success({ + content: intl.formatMessage( + { id: "toast.updated_entity" }, + { entity: intl.formatMessage({ id: "image" }).toLocaleLowerCase() } + ), + }); + } + async function onRescan() { if (!image || !image.visual_files.length) { return; @@ -225,6 +238,7 @@ export const Image: React.FC = () => { setIsDeleteAlertOpen(true)} /> diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx index 96ace160903..9d7e55e9f18 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx @@ -4,7 +4,6 @@ import { FormattedMessage, useIntl } from "react-intl"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; -import { useImageUpdate } from "src/core/StashService"; import { PerformerSelect, TagSelect, @@ -25,12 +24,14 @@ import { DateInput } from "src/components/Shared/DateInput"; interface IProps { image: GQL.ImageDataFragment; isVisible: boolean; + onSubmit: (input: GQL.ImageUpdateInput) => Promise; onDelete: () => void; } export const ImageEditPanel: React.FC = ({ image, isVisible, + onSubmit, onDelete, }) => { const intl = useIntl(); @@ -41,8 +42,6 @@ export const ImageEditPanel: React.FC = ({ const { configuration } = React.useContext(ConfigurationContext); - const [updateImage] = useImageUpdate(); - const schema = yup.object({ title: yup.string().ensure(), url: yup.string().ensure(), @@ -97,7 +96,9 @@ export const ImageEditPanel: React.FC = ({ useEffect(() => { if (isVisible) { Mousetrap.bind("s s", () => { - formik.handleSubmit(); + if (formik.dirty) { + formik.submitForm(); + } }); Mousetrap.bind("d d", () => { onDelete(); @@ -113,23 +114,11 @@ export const ImageEditPanel: React.FC = ({ async function onSave(input: InputValues) { setIsLoading(true); try { - const result = await updateImage({ - variables: { - input: { - id: image.id, - ...input, - }, - }, + await onSubmit({ + id: image.id, + ...input, }); - if (result.data?.imageUpdate) { - Toast.success({ - content: intl.formatMessage( - { id: "toast.updated_entity" }, - { entity: intl.formatMessage({ id: "image" }).toLocaleLowerCase() } - ), - }); - formik.resetForm(); - } + formik.resetForm(); } catch (e) { Toast.error(e); } diff --git a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx index 4e723858c32..fc04df94bb1 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx @@ -83,7 +83,7 @@ const MoviePage: React.FC = ({ movie }) => { // set up hotkeys useEffect(() => { - Mousetrap.bind("e", () => setIsEditing(true)); + Mousetrap.bind("e", () => toggleEditing()); Mousetrap.bind("d d", () => { onDelete(); }); @@ -95,22 +95,21 @@ const MoviePage: React.FC = ({ movie }) => { }); async function onSave(input: GQL.MovieCreateInput) { - try { - const result = await updateMovie({ - variables: { - input: { - id: movie.id, - ...input, - }, + await updateMovie({ + variables: { + input: { + id: movie.id, + ...input, }, - }); - if (result.data?.movieUpdate) { - setIsEditing(false); - history.push(`/movies/${result.data.movieUpdate.id}`); - } - } catch (e) { - Toast.error(e); - } + }, + }); + toggleEditing(false); + Toast.success({ + content: intl.formatMessage( + { id: "toast.updated_entity" }, + { entity: intl.formatMessage({ id: "movie" }).toLocaleLowerCase() } + ), + }); } async function onDelete() { @@ -124,8 +123,12 @@ const MoviePage: React.FC = ({ movie }) => { history.push(`/movies`); } - function onToggleEdit() { - setIsEditing(!isEditing); + function toggleEditing(value?: boolean) { + if (value !== undefined) { + setIsEditing(value); + } else { + setIsEditing((e) => !e); + } setFrontImage(undefined); setBackImage(undefined); } @@ -239,7 +242,7 @@ const MoviePage: React.FC = ({ movie }) => { objectName={movie.name} isNew={false} isEditing={isEditing} - onToggleEdit={onToggleEdit} + onToggleEdit={() => toggleEditing()} onSave={() => {}} onImageChange={() => {}} onDelete={onDelete} @@ -249,7 +252,7 @@ const MoviePage: React.FC = ({ movie }) => { toggleEditing()} onDelete={onDelete} setFrontImage={setFrontImage} setBackImage={setBackImage} diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieCreate.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieCreate.tsx index 36b6ea5bd10..973fe89bd30 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieCreate.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieCreate.tsx @@ -2,15 +2,17 @@ import React, { useMemo, useState } from "react"; import * as GQL from "src/core/generated-graphql"; import { useMovieCreate } from "src/core/StashService"; import { useHistory, useLocation } from "react-router-dom"; +import { useIntl } from "react-intl"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { useToast } from "src/hooks/Toast"; import { MovieEditPanel } from "./MovieEditPanel"; const MovieCreate: React.FC = () => { const history = useHistory(); - const location = useLocation(); + const intl = useIntl(); const Toast = useToast(); + const location = useLocation(); const query = useMemo(() => new URLSearchParams(location.search), [location]); const movie = { name: query.get("q") ?? undefined, @@ -24,15 +26,17 @@ const MovieCreate: React.FC = () => { const [createMovie] = useMovieCreate(); async function onSave(input: GQL.MovieCreateInput) { - try { - const result = await createMovie({ - variables: input, + const result = await createMovie({ + variables: input, + }); + if (result.data?.movieCreate?.id) { + history.push(`/movies/${result.data.movieCreate.id}`); + Toast.success({ + content: intl.formatMessage( + { id: "toast.created_entity" }, + { entity: intl.formatMessage({ id: "gallery" }).toLocaleLowerCase() } + ), }); - if (result.data?.movieCreate?.id) { - history.push(`/movies/${result.data.movieCreate.id}`); - } - } catch (e) { - Toast.error(e); } } diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx index c2c51794cb5..60f4465ed00 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx @@ -28,7 +28,7 @@ import { DateInput } from "src/components/Shared/DateInput"; interface IMovieEditPanel { movie: Partial; - onSubmit: (movie: GQL.MovieCreateInput) => void; + onSubmit: (movie: GQL.MovieCreateInput) => Promise; onCancel: () => void; onDelete: () => void; setFrontImage: (image?: string | null) => void; @@ -103,7 +103,7 @@ export const MovieEditPanel: React.FC = ({ initialValues, enableReinitialize: true, validationSchema: schema, - onSubmit: (values) => onSubmit(values), + onSubmit: (values) => onSave(values), }); function setRating(v: number) { @@ -116,19 +116,17 @@ export const MovieEditPanel: React.FC = ({ setRating ); - function onCancelEditing() { - setFrontImage(undefined); - setBackImage(undefined); - onCancel?.(); - } - // set up hotkeys useEffect(() => { // Mousetrap.bind("u", (e) => { // setStudioFocus() // e.preventDefault(); // }); - Mousetrap.bind("s s", () => formik.handleSubmit()); + Mousetrap.bind("s s", () => { + if (formik.dirty) { + formik.submitForm(); + } + }); return () => { // Mousetrap.unbind("u"); @@ -182,6 +180,17 @@ export const MovieEditPanel: React.FC = ({ } } + async function onSave(input: InputValues) { + setIsLoading(true); + try { + await onSubmit(input); + formik.resetForm(); + } catch (e) { + Toast.error(e); + } + setIsLoading(false); + } + async function onScrapeMovieURL() { const { url } = formik.values; if (!url) return; @@ -488,7 +497,7 @@ export const MovieEditPanel: React.FC = ({ objectName={movie?.name ?? intl.formatMessage({ id: "movie" })} isNew={isNew} isEditing={isEditing} - onToggleEdit={onCancelEditing} + onToggleEdit={onCancel} onSave={formik.handleSubmit} saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})} onImageChange={onFrontImageChange} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index 197556c8b5c..a024124c8d6 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -68,15 +68,17 @@ const PerformerPage: React.FC = ({ performer }) => { const activeImage = useMemo(() => { const performerImage = performer.image_path; - if (image === null && performerImage) { - const performerImageURL = new URL(performerImage); - performerImageURL.searchParams.set("default", "true"); - return performerImageURL.toString(); - } else if (image) { - return image; + if (isEditing) { + if (image === null && performerImage) { + const performerImageURL = new URL(performerImage); + performerImageURL.searchParams.set("default", "true"); + return performerImageURL.toString(); + } else if (image) { + return image; + } } return performerImage; - }, [image, performer.image_path]); + }, [image, isEditing, performer.image_path]); const lightboxImages = useMemo( () => [{ paths: { thumbnail: activeImage, image: activeImage } }], @@ -122,15 +124,10 @@ const PerformerPage: React.FC = ({ performer }) => { setRating ); - // reset image if performer changed - useEffect(() => { - setImage(undefined); - }, [performer]); - // set up hotkeys useEffect(() => { Mousetrap.bind("a", () => setActiveTabKey("details")); - Mousetrap.bind("e", () => setIsEditing(!isEditing)); + Mousetrap.bind("e", () => toggleEditing()); Mousetrap.bind("c", () => setActiveTabKey("scenes")); Mousetrap.bind("g", () => setActiveTabKey("galleries")); Mousetrap.bind("m", () => setActiveTabKey("movies")); @@ -147,6 +144,24 @@ const PerformerPage: React.FC = ({ performer }) => { }; }); + async function onSave(input: GQL.PerformerCreateInput) { + await updatePerformer({ + variables: { + input: { + id: performer.id, + ...input, + }, + }, + }); + toggleEditing(false); + Toast.success({ + content: intl.formatMessage( + { id: "toast.updated_entity" }, + { entity: intl.formatMessage({ id: "performer" }).toLocaleLowerCase() } + ), + }); + } + async function onDelete() { try { await deletePerformer({ variables: { id: performer.id } }); @@ -158,6 +173,15 @@ const PerformerPage: React.FC = ({ performer }) => { history.push("/performers"); } + function toggleEditing(value?: boolean) { + if (value !== undefined) { + setIsEditing(value); + } else { + setIsEditing((e) => !e); + } + setImage(undefined); + } + function renderImage() { if (activeImage) { return ( @@ -175,9 +199,7 @@ const PerformerPage: React.FC = ({ performer }) => { objectName={ performer?.name ?? intl.formatMessage({ id: "performer" }) } - onToggleEdit={() => { - setIsEditing(!isEditing); - }} + onToggleEdit={() => toggleEditing()} onDelete={onDelete} onAutoTag={onAutoTag} isNew={false} @@ -297,7 +319,8 @@ const PerformerPage: React.FC = ({ performer }) => { setIsEditing(false)} + onSubmit={onSave} + onCancel={() => toggleEditing()} setImage={setImage} setEncodingImage={setEncodingImage} /> diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerCreate.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerCreate.tsx index 2f3e32b2e94..26b3f88f0f8 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerCreate.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerCreate.tsx @@ -2,9 +2,16 @@ import React, { useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { PerformerEditPanel } from "./PerformerEditPanel"; -import { useLocation } from "react-router-dom"; +import { useHistory, useLocation } from "react-router-dom"; +import { useToast } from "src/hooks/Toast"; +import * as GQL from "src/core/generated-graphql"; +import { usePerformerCreate } from "src/core/StashService"; const PerformerCreate: React.FC = () => { + const Toast = useToast(); + const history = useHistory(); + const intl = useIntl(); + const [image, setImage] = useState(); const [encodingImage, setEncodingImage] = useState(false); @@ -14,7 +21,24 @@ const PerformerCreate: React.FC = () => { name: query.get("q") ?? undefined, }; - const intl = useIntl(); + const [createPerformer] = usePerformerCreate(); + + async function onSave(input: GQL.PerformerCreateInput) { + const result = await createPerformer({ + variables: { input }, + }); + if (result.data?.performerCreate) { + history.push(`/performers/${result.data.performerCreate.id}`); + Toast.success({ + content: intl.formatMessage( + { id: "toast.created_entity" }, + { + entity: intl.formatMessage({ id: "performer" }).toLocaleLowerCase(), + } + ), + }); + } + } function renderPerformerImage() { if (encodingImage) { @@ -46,6 +70,7 @@ const PerformerCreate: React.FC = () => { diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index 03f2dd12838..5c2d26d3d08 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -8,8 +8,6 @@ import { useListPerformerScrapers, queryScrapePerformer, mutateReloadScrapers, - usePerformerUpdate, - usePerformerCreate, useTagCreate, queryScrapePerformerURL, } from "src/core/StashService"; @@ -24,7 +22,7 @@ import ImageUtils from "src/utils/image"; import { getStashIDs } from "src/utils/stashIds"; import { stashboxDisplayName } from "src/utils/stashbox"; import { useToast } from "src/hooks/Toast"; -import { Prompt, useHistory } from "react-router-dom"; +import { Prompt } from "react-router-dom"; import { useFormik } from "formik"; import { genderToString, @@ -57,6 +55,7 @@ const isScraper = ( interface IPerformerDetails { performer: Partial; isVisible: boolean; + onSubmit: (performer: GQL.PerformerCreateInput) => Promise; onCancel?: () => void; setImage: (image?: string | null) => void; setEncodingImage: (loading: boolean) => void; @@ -65,12 +64,12 @@ interface IPerformerDetails { export const PerformerEditPanel: React.FC = ({ performer, isVisible, + onSubmit, onCancel, setImage, setEncodingImage, }) => { const Toast = useToast(); - const history = useHistory(); const isNew = performer.id === undefined; @@ -82,9 +81,6 @@ export const PerformerEditPanel: React.FC = ({ // Network state const [isLoading, setIsLoading] = useState(false); - const [updatePerformer] = usePerformerUpdate(); - const [createPerformer] = usePerformerCreate(); - const Scrapers = useListPerformerScrapers(); const [queryableScrapers, setQueryableScrapers] = useState([]); @@ -454,61 +450,35 @@ export const PerformerEditPanel: React.FC = ({ ImageUtils.onImageChange(event, onImageLoad); } + function valuesToInput(input: InputValues): GQL.PerformerCreateInput { + return { + ...input, + gender: input.gender || null, + height_cm: input.height_cm || null, + weight: input.weight || null, + penis_length: input.penis_length || null, + circumcised: input.circumcised || null, + }; + } + async function onSave(input: InputValues) { setIsLoading(true); try { - if (isNew) { - const result = await createPerformer({ - variables: { - input: { - ...input, - gender: input.gender || null, - height_cm: input.height_cm || null, - weight: input.weight || null, - penis_length: input.penis_length || null, - circumcised: input.circumcised || null, - }, - }, - }); - if (result.data?.performerCreate) { - history.push(`/performers/${result.data.performerCreate.id}`); - } - } else { - await updatePerformer({ - variables: { - input: { - id: performer.id!, - ...input, - gender: input.gender || null, - height_cm: input.height_cm || null, - weight: input.weight || null, - penis_length: input.penis_length || null, - circumcised: input.circumcised || null, - }, - }, - }); - } + await onSubmit(valuesToInput(input)); + formik.resetForm(); } catch (e) { Toast.error(e); - setIsLoading(false); - return; - } - if (!isNew && onCancel) { - onCancel(); } setIsLoading(false); } - function onCancelEditing() { - setImage(undefined); - onCancel?.(); - } - // set up hotkeys useEffect(() => { if (isVisible) { Mousetrap.bind("s s", () => { - onSave?.(formik.values); + if (formik.dirty) { + formik.submitForm(); + } }); return () => { @@ -699,9 +669,7 @@ export const PerformerEditPanel: React.FC = ({ } const currentPerformer = { - ...formik.values, - gender: formik.values.gender || null, - circumcised: formik.values.circumcised || null, + ...valuesToInput(formik.values), image: formik.values.image ?? performer.image_path, }; @@ -729,7 +697,7 @@ export const PerformerEditPanel: React.FC = ({ return (
{!isNew && onCancel ? ( - ) : null} diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index 5d6bb369083..b3ef0b42365 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -169,6 +169,23 @@ const ScenePage: React.FC = ({ }; }); + async function onSave(input: GQL.SceneCreateInput) { + await updateScene({ + variables: { + input: { + id: scene.id, + ...input, + }, + }, + }); + Toast.success({ + content: intl.formatMessage( + { id: "toast.updated_entity" }, + { entity: intl.formatMessage({ id: "scene" }).toLocaleLowerCase() } + ), + }); + } + const onOrganizedClick = async () => { try { setOrganizedLoading(true); @@ -461,6 +478,7 @@ const ScenePage: React.FC = ({ setIsDeleteAlertOpen(true)} /> diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneCreate.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneCreate.tsx index 4e289c52d5e..81181272fab 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneCreate.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneCreate.tsx @@ -1,19 +1,23 @@ import React, { useEffect, useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import { useLocation } from "react-router-dom"; +import { useHistory, useLocation } from "react-router-dom"; import { SceneEditPanel } from "./SceneEditPanel"; -import { useFindScene } from "src/core/StashService"; +import * as GQL from "src/core/generated-graphql"; +import { mutateCreateScene, useFindScene } from "src/core/StashService"; import ImageUtils from "src/utils/image"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; +import { useToast } from "src/hooks/Toast"; const SceneCreate: React.FC = () => { + const history = useHistory(); const intl = useIntl(); + const Toast = useToast(); const location = useLocation(); const query = useMemo(() => new URLSearchParams(location.search), [location]); // create scene from provided scene id if applicable - const { data, loading } = useFindScene(query.get("from_scene_id") ?? ""); + const { data, loading } = useFindScene(query.get("from_scene_id") ?? "new"); const [loadingCoverImage, setLoadingCoverImage] = useState(false); const [coverImage, setCoverImage] = useState(); @@ -53,6 +57,23 @@ const SceneCreate: React.FC = () => { return ; } + async function onSave(input: GQL.SceneCreateInput) { + const fileID = query.get("file_id") ?? undefined; + const result = await mutateCreateScene({ + ...input, + file_ids: fileID ? [fileID] : undefined, + }); + if (result.data?.sceneCreate?.id) { + history.push(`/scenes/${result.data.sceneCreate.id}`); + Toast.success({ + content: intl.formatMessage( + { id: "toast.created_entity" }, + { entity: intl.formatMessage({ id: "scene" }).toLocaleLowerCase() } + ), + }); + } + } + return (
@@ -64,10 +85,10 @@ const SceneCreate: React.FC = () => {
diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index d3e81aac0e6..840621e33ed 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -16,10 +16,8 @@ import { queryScrapeScene, queryScrapeSceneURL, useListSceneScrapers, - useSceneUpdate, mutateReloadScrapers, queryScrapeSceneQueryFragment, - mutateCreateScene, } from "src/core/StashService"; import { PerformerSelect, @@ -37,7 +35,7 @@ import ImageUtils from "src/utils/image"; import FormUtils from "src/utils/form"; import { getStashIDs } from "src/utils/stashIds"; import { useFormik } from "formik"; -import { Prompt, useHistory } from "react-router-dom"; +import { Prompt } from "react-router-dom"; import { ConfigurationContext } from "src/hooks/Config"; import { stashboxDisplayName } from "src/utils/stashbox"; import { SceneMovieTable } from "./SceneMovieTable"; @@ -59,24 +57,23 @@ const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal")); interface IProps { scene: Partial; - fileID?: string; initialCoverImage?: string; isNew?: boolean; isVisible: boolean; + onSubmit: (input: GQL.SceneCreateInput) => Promise; onDelete?: () => void; } export const SceneEditPanel: React.FC = ({ scene, - fileID, initialCoverImage, isNew = false, isVisible, + onSubmit, onDelete, }) => { const intl = useIntl(); const Toast = useToast(); - const history = useHistory(); const [galleries, setGalleries] = useState<{ id: string; title: string }[]>( [] @@ -92,14 +89,6 @@ export const SceneEditPanel: React.FC = ({ const [scrapedScene, setScrapedScene] = useState(); const [endpoint, setEndpoint] = useState(); - const [coverImagePreview, setCoverImagePreview] = useState(); - - useEffect(() => { - setCoverImagePreview( - initialCoverImage ?? scene.paths?.screenshot ?? undefined - ); - }, [scene.paths?.screenshot, initialCoverImage]); - useEffect(() => { setGalleries( scene.galleries?.map((g) => ({ @@ -114,8 +103,6 @@ export const SceneEditPanel: React.FC = ({ // Network state const [isLoading, setIsLoading] = useState(false); - const [updateScene] = useSceneUpdate(); - const schema = yup.object({ title: yup.string().ensure(), code: yup.string().ensure(), @@ -183,6 +170,19 @@ export const SceneEditPanel: React.FC = ({ onSubmit: (values) => onSave(values), }); + const coverImagePreview = useMemo(() => { + const sceneImage = scene.paths?.screenshot; + const formImage = formik.values.cover_image; + if (formImage === null && sceneImage) { + const sceneImageURL = new URL(sceneImage); + sceneImageURL.searchParams.set("default", "true"); + return sceneImageURL.toString(); + } else if (formImage) { + return formImage; + } + return sceneImage; + }, [formik.values.cover_image, scene.paths?.screenshot]); + function setRating(v: number) { formik.setFieldValue("rating100", v); } @@ -209,7 +209,9 @@ export const SceneEditPanel: React.FC = ({ useEffect(() => { if (isVisible) { Mousetrap.bind("s s", () => { - formik.handleSubmit(); + if (formik.dirty) { + formik.submitForm(); + } }); Mousetrap.bind("d d", () => { if (onDelete) { @@ -259,35 +261,8 @@ export const SceneEditPanel: React.FC = ({ async function onSave(input: InputValues) { setIsLoading(true); try { - if (!isNew) { - const result = await updateScene({ - variables: { - input: { - id: scene.id!, - ...input, - }, - }, - }); - if (result.data?.sceneUpdate) { - Toast.success({ - content: intl.formatMessage( - { id: "toast.updated_entity" }, - { - entity: intl.formatMessage({ id: "scene" }).toLocaleLowerCase(), - } - ), - }); - formik.resetForm(); - } - } else { - const result = await mutateCreateScene({ - ...input, - file_ids: fileID ? [fileID] : undefined, - }); - if (result.data?.sceneCreate?.id) { - history.push(`/scenes/${result.data?.sceneCreate.id}`); - } - } + await onSubmit(input); + formik.resetForm(); } catch (e) { Toast.error(e); } @@ -318,7 +293,6 @@ export const SceneEditPanel: React.FC = ({ const encodingImage = ImageUtils.usePasteImage(onImageLoad); function onImageLoad(imageData: string) { - setCoverImagePreview(imageData); formik.setFieldValue("cover_image", imageData); } @@ -619,7 +593,6 @@ export const SceneEditPanel: React.FC = ({ if (updatedScene.image) { // image is a base64 string formik.setFieldValue("cover_image", updatedScene.image); - setCoverImagePreview(updatedScene.image); } if (updatedScene.remote_site_id && endpoint) { diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index 8ae80d93f50..049bfa07636 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -69,7 +69,7 @@ const StudioPage: React.FC = ({ studio }) => { // set up hotkeys useEffect(() => { - Mousetrap.bind("e", () => setIsEditing(true)); + Mousetrap.bind("e", () => toggleEditing()); Mousetrap.bind("d d", () => { onDelete(); }); @@ -83,21 +83,21 @@ const StudioPage: React.FC = ({ studio }) => { }); async function onSave(input: GQL.StudioCreateInput) { - try { - const result = await updateStudio({ - variables: { - input: { - id: studio.id, - ...input, - }, + await updateStudio({ + variables: { + input: { + id: studio.id, + ...input, }, - }); - if (result.data?.studioUpdate) { - setIsEditing(false); - } - } catch (e) { - Toast.error(e); - } + }, + }); + toggleEditing(false); + Toast.success({ + content: intl.formatMessage( + { id: "toast.updated_entity" }, + { entity: intl.formatMessage({ id: "studio" }).toLocaleLowerCase() } + ), + }); } async function onAutoTag() { @@ -149,8 +149,13 @@ const StudioPage: React.FC = ({ studio }) => { ); } - function onToggleEdit() { - setIsEditing(!isEditing); + function toggleEditing(value?: boolean) { + if (value !== undefined) { + setIsEditing(value); + } else { + setIsEditing((e) => !e); + } + setImage(undefined); } function renderImage() { @@ -213,7 +218,7 @@ const StudioPage: React.FC = ({ studio }) => { objectName={studio.name ?? intl.formatMessage({ id: "studio" })} isNew={false} isEditing={isEditing} - onToggleEdit={onToggleEdit} + onToggleEdit={() => toggleEditing()} onSave={() => {}} onImageChange={() => {}} onClearImage={() => {}} @@ -225,7 +230,7 @@ const StudioPage: React.FC = ({ studio }) => { toggleEditing()} onDelete={onDelete} setImage={setImage} setEncodingImage={setEncodingImage} diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioCreate.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioCreate.tsx index 44ceb259a83..250c85d445d 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioCreate.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioCreate.tsx @@ -27,15 +27,17 @@ const StudioCreate: React.FC = () => { const [createStudio] = useStudioCreate(); async function onSave(input: GQL.StudioCreateInput) { - try { - const result = await createStudio({ - variables: { input }, + const result = await createStudio({ + variables: { input }, + }); + if (result.data?.studioCreate?.id) { + history.push(`/studios/${result.data.studioCreate.id}`); + Toast.success({ + content: intl.formatMessage( + { id: "toast.created_entity" }, + { entity: intl.formatMessage({ id: "studio" }).toLocaleLowerCase() } + ), }); - if (result.data?.studioCreate?.id) { - history.push(`/studios/${result.data.studioCreate.id}`); - } - } catch (e) { - Toast.error(e); } } diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx index c2a469a7042..8eb9194837e 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx @@ -1,9 +1,10 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; import Mousetrap from "mousetrap"; import { Icon } from "src/components/Shared/Icon"; +import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { StudioSelect } from "src/components/Shared/Select"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { Button, Form, Col, Row } from "react-bootstrap"; @@ -18,10 +19,11 @@ import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; import { useRatingKeybinds } from "src/hooks/keybinds"; import { ConfigurationContext } from "src/hooks/Config"; import isEqual from "lodash-es/isEqual"; +import { useToast } from "src/hooks/Toast"; interface IStudioEditPanel { studio: Partial; - onSubmit: (studio: GQL.StudioCreateInput) => void; + onSubmit: (studio: GQL.StudioCreateInput) => Promise; onCancel: () => void; onDelete: () => void; setImage: (image?: string | null) => void; @@ -37,10 +39,14 @@ export const StudioEditPanel: React.FC = ({ setEncodingImage, }) => { const intl = useIntl(); + const Toast = useToast(); const isNew = studio.id === undefined; const { configuration } = React.useContext(ConfigurationContext); + // Network state + const [isLoading, setIsLoading] = useState(false); + const schema = yup.object({ name: yup.string().required(), url: yup.string().ensure(), @@ -73,6 +79,7 @@ export const StudioEditPanel: React.FC = ({ }); const initialValues = { + id: studio.id, name: studio.name ?? "", url: studio.url ?? "", details: studio.details ?? "", @@ -89,7 +96,7 @@ export const StudioEditPanel: React.FC = ({ initialValues, enableReinitialize: true, validationSchema: schema, - onSubmit: (values) => onSubmit(values), + onSubmit: (values) => onSave(values), }); const encodingImage = ImageUtils.usePasteImage((imageData) => @@ -114,20 +121,30 @@ export const StudioEditPanel: React.FC = ({ setRating ); - function onCancelEditing() { - setImage(undefined); - onCancel?.(); - } - // set up hotkeys useEffect(() => { - Mousetrap.bind("s s", () => formik.handleSubmit()); + Mousetrap.bind("s s", () => { + if (formik.dirty) { + formik.submitForm(); + } + }); return () => { Mousetrap.unbind("s s"); }; }); + async function onSave(input: InputValues) { + setIsLoading(true); + try { + await onSubmit(input); + formik.resetForm(); + } catch (e) { + Toast.error(e); + } + setIsLoading(false); + } + function onImageLoad(imageData: string | null) { formik.setFieldValue("image", imageData); } @@ -200,6 +217,8 @@ export const StudioEditPanel: React.FC = ({ : undefined; const aliasErrorIdx = aliasErrors?.split(" ").map((e) => parseInt(e)); + if (isLoading) return ; + return ( <> = ({ objectName={studio?.name ?? intl.formatMessage({ id: "studio" })} isNew={isNew} isEditing - onToggleEdit={onCancelEditing} + onToggleEdit={onCancel} onSave={formik.handleSubmit} saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})} onImageChange={onImageChange} diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index 07f7db40b59..3e7cc7d09f0 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -88,7 +88,7 @@ const TagPage: React.FC = ({ tag }) => { // set up hotkeys useEffect(() => { - Mousetrap.bind("e", () => setIsEditing(true)); + Mousetrap.bind("e", () => toggleEditing()); Mousetrap.bind("d d", () => { onDelete(); }); @@ -106,30 +106,31 @@ const TagPage: React.FC = ({ tag }) => { }); async function onSave(input: GQL.TagCreateInput) { - try { - const oldRelations = { - parents: tag.parents ?? [], - children: tag.children ?? [], - }; - const result = await updateTag({ - variables: { - input: { - id: tag.id, - ...input, - }, + const oldRelations = { + parents: tag.parents ?? [], + children: tag.children ?? [], + }; + const result = await updateTag({ + variables: { + input: { + id: tag.id, + ...input, }, + }, + }); + if (result.data?.tagUpdate) { + toggleEditing(false); + const updated = result.data.tagUpdate; + tagRelationHook(updated, oldRelations, { + parents: updated.parents, + children: updated.children, + }); + Toast.success({ + content: intl.formatMessage( + { id: "toast.updated_entity" }, + { entity: intl.formatMessage({ id: "tag" }).toLocaleLowerCase() } + ), }); - if (result.data?.tagUpdate) { - setIsEditing(false); - const updated = result.data.tagUpdate; - tagRelationHook(updated, oldRelations, { - parents: updated.parents, - children: updated.children, - }); - return updated.id; - } - } catch (e) { - Toast.error(e); } } @@ -190,8 +191,12 @@ const TagPage: React.FC = ({ tag }) => { ); } - function onToggleEdit() { - setIsEditing(!isEditing); + function toggleEditing(value?: boolean) { + if (value !== undefined) { + setIsEditing(value); + } else { + setIsEditing((e) => !e); + } setImage(undefined); } @@ -283,7 +288,7 @@ const TagPage: React.FC = ({ tag }) => { objectName={tag.name} isNew={false} isEditing={isEditing} - onToggleEdit={onToggleEdit} + onToggleEdit={() => toggleEditing()} onSave={() => {}} onImageChange={() => {}} onClearImage={() => {}} @@ -297,7 +302,7 @@ const TagPage: React.FC = ({ tag }) => { toggleEditing()} onDelete={onDelete} setImage={setImage} setEncodingImage={setEncodingImage} diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagCreate.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagCreate.tsx index 428d339b046..4b4c6dfc16e 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagCreate.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagCreate.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useState } from "react"; import { useHistory, useLocation } from "react-router-dom"; - +import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { useTagCreate } from "src/core/StashService"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; @@ -9,10 +9,11 @@ import { tagRelationHook } from "src/core/tags"; import { TagEditPanel } from "./TagEditPanel"; const TagCreate: React.FC = () => { + const intl = useIntl(); const history = useHistory(); - const location = useLocation(); const Toast = useToast(); + const location = useLocation(); const query = useMemo(() => new URLSearchParams(location.search), [location]); const tag = { name: query.get("q") ?? undefined, @@ -25,24 +26,26 @@ const TagCreate: React.FC = () => { const [createTag] = useTagCreate(); async function onSave(input: GQL.TagCreateInput) { - try { - const oldRelations = { - parents: [], - children: [], - }; - const result = await createTag({ - variables: { input }, + const oldRelations = { + parents: [], + children: [], + }; + const result = await createTag({ + variables: { input }, + }); + if (result.data?.tagCreate?.id) { + const created = result.data.tagCreate; + tagRelationHook(created, oldRelations, { + parents: created.parents, + children: created.children, + }); + history.push(`/tags/${created.id}`); + Toast.success({ + content: intl.formatMessage( + { id: "toast.created_entity" }, + { entity: intl.formatMessage({ id: "tag" }).toLocaleLowerCase() } + ), }); - if (result.data?.tagCreate?.id) { - const created = result.data.tagCreate; - tagRelationHook(created, oldRelations, { - parents: created.parents, - children: created.children, - }); - history.push(`/tags/${result.data.tagCreate.id}`); - } - } catch (e) { - Toast.error(e); } } diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx index 2c2eca35d69..8ad847fc91d 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; @@ -10,13 +10,14 @@ import ImageUtils from "src/utils/image"; import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; import Mousetrap from "mousetrap"; +import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { StringListInput } from "src/components/Shared/StringListInput"; import isEqual from "lodash-es/isEqual"; +import { useToast } from "src/hooks/Toast"; interface ITagEditPanel { tag: Partial; - // returns id - onSubmit: (tag: GQL.TagCreateInput) => void; + onSubmit: (tag: GQL.TagCreateInput) => Promise; onCancel: () => void; onDelete: () => void; setImage: (image?: string | null) => void; @@ -32,9 +33,13 @@ export const TagEditPanel: React.FC = ({ setEncodingImage, }) => { const intl = useIntl(); + const Toast = useToast(); const isNew = tag.id === undefined; + // Network state + const [isLoading, setIsLoading] = useState(false); + const labelXS = 3; const labelXL = 3; const fieldXS = 9; @@ -84,23 +89,33 @@ export const TagEditPanel: React.FC = ({ initialValues, validationSchema: schema, enableReinitialize: true, - onSubmit: (values) => onSubmit(values), + onSubmit: (values) => onSave(values), }); - function onCancelEditing() { - setImage(undefined); - onCancel?.(); - } - // set up hotkeys useEffect(() => { - Mousetrap.bind("s s", () => formik.handleSubmit()); + Mousetrap.bind("s s", () => { + if (formik.dirty) { + formik.submitForm(); + } + }); return () => { Mousetrap.unbind("s s"); }; }); + async function onSave(input: InputValues) { + setIsLoading(true); + try { + await onSubmit(input); + formik.resetForm(); + } catch (e) { + Toast.error(e); + } + setIsLoading(false); + } + const encodingImage = ImageUtils.usePasteImage(onImageLoad); useEffect(() => { @@ -127,6 +142,8 @@ export const TagEditPanel: React.FC = ({ : undefined; const aliasErrorIdx = aliasErrors?.split(" ").map((e) => parseInt(e)); + if (isLoading) return ; + const isEditing = true; // TODO: CSS class @@ -275,7 +292,7 @@ export const TagEditPanel: React.FC = ({ objectName={tag?.name ?? intl.formatMessage({ id: "tag" })} isNew={isNew} isEditing={isEditing} - onToggleEdit={onCancelEditing} + onToggleEdit={onCancel} onSave={formik.handleSubmit} saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})} onImageChange={onImageChange} diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index df4ce69ea82..10bb49d3ae4 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -222,8 +222,10 @@ export const useFindGallery = (id: string) => { const skip = id === "new"; return GQL.useFindGalleryQuery({ variables: { id }, skip }); }; -export const useFindScene = (id: string) => - GQL.useFindSceneQuery({ variables: { id } }); +export const useFindScene = (id: string) => { + const skip = id === "new"; + return GQL.useFindSceneQuery({ variables: { id }, skip }); +}; export const useSceneStreams = (id: string) => GQL.useSceneStreamsQuery({ variables: { id } }); From 88179ed54e56ba627a32bdc50078b60fef8e3393 Mon Sep 17 00:00:00 2001 From: CJ <72030708+Teda1@users.noreply.github.com> Date: Tue, 30 May 2023 20:04:38 -0500 Subject: [PATCH 044/135] Adds videojs-vr support (#3636) * Add button for VR mode * fix canvas disapearing * allow user to specify vr tag --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- ui/v2.5/package.json | 1 + ui/v2.5/public/vr.svg | 5 + .../components/ScenePlayer/ScenePlayer.tsx | 19 ++- .../src/components/ScenePlayer/styles.scss | 11 ++ ui/v2.5/src/components/ScenePlayer/vrmode.ts | 146 ++++++++++++++++++ .../Scenes/SceneDetails/SceneEditPanel.tsx | 6 - .../SceneDetails/SceneVideoFilterPanel.tsx | 4 +- .../SettingsInterfacePanel.tsx | 7 + ui/v2.5/src/core/config.ts | 1 + ui/v2.5/src/locales/en-GB.json | 6 +- ui/v2.5/yarn.lock | 49 +++++- 11 files changed, 245 insertions(+), 10 deletions(-) create mode 100644 ui/v2.5/public/vr.svg create mode 100644 ui/v2.5/src/components/ScenePlayer/vrmode.ts diff --git a/ui/v2.5/package.json b/ui/v2.5/package.json index c1ce1575000..47356a9d6d1 100644 --- a/ui/v2.5/package.json +++ b/ui/v2.5/package.json @@ -70,6 +70,7 @@ "videojs-contrib-dash": "^5.1.1", "videojs-mobile-ui": "^0.8.0", "videojs-seek-buttons": "^3.0.1", + "videojs-vr": "^2.0.0", "videojs-vtt.js": "^0.15.4", "yup": "^1.0.0" }, diff --git a/ui/v2.5/public/vr.svg b/ui/v2.5/public/vr.svg new file mode 100644 index 00000000000..2c6c29773ab --- /dev/null +++ b/ui/v2.5/public/vr.svg @@ -0,0 +1,5 @@ + + diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index c553fa3cf14..0eef9452871 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -20,6 +20,7 @@ import "./markers"; import "./vtt-thumbnails"; import "./big-buttons"; import "./track-activity"; +import "./vrmode"; import cx from "classnames"; import { useSceneSaveActivity, @@ -213,6 +214,7 @@ export const ScenePlayer: React.FC = ({ const minimumPlayPercent = uiConfig?.minimumPlayPercent ?? 0; const trackActivity = uiConfig?.trackActivity ?? false; + const vrTag = uiConfig?.vrTag ?? undefined; const file = useMemo( () => ((scene?.files.length ?? 0) > 0 ? scene?.files[0] : undefined), @@ -265,6 +267,16 @@ export const ScenePlayer: React.FC = ({ // Initialize VideoJS player useEffect(() => { + function isVrScene() { + if (!scene?.id || !vrTag) return false; + + return scene?.tags.some((tag) => { + if (vrTag == tag.name) { + return true; + } + }); + } + const options: VideoJsPlayerOptions = { id: VIDEO_PLAYER_ID, controls: true, @@ -318,11 +330,15 @@ export const ScenePlayer: React.FC = ({ }, skipButtons: {}, trackActivity: {}, + vrMenu: { + showButton: isVrScene(), + }, }, }; const videoEl = document.createElement("video-js"); videoEl.setAttribute("data-vjs-player", "true"); + videoEl.setAttribute("crossorigin", "anonymous"); videoEl.classList.add("vjs-big-play-centered"); videoRef.current!.appendChild(videoEl); @@ -348,7 +364,7 @@ export const ScenePlayer: React.FC = ({ // reset sceneId to force reload sources sceneId.current = undefined; }; - }, []); + }, [scene, vrTag]); useEffect(() => { const player = getPlayer(); @@ -662,6 +678,7 @@ export const ScenePlayer: React.FC = ({ }, [ getPlayer, scene, + vrTag, trackActivity, minimumPlayPercent, sceneIncrementPlayCount, diff --git a/ui/v2.5/src/components/ScenePlayer/styles.scss b/ui/v2.5/src/components/ScenePlayer/styles.scss index c8bee39ea95..63cc0bc3c4c 100644 --- a/ui/v2.5/src/components/ScenePlayer/styles.scss +++ b/ui/v2.5/src/components/ScenePlayer/styles.scss @@ -189,6 +189,17 @@ $sceneTabWidth: 450px; } } + .vjs-vr-selector { + .vjs-menu li { + font-size: 0.8em; + } + + .vjs-button { + background: url("/vr.svg") center center no-repeat; + width: 50%; + } + } + .vjs-marker { background-color: rgba(33, 33, 33, 0.8); bottom: 0; diff --git a/ui/v2.5/src/components/ScenePlayer/vrmode.ts b/ui/v2.5/src/components/ScenePlayer/vrmode.ts new file mode 100644 index 00000000000..93459ab8664 --- /dev/null +++ b/ui/v2.5/src/components/ScenePlayer/vrmode.ts @@ -0,0 +1,146 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import videojs, { VideoJsPlayer } from "video.js"; +import "videojs-vr"; + +export interface VRMenuOptions { + /** + * Whether to show the vr button. + * @default false + */ + showButton?: boolean; +} + +enum VRType { + Spherical = "360", + Off = "Off", +} + +const vrTypeProjection = { + [VRType.Spherical]: "360", + [VRType.Off]: "NONE", +}; + +function isVrDevice() { + return navigator.userAgent.match(/oculusbrowser|\svr\s/i); +} + +class VRMenuItem extends videojs.getComponent("MenuItem") { + public type: VRType; + public isSelected = false; + + constructor(parent: VRMenuButton, type: VRType) { + const options = {} as videojs.MenuItemOptions; + options.selectable = true; + options.multiSelectable = false; + options.label = type; + + super(parent.player(), options); + + this.type = type; + + this.addClass("vjs-source-menu-item"); + } + + selected(selected: boolean): void { + super.selected(selected); + this.isSelected = selected; + } + + handleClick() { + if (this.isSelected) return; + + this.trigger("selected"); + } +} + +class VRMenuButton extends videojs.getComponent("MenuButton") { + private items: VRMenuItem[] = []; + private selectedType: VRType = VRType.Off; + + constructor(player: VideoJsPlayer) { + super(player); + this.setTypes(); + } + + private onSelected(item: VRMenuItem) { + this.selectedType = item.type; + + this.items.forEach((i) => { + i.selected(i.type === this.selectedType); + }); + + this.trigger("typeselected", item.type); + } + + public setTypes() { + this.items = Object.values(VRType).map((type) => { + const item = new VRMenuItem(this, type); + + item.on("selected", () => { + this.onSelected(item); + }); + + return item; + }); + this.update(); + } + + createEl() { + return videojs.dom.createEl("div", { + className: + "vjs-vr-selector vjs-menu-button vjs-menu-button-popup vjs-control vjs-button", + }); + } + + createItems() { + if (this.items === undefined) return []; + + for (const item of this.items) { + item.selected(item.type === this.selectedType); + } + + return this.items; + } +} + +class VRMenuPlugin extends videojs.getPlugin("plugin") { + private menu: VRMenuButton; + + constructor(player: VideoJsPlayer, options: VRMenuOptions) { + super(player); + + this.menu = new VRMenuButton(player); + + if (isVrDevice() || !options.showButton) return; + + this.menu.on("typeselected", (_, type: VRType) => { + const projection = vrTypeProjection[type]; + player.vr({ projection }); + player.load(); + }); + + player.on("ready", () => { + const { controlBar } = player; + const fullscreenToggle = controlBar.getChild("fullscreenToggle")!.el(); + controlBar.addChild(this.menu); + controlBar.el().insertBefore(this.menu.el(), fullscreenToggle); + }); + } +} + +// Register the plugin with video.js. +videojs.registerComponent("VRMenuButton", VRMenuButton); +videojs.registerPlugin("vrMenu", VRMenuPlugin); + +/* eslint-disable @typescript-eslint/naming-convention */ +declare module "video.js" { + interface VideoJsPlayer { + vrMenu: () => VRMenuPlugin; + vr: (options: Object) => void; + } + interface VideoJsPlayerPluginOptions { + vrMenu?: VRMenuOptions; + } +} + +export default VRMenuPlugin; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index 840621e33ed..4e11ef6eb66 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -743,7 +743,6 @@ export const SceneEditPanel: React.FC = ({ /> - {FormUtils.renderLabel({ title: intl.formatMessage({ id: "date" }), @@ -756,7 +755,6 @@ export const SceneEditPanel: React.FC = ({ /> - {renderTextField( "director", intl.formatMessage({ id: "director" }) @@ -790,7 +788,6 @@ export const SceneEditPanel: React.FC = ({ /> - {FormUtils.renderLabel({ title: intl.formatMessage({ id: "studio" }), @@ -811,7 +808,6 @@ export const SceneEditPanel: React.FC = ({ /> - {FormUtils.renderLabel({ title: intl.formatMessage({ id: "performers" }), @@ -834,7 +830,6 @@ export const SceneEditPanel: React.FC = ({ /> - {FormUtils.renderLabel({ title: `${intl.formatMessage({ @@ -857,7 +852,6 @@ export const SceneEditPanel: React.FC = ({ {renderTableMovies()} - {FormUtils.renderLabel({ title: intl.formatMessage({ id: "tags" }), diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneVideoFilterPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneVideoFilterPanel.tsx index f70451d4e5a..5de8b045aac 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneVideoFilterPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneVideoFilterPanel.tsx @@ -111,7 +111,9 @@ export const SceneVideoFilterPanel: React.FC = ( function updateVideoStyle() { const playerVideoContainer = document.getElementById(VIDEO_PLAYER_ID); const videoElements = - playerVideoContainer?.getElementsByTagName("video") ?? []; + playerVideoContainer?.getElementsByTagName("canvas") ?? + playerVideoContainer?.getElementsByTagName("video") ?? + []; const playerVideoElement = videoElements.length > 0 ? videoElements[0] : null; diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index 97866b4d406..c44f3ab78b3 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -290,6 +290,13 @@ export const SettingsInterfacePanel: React.FC = () => { checked={ui.trackActivity ?? undefined} onChange={(v) => saveUI({ trackActivity: v })} /> + saveUI({ vrTag: v })} + /> id="ignore-interval" headingID="config.ui.minimum_play_percent.heading" diff --git a/ui/v2.5/src/core/config.ts b/ui/v2.5/src/core/config.ts index 8ca489bf371..90e11742c54 100644 --- a/ui/v2.5/src/core/config.ts +++ b/ui/v2.5/src/core/config.ts @@ -59,6 +59,7 @@ export interface IUIConfig { lastNoteSeen?: number; + vrTag?: string; pinnedFilters?: PinnedFilters; } diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index d0727867650..0eafd4bca21 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -658,7 +658,11 @@ "heading": "Continue playlist by default" }, "show_scrubber": "Show Scrubber", - "track_activity": "Track Activity" + "track_activity": "Track Activity", + "vr_tag": { + "description": "The VR button will only be displayed for scenes with this tag.", + "heading": "VR Tag" + } } }, "scene_wall": { diff --git a/ui/v2.5/yarn.lock b/ui/v2.5/yarn.lock index 8725ca14e02..3d150f8d1a4 100644 --- a/ui/v2.5/yarn.lock +++ b/ui/v2.5/yarn.lock @@ -1081,7 +1081,7 @@ dependencies: regenerator-runtime "^0.13.11" -"@babel/runtime@^7.8.4": +"@babel/runtime@^7.14.5", "@babel/runtime@^7.8.4": version "7.21.0" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.0.tgz#5b55c9d394e5fcf304909a8b00c07dc217b56673" integrity sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw== @@ -3211,6 +3211,15 @@ capital-case@^1.0.4: tslib "^2.0.3" upper-case-first "^2.0.2" +cardboard-vr-display@^1.0.19: + version "1.0.19" + resolved "https://registry.yarnpkg.com/cardboard-vr-display/-/cardboard-vr-display-1.0.19.tgz#81dcde1804b329b8228b757ac00e1fd2afa9d748" + integrity sha512-+MjcnWKAkb95p68elqZLDPzoiF/dGncQilLGvPBM5ZorABp/ao3lCs7nnRcYBckmuNkg1V/5rdGDKoUaCVsHzQ== + dependencies: + gl-preserve-state "^1.0.0" + nosleep.js "^0.7.0" + webvr-polyfill-dpdb "^1.0.17" + ccount@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.1.0.tgz#246687debb6014735131be8abab2d93898f8d043" @@ -4455,6 +4464,11 @@ get-symbol-description@^1.0.0: call-bind "^1.0.2" get-intrinsic "^1.1.1" +gl-preserve-state@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/gl-preserve-state/-/gl-preserve-state-1.0.0.tgz#4ef710d62873f1470ed015c6546c37dacddd4198" + integrity sha512-zQZ25l3haD4hvgJZ6C9+s0ebdkW9y+7U2qxvGu1uWOJh8a4RU+jURIKEQhf8elIlFpMH6CrAY2tH0mYrRjet3Q== + glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" @@ -6002,6 +6016,11 @@ normalize-url@^4.5.1: resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.1.tgz#0dd90cf1288ee1d1313b87081c9a5932ee48518a" integrity sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA== +nosleep.js@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/nosleep.js/-/nosleep.js-0.7.0.tgz#cfd919c25523ca0d0f4a69fb3305c083adaee289" + integrity sha512-Z4B1HgvzR+en62ghwZf6BwAR6x4/pjezsiMcbF9KMLh7xoscpoYhaSXfY3lLkqC68AtW+/qLJ1lzvBIj0FGaTA== + nullthrows@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/nullthrows/-/nullthrows-1.1.1.tgz#7818258843856ae971eae4208ad7d7eb19a431b1" @@ -7471,6 +7490,11 @@ thehandy@^1.0.3: resolved "https://registry.yarnpkg.com/thehandy/-/thehandy-1.0.3.tgz#51c5e9bae5932a6e5c563203711d78610b99d402" integrity sha512-zuuyWKBx/jqku9+MZkdkoK2oLM2mS8byWVR/vkQYq/ygAT6gPAXwiT94rfGuqv+1BLmsyJxm69nhVIzOZjfyIg== +three@0.125.2: + version "0.125.2" + resolved "https://registry.yarnpkg.com/three/-/three-0.125.2.tgz#dcba12749a2eb41522e15212b919cd3fbf729b12" + integrity sha512-7rIRO23jVKWcAPFdW/HREU2NZMGWPBZ4XwEMt0Ak0jwLUKVJhcKM55eCBWyGZq/KiQbeo1IeuAoo/9l2dzhTXA== + throttle-debounce@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-5.0.0.tgz#a17a4039e82a2ed38a5e7268e4132d6960d41933" @@ -7975,6 +7999,17 @@ videojs-seek-buttons@^3.0.1: dependencies: global "^4.4.0" +videojs-vr@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/videojs-vr/-/videojs-vr-2.0.0.tgz#3d86e3fececf7373cfb89b950ed6ab77ca783d2b" + integrity sha512-ix4iN8XHaDSEe89Jqybj9DuLKYuK33EIzcSI0IEdnv1KJuH8bd0PYlQEgqIZTOmWruFpW/+rjYFCVUQ9PTypJw== + dependencies: + "@babel/runtime" "^7.14.5" + global "^4.4.0" + three "0.125.2" + video.js "^6 || ^7" + webvr-polyfill "0.10.12" + videojs-vtt.js@^0.15.4: version "0.15.4" resolved "https://registry.yarnpkg.com/videojs-vtt.js/-/videojs-vtt.js-0.15.4.tgz#5dc5aabcd82ba40c5595469bd855ea8230ca152c" @@ -8052,6 +8087,18 @@ webidl-conversions@^3.0.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== +webvr-polyfill-dpdb@^1.0.17: + version "1.0.18" + resolved "https://registry.yarnpkg.com/webvr-polyfill-dpdb/-/webvr-polyfill-dpdb-1.0.18.tgz#258484ce06b057bf18898acc911bd173847bce11" + integrity sha512-O0S1ZGEWyPvyZEkS2VbyV7mtir/NM9MNK3EuhbHPoJ8EHTky2pTXehjIl+IiDPr+Lldgx129QGt3NGly7rwRPw== + +webvr-polyfill@0.10.12: + version "0.10.12" + resolved "https://registry.yarnpkg.com/webvr-polyfill/-/webvr-polyfill-0.10.12.tgz#47ea0b0d558f09e089bc49fa7b47a4ee7e4b8148" + integrity sha512-trDJEVUQnRIVAnmImjEQ0BlL1NfuWl8+eaEdu+bs4g59c7OtETi/5tFkgEFDRaWEYwHntXs/uFF3OXZuutNGGA== + dependencies: + cardboard-vr-display "^1.0.19" + whatwg-fetch@^3.4.1: version "3.6.2" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz#dced24f37f2624ed0281725d51d0e2e3fe677f8c" From 9c8a6ee495cb4dcdeda1ff73014b94ce47bccbbd Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 31 May 2023 11:05:28 +1000 Subject: [PATCH 045/135] Male performer images (#3770) * Apply cis gender images to default transgender images * Replace male images with consistent ones --- internal/api/images.go | 4 ++-- internal/static/performer_male/Male01.png | Bin 0 -> 29574 bytes internal/static/performer_male/Male02.png | Bin 0 -> 27367 bytes internal/static/performer_male/Male03.png | Bin 0 -> 26475 bytes internal/static/performer_male/Male04.png | Bin 0 -> 26600 bytes internal/static/performer_male/Male05.png | Bin 0 -> 25812 bytes internal/static/performer_male/Male06.png | Bin 0 -> 31704 bytes .../static/performer_male/noname_male_01.jpg | Bin 27775 -> 0 bytes .../static/performer_male/noname_male_02.jpg | Bin 15631 -> 0 bytes 9 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 internal/static/performer_male/Male01.png create mode 100644 internal/static/performer_male/Male02.png create mode 100644 internal/static/performer_male/Male03.png create mode 100644 internal/static/performer_male/Male04.png create mode 100644 internal/static/performer_male/Male05.png create mode 100644 internal/static/performer_male/Male06.png delete mode 100644 internal/static/performer_male/noname_male_01.jpg delete mode 100644 internal/static/performer_male/noname_male_02.jpg diff --git a/internal/api/images.go b/internal/api/images.go index 7ddbbfc1051..95ed4c8447f 100644 --- a/internal/api/images.go +++ b/internal/api/images.go @@ -102,9 +102,9 @@ func getRandomPerformerImageUsingName(name string, gender *models.GenderEnum, cu if box == nil { switch g { - case models.GenderEnumFemale: + case models.GenderEnumFemale, models.GenderEnumTransgenderFemale: box = performerBox - case models.GenderEnumMale: + case models.GenderEnumMale, models.GenderEnumTransgenderMale: box = performerBoxMale default: box = performerBox diff --git a/internal/static/performer_male/Male01.png b/internal/static/performer_male/Male01.png new file mode 100644 index 0000000000000000000000000000000000000000..8a486299ab6fc4ea7ab3cbd98fc3778ff84f7162 GIT binary patch literal 29574 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 literal 0 HcmV?d00001 diff --git a/internal/static/performer_male/Male02.png b/internal/static/performer_male/Male02.png new file mode 100644 index 0000000000000000000000000000000000000000..673b120eb43392fbac967b16b2d2c167a8dfe815 GIT binary patch literal 27367 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 z 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 diff --git a/internal/static/performer_male/noname_male_02.jpg b/internal/static/performer_male/noname_male_02.jpg deleted file mode 100644 index 93ad7ec9dd8e8a3db3efac695a484a1c73752853..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15631 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 Date: Wed, 31 May 2023 11:06:01 +1000 Subject: [PATCH 046/135] Update gallery UpdatedAt timestamp on contents change (#3771) * Update gallery updatedAt on content change * Update gallery in UI on image change --- internal/api/resolver_mutation_image.go | 23 +++++++ internal/manager/repository.go | 2 + pkg/gallery/service.go | 5 ++ pkg/gallery/update.go | 27 ++++++-- pkg/models/update.go | 42 +++++++++++ pkg/models/update_test.go | 92 +++++++++++++++++++++++++ ui/v2.5/src/core/StashService.ts | 2 + 7 files changed, 187 insertions(+), 6 deletions(-) create mode 100644 pkg/models/update_test.go diff --git a/internal/api/resolver_mutation_image.go b/internal/api/resolver_mutation_image.go index 353dab744ee..24b81967a6f 100644 --- a/internal/api/resolver_mutation_image.go +++ b/internal/api/resolver_mutation_image.go @@ -10,6 +10,7 @@ import ( "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/plugin" + "github.com/stashapp/stash/pkg/sliceutil/intslice" "github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/utils" ) @@ -138,6 +139,8 @@ func (r *mutationResolver) imageUpdate(ctx context.Context, input ImageUpdateInp } } + var updatedGalleryIDs []int + if translator.hasField("gallery_ids") { updatedImage.GalleryIDs, err = translateUpdateIDs(input.GalleryIds, models.RelationshipUpdateModeSet) if err != nil { @@ -152,6 +155,8 @@ func (r *mutationResolver) imageUpdate(ctx context.Context, input ImageUpdateInp if err := r.galleryService.ValidateImageGalleryChange(ctx, i, *updatedImage.GalleryIDs); err != nil { return nil, err } + + updatedGalleryIDs = updatedImage.GalleryIDs.ImpactedIDs(i.GalleryIDs.List()) } if translator.hasField("performer_ids") { @@ -174,6 +179,13 @@ func (r *mutationResolver) imageUpdate(ctx context.Context, input ImageUpdateInp return nil, err } + // #3759 - update all impacted galleries + for _, galleryID := range updatedGalleryIDs { + if err := r.galleryService.Updated(ctx, galleryID); err != nil { + return nil, fmt.Errorf("updating gallery %d: %w", galleryID, err) + } + } + return image, nil } @@ -223,6 +235,7 @@ func (r *mutationResolver) BulkImageUpdate(ctx context.Context, input BulkImageU // Start the transaction and save the image marker if err := r.withTxn(ctx, func(ctx context.Context) error { + var updatedGalleryIDs []int qb := r.repository.Image for _, imageID := range imageIDs { @@ -244,6 +257,9 @@ func (r *mutationResolver) BulkImageUpdate(ctx context.Context, input BulkImageU if err := r.galleryService.ValidateImageGalleryChange(ctx, i, *updatedImage.GalleryIDs); err != nil { return err } + + thisUpdatedGalleryIDs := updatedImage.GalleryIDs.ImpactedIDs(i.GalleryIDs.List()) + updatedGalleryIDs = intslice.IntAppendUniques(updatedGalleryIDs, thisUpdatedGalleryIDs) } image, err := qb.UpdatePartial(ctx, imageID, updatedImage) @@ -254,6 +270,13 @@ func (r *mutationResolver) BulkImageUpdate(ctx context.Context, input BulkImageU ret = append(ret, image) } + // #3759 - update all impacted galleries + for _, galleryID := range updatedGalleryIDs { + if err := r.galleryService.Updated(ctx, galleryID); err != nil { + return fmt.Errorf("updating gallery %d: %w", galleryID, err) + } + } + return nil }); err != nil { return nil, err diff --git a/internal/manager/repository.go b/internal/manager/repository.go index dd49c4af763..55fea16724a 100644 --- a/internal/manager/repository.go +++ b/internal/manager/repository.go @@ -113,4 +113,6 @@ type GalleryService interface { Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) ValidateImageGalleryChange(ctx context.Context, i *models.Image, updateIDs models.UpdateIDs) error + + Updated(ctx context.Context, galleryID int) error } diff --git a/pkg/gallery/service.go b/pkg/gallery/service.go index acf70763f20..7dfc3857f5d 100644 --- a/pkg/gallery/service.go +++ b/pkg/gallery/service.go @@ -18,6 +18,11 @@ type Repository interface { Destroy(ctx context.Context, id int) error models.FileLoader ImageUpdater + PartialUpdater +} + +type PartialUpdater interface { + UpdatePartial(ctx context.Context, id int, updatedGallery models.GalleryPartial) (*models.Gallery, error) } type ImageFinder interface { diff --git a/pkg/gallery/update.go b/pkg/gallery/update.go index 5350499ac1c..72f479bea99 100644 --- a/pkg/gallery/update.go +++ b/pkg/gallery/update.go @@ -2,20 +2,25 @@ package gallery import ( "context" + "fmt" + "time" "github.com/stashapp/stash/pkg/models" ) -type PartialUpdater interface { - UpdatePartial(ctx context.Context, id int, updatedGallery models.GalleryPartial) (*models.Gallery, error) -} - type ImageUpdater interface { GetImageIDs(ctx context.Context, galleryID int) ([]int, error) AddImages(ctx context.Context, galleryID int, imageIDs ...int) error RemoveImages(ctx context.Context, galleryID int, imageIDs ...int) error } +func (s *Service) Updated(ctx context.Context, galleryID int) error { + _, err := s.Repository.UpdatePartial(ctx, galleryID, models.GalleryPartial{ + UpdatedAt: models.NewOptionalTime(time.Now()), + }) + return err +} + // AddImages adds images to the provided gallery. // It returns an error if the gallery does not support adding images, or if // the operation fails. @@ -24,7 +29,12 @@ func (s *Service) AddImages(ctx context.Context, g *models.Gallery, toAdd ...int return err } - return s.Repository.AddImages(ctx, g.ID, toAdd...) + if err := s.Repository.AddImages(ctx, g.ID, toAdd...); err != nil { + return fmt.Errorf("failed to add images to gallery: %w", err) + } + + // #3759 - update the gallery's UpdatedAt timestamp + return s.Updated(ctx, g.ID) } // RemoveImages removes images from the provided gallery. @@ -36,7 +46,12 @@ func (s *Service) RemoveImages(ctx context.Context, g *models.Gallery, toRemove return err } - return s.Repository.RemoveImages(ctx, g.ID, toRemove...) + if err := s.Repository.RemoveImages(ctx, g.ID, toRemove...); err != nil { + return fmt.Errorf("failed to remove images from gallery: %w", err) + } + + // #3759 - update the gallery's UpdatedAt timestamp + return s.Updated(ctx, g.ID) } func AddPerformer(ctx context.Context, qb PartialUpdater, o *models.Gallery, performerID int) error { diff --git a/pkg/models/update.go b/pkg/models/update.go index fbfab3d3029..ffa793bdaad 100644 --- a/pkg/models/update.go +++ b/pkg/models/update.go @@ -64,6 +64,48 @@ func (u *UpdateIDs) IDStrings() []string { return intslice.IntSliceToStringSlice(u.IDs) } +// GetImpactedIDs returns the IDs that will be impacted by the update. +// If the update is to add IDs, then the impacted IDs are the IDs being added. +// If the update is to remove IDs, then the impacted IDs are the IDs being removed. +// If the update is to set IDs, then the impacted IDs are the IDs being removed and the IDs being added. +// Any IDs that are already present and are being added are not returned. +// Likewise, any IDs that are not present that are being removed are not returned. +func (u *UpdateIDs) ImpactedIDs(existing []int) []int { + if u == nil { + return nil + } + + switch u.Mode { + case RelationshipUpdateModeAdd: + return intslice.IntExclude(u.IDs, existing) + case RelationshipUpdateModeRemove: + return intslice.IntIntercect(existing, u.IDs) + case RelationshipUpdateModeSet: + // get the difference between the two lists + return intslice.IntNotIntersect(existing, u.IDs) + } + + return nil +} + +// GetEffectiveIDs returns the new IDs that will be effective after the update. +func (u *UpdateIDs) EffectiveIDs(existing []int) []int { + if u == nil { + return nil + } + + switch u.Mode { + case RelationshipUpdateModeAdd: + return intslice.IntAppendUniques(existing, u.IDs) + case RelationshipUpdateModeRemove: + return intslice.IntExclude(existing, u.IDs) + case RelationshipUpdateModeSet: + return u.IDs + } + + return nil +} + type UpdateStrings struct { Values []string `json:"values"` Mode RelationshipUpdateMode `json:"mode"` diff --git a/pkg/models/update_test.go b/pkg/models/update_test.go new file mode 100644 index 00000000000..0baf7926f7a --- /dev/null +++ b/pkg/models/update_test.go @@ -0,0 +1,92 @@ +package models + +import ( + "reflect" + "testing" +) + +func TestUpdateIDs_ImpactedIDs(t *testing.T) { + tests := []struct { + name string + IDs []int + Mode RelationshipUpdateMode + existing []int + want []int + }{ + { + name: "add", + IDs: []int{1, 2, 3}, + Mode: RelationshipUpdateModeAdd, + existing: []int{1, 2}, + want: []int{3}, + }, + { + name: "remove", + IDs: []int{1, 2, 3}, + Mode: RelationshipUpdateModeRemove, + existing: []int{1, 2}, + want: []int{1, 2}, + }, + { + name: "set", + IDs: []int{1, 2, 3}, + Mode: RelationshipUpdateModeSet, + existing: []int{1, 2}, + want: []int{3}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u := &UpdateIDs{ + IDs: tt.IDs, + Mode: tt.Mode, + } + if got := u.ImpactedIDs(tt.existing); !reflect.DeepEqual(got, tt.want) { + t.Errorf("UpdateIDs.ImpactedIDs() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestUpdateIDs_EffectiveIDs(t *testing.T) { + tests := []struct { + name string + IDs []int + Mode RelationshipUpdateMode + existing []int + want []int + }{ + { + name: "add", + IDs: []int{2, 3}, + Mode: RelationshipUpdateModeAdd, + existing: []int{1, 2}, + want: []int{1, 2, 3}, + }, + { + name: "remove", + IDs: []int{2, 3}, + Mode: RelationshipUpdateModeRemove, + existing: []int{1, 2}, + want: []int{1}, + }, + { + name: "set", + IDs: []int{1, 2, 3}, + Mode: RelationshipUpdateModeSet, + existing: []int{1, 2}, + want: []int{1, 2, 3}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u := &UpdateIDs{ + IDs: tt.IDs, + Mode: tt.Mode, + } + if got := u.EffectiveIDs(tt.existing); !reflect.DeepEqual(got, tt.want) { + t.Errorf("UpdateIDs.EffectiveIDs() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 10bb49d3ae4..7e79db3b164 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -734,6 +734,7 @@ export const mutateAddGalleryImages = (input: GQL.GalleryAddInput) => mutation: GQL.AddGalleryImagesDocument, variables: input, update: deleteCache(galleryMutationImpactedQueries), + refetchQueries: getQueryNames([GQL.FindGalleryDocument]), }); export const mutateRemoveGalleryImages = (input: GQL.GalleryRemoveInput) => @@ -741,6 +742,7 @@ export const mutateRemoveGalleryImages = (input: GQL.GalleryRemoveInput) => mutation: GQL.RemoveGalleryImagesDocument, variables: input, update: deleteCache(galleryMutationImpactedQueries), + refetchQueries: getQueryNames([GQL.FindGalleryDocument]), }); export const mutateGallerySetPrimaryFile = (id: string, fileID: string) => From 94450da8b5385d7c7d79ab5edffc655f5a4adedd Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 31 May 2023 11:42:28 +1000 Subject: [PATCH 047/135] Use string criterion for name (#3788) --- ui/v2.5/src/models/list-filter/criteria/factory.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/v2.5/src/models/list-filter/criteria/factory.ts b/ui/v2.5/src/models/list-filter/criteria/factory.ts index 311b7872821..5096a14b0f3 100644 --- a/ui/v2.5/src/models/list-filter/criteria/factory.ts +++ b/ui/v2.5/src/models/list-filter/criteria/factory.ts @@ -66,6 +66,9 @@ export function makeCriteria( case "none": return new NoneCriterion(); case "name": + return new StringCriterion( + new MandatoryStringCriterionOption(type, type) + ); case "path": return new StringCriterion(new PathCriterionOption(type, type)); case "checksum": From c8a796e12582389163a4e9bfabd0de96e2dba63b Mon Sep 17 00:00:00 2001 From: CJ <72030708+Teda1@users.noreply.github.com> Date: Thu, 1 Jun 2023 20:13:28 -0500 Subject: [PATCH 048/135] Fixes video filter issues (#3792) --- .../SceneDetails/SceneVideoFilterPanel.tsx | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneVideoFilterPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneVideoFilterPanel.tsx index 5de8b045aac..e547e750f7d 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneVideoFilterPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneVideoFilterPanel.tsx @@ -108,15 +108,26 @@ export const SceneVideoFilterPanel: React.FC = ( aspectRatioRange.default ); + // eslint-disable-next-line + function getVideoElement(playerVideoContainer: any) { + let videoElements = playerVideoContainer.getElementsByTagName("canvas"); + + if (videoElements.length == 0) { + videoElements = playerVideoContainer.getElementsByTagName("video"); + } + + if (videoElements.length > 0) { + return videoElements[0]; + } + } + function updateVideoStyle() { - const playerVideoContainer = document.getElementById(VIDEO_PLAYER_ID); - const videoElements = - playerVideoContainer?.getElementsByTagName("canvas") ?? - playerVideoContainer?.getElementsByTagName("video") ?? - []; - const playerVideoElement = - videoElements.length > 0 ? videoElements[0] : null; + const playerVideoContainer = document.getElementById(VIDEO_PLAYER_ID)!; + if (!playerVideoContainer) { + return; + } + const playerVideoElement = getVideoElement(playerVideoContainer); if (playerVideoElement != null) { let styleString = "filter:"; let style = playerVideoElement.attributes.getNamedItem("style"); @@ -188,6 +199,10 @@ export const SceneVideoFilterPanel: React.FC = ( styleString += ` scale(${xScale},${yScale})`; } + if (playerVideoElement.tagName == "CANVAS") { + styleString += "; width: 100%; height: 100%; position: absolute; top:0"; + } + style.value = `${styleString};`; } } From 4acf84322901bf5eba92f6d432c1eebee368619f Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Fri, 2 Jun 2023 03:15:33 +0200 Subject: [PATCH 049/135] Fix videojs-vr issues (#3793) * Add videojs-vr.d.ts * Improve dynamic VR toggling --- ui/v2.5/src/@types/videojs-vr.d.ts | 116 ++++++++++++++++++ .../components/ScenePlayer/ScenePlayer.tsx | 32 ++--- ui/v2.5/src/components/ScenePlayer/vrmode.ts | 58 +++++++-- 3 files changed, 181 insertions(+), 25 deletions(-) create mode 100644 ui/v2.5/src/@types/videojs-vr.d.ts diff --git a/ui/v2.5/src/@types/videojs-vr.d.ts b/ui/v2.5/src/@types/videojs-vr.d.ts new file mode 100644 index 00000000000..54111718f37 --- /dev/null +++ b/ui/v2.5/src/@types/videojs-vr.d.ts @@ -0,0 +1,116 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +declare module "videojs-vr" { + import videojs from "video.js"; + + declare function videojsVR(options?: videojsVR.Options): videojsVR.Plugin; + + declare namespace videojsVR { + const VERSION: typeof videojs.VERSION; + + type ProjectionType = + // The video is half sphere and the user should not be able to look behind themselves + | "180" + // Used for side-by-side 180 videos The video is half sphere and the user should not be able to look behind themselves + | "180_LR" + // Used for monoscopic 180 videos The video is half sphere and the user should not be able to look behind themselves + | "180_MONO" + // The video is a sphere + | "360" + | "Sphere" + | "equirectangular" + // The video is a cube + | "360_CUBE" + | "Cube" + // This video is not a 360 video + | "NONE" + // Check player.mediainfo.projection to see if the current video is a 360 video. + | "AUTO" + // Used for side-by-side 360 videos + | "360_LR" + // Used for top-to-bottom 360 videos + | "360_TB" + // Used for Equi-Angular Cubemap videos + | "EAC" + // Used for side-by-side Equi-Angular Cubemap videos + | "EAC_LR"; + + interface Options { + /** + * Force the cardboard button to display on all devices even if we don't think they support it. + * + * @default false + */ + forceCardboard?: boolean; + + /** + * Whether motion/gyro controls should be enabled. + * + * @default true on iOS and Android + */ + motionControls?: boolean; + + /** + * Defines the projection type. + * + * @default "AUTO" + */ + projection?: ProjectionType; + + /** + * This alters the number of segments in the spherical mesh onto which equirectangular videos are projected. + * The default is 32 but in some circumstances you may notice artifacts and need to increase this number. + * + * @default 32 + */ + sphereDetail?: number; + + /** + * Enable debug logging for this plugin + * + * @default false + */ + debug?: boolean; + + /** + * Use this property to pass the Omnitone library object to the plugin. Please be aware of, the Omnitone library is not included in the build files. + */ + omnitone?: object; + + /** + * Default options for the Omnitone library. Please check available options on https://github.com/GoogleChrome/omnitone + */ + omnitoneOptions?: object; + + /** + * Feature to disable the togglePlay manually. This functionality is useful in live events so that users cannot stop the live, but still have a controlBar available. + * + * @default false + */ + disableTogglePlay?: boolean; + } + + interface PlayerMediaInfo { + /** + * This should be set on a source-by-source basis to turn 360 videos on an off depending upon the video. + * Note that AUTO is the same as NONE for player.mediainfo.projection. + */ + projection?: ProjectionType; + } + + class Plugin extends videojs.Plugin { + setProjection(projection: ProjectionType): void; + init(): void; + reset(): void; + } + } + + export = videojsVR; + + declare module "video.js" { + interface VideoJsPlayer { + vr: typeof videojsVR; + mediainfo?: videojsVR.PlayerMediaInfo; + } + } +} diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index 0eef9452871..b4699e454c7 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -267,16 +267,6 @@ export const ScenePlayer: React.FC = ({ // Initialize VideoJS player useEffect(() => { - function isVrScene() { - if (!scene?.id || !vrTag) return false; - - return scene?.tags.some((tag) => { - if (vrTag == tag.name) { - return true; - } - }); - } - const options: VideoJsPlayerOptions = { id: VIDEO_PLAYER_ID, controls: true, @@ -330,9 +320,7 @@ export const ScenePlayer: React.FC = ({ }, skipButtons: {}, trackActivity: {}, - vrMenu: { - showButton: isVrScene(), - }, + vrMenu: {}, }, }; @@ -364,7 +352,8 @@ export const ScenePlayer: React.FC = ({ // reset sceneId to force reload sources sceneId.current = undefined; }; - }, [scene, vrTag]); + // empty deps - only init once + }, []); useEffect(() => { const player = getPlayer(); @@ -388,6 +377,21 @@ export const ScenePlayer: React.FC = ({ scene?.paths.funscript, ]); + useEffect(() => { + const player = getPlayer(); + if (!player) return; + + const vrMenu = player.vrMenu(); + + let showButton = false; + + if (scene && vrTag) { + showButton = scene.tags.some((tag) => vrTag === tag.name); + } + + vrMenu.setShowButton(showButton); + }, [getPlayer, scene, vrTag]); + // Player event handlers useEffect(() => { const player = getPlayer(); diff --git a/ui/v2.5/src/components/ScenePlayer/vrmode.ts b/ui/v2.5/src/components/ScenePlayer/vrmode.ts index 93459ab8664..b11be3364c6 100644 --- a/ui/v2.5/src/components/ScenePlayer/vrmode.ts +++ b/ui/v2.5/src/components/ScenePlayer/vrmode.ts @@ -1,6 +1,9 @@ /* eslint-disable @typescript-eslint/naming-convention */ import videojs, { VideoJsPlayer } from "video.js"; import "videojs-vr"; +// separate type import, otherwise typescript elides the above import +// and the plugin does not get initialized +import type { ProjectionType, Plugin as VideoJsVRPlugin } from "videojs-vr"; export interface VRMenuOptions { /** @@ -15,7 +18,7 @@ enum VRType { Off = "Off", } -const vrTypeProjection = { +const vrTypeProjection: Record = { [VRType.Spherical]: "360", [VRType.Off]: "NONE", }; @@ -29,7 +32,7 @@ class VRMenuItem extends videojs.getComponent("MenuItem") { public isSelected = false; constructor(parent: VRMenuButton, type: VRType) { - const options = {} as videojs.MenuItemOptions; + const options: videojs.MenuItemOptions = {}; options.selectable = true; options.multiSelectable = false; options.label = type; @@ -105,27 +108,61 @@ class VRMenuButton extends videojs.getComponent("MenuButton") { class VRMenuPlugin extends videojs.getPlugin("plugin") { private menu: VRMenuButton; + private showButton: boolean; + private vr?: VideoJsVRPlugin; constructor(player: VideoJsPlayer, options: VRMenuOptions) { super(player); this.menu = new VRMenuButton(player); + this.showButton = options.showButton ?? false; - if (isVrDevice() || !options.showButton) return; + if (isVrDevice()) return; + + this.vr = this.player.vr(); this.menu.on("typeselected", (_, type: VRType) => { - const projection = vrTypeProjection[type]; - player.vr({ projection }); - player.load(); + this.loadVR(type); }); player.on("ready", () => { - const { controlBar } = player; - const fullscreenToggle = controlBar.getChild("fullscreenToggle")!.el(); - controlBar.addChild(this.menu); - controlBar.el().insertBefore(this.menu.el(), fullscreenToggle); + if (this.showButton) { + this.addButton(); + } }); } + + private loadVR(type: VRType) { + const projection = vrTypeProjection[type]; + this.vr?.setProjection(projection); + this.vr?.init(); + } + + private addButton() { + const { controlBar } = this.player; + const fullscreenToggle = controlBar.getChild("fullscreenToggle")!.el(); + controlBar.addChild(this.menu); + controlBar.el().insertBefore(this.menu.el(), fullscreenToggle); + } + + private removeButton() { + const { controlBar } = this.player; + controlBar.removeChild(this.menu); + } + + public setShowButton(showButton: boolean) { + if (isVrDevice()) return; + + if (showButton === this.showButton) return; + + this.showButton = showButton; + if (showButton) { + this.addButton(); + } else { + this.removeButton(); + this.loadVR(VRType.Off); + } + } } // Register the plugin with video.js. @@ -136,7 +173,6 @@ videojs.registerPlugin("vrMenu", VRMenuPlugin); declare module "video.js" { interface VideoJsPlayer { vrMenu: () => VRMenuPlugin; - vr: (options: Object) => void; } interface VideoJsPlayerPluginOptions { vrMenu?: VRMenuOptions; From 256e0a11ea7d090999da2c377ed4fe77378b49dd Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 6 Jun 2023 13:01:50 +1000 Subject: [PATCH 050/135] Fix joined hierarchical filtering (#3775) * Fix joined hierarchical filtering * Fix scene performer tag filter * Generalise performer tag handler * Add unit tests * Add equals handling * Make performer tags equals/not equals unsupported * Make tags not equals unsupported * Make not equals unsupported for performers criterion * Support equals/not equals for studio criterion * Fix marker scene tags equals filter * Fix scene performer tag filter * Make equals/not equals unsupported for hierarchical criterion * Use existing studio handler in movie * Hide unsupported tag modifier options * Use existing performer tags logic where possible * Restore old parent/child filter logic * Disable sub-tags in equals modifier for tags criterion --- pkg/models/filter.go | 11 + pkg/sqlite/filter.go | 192 ++++- pkg/sqlite/gallery.go | 53 +- pkg/sqlite/gallery_test.go | 708 +++++++++++----- pkg/sqlite/image.go | 53 +- pkg/sqlite/image_test.go | 764 ++++++++++++------ pkg/sqlite/movies.go | 15 +- pkg/sqlite/performer.go | 6 +- pkg/sqlite/performer_test.go | 21 +- pkg/sqlite/scene.go | 53 +- pkg/sqlite/scene_marker.go | 39 +- pkg/sqlite/scene_marker_test.go | 112 ++- pkg/sqlite/scene_test.go | 648 +++++++++++---- pkg/sqlite/setup_test.go | 134 +-- pkg/sqlite/tag.go | 178 +++- pkg/sqlite/tag_test.go | 8 +- .../List/Filters/SelectableFilter.tsx | 20 +- .../models/list-filter/criteria/criterion.ts | 7 +- .../src/models/list-filter/criteria/tags.ts | 39 +- 19 files changed, 2138 insertions(+), 923 deletions(-) diff --git a/pkg/models/filter.go b/pkg/models/filter.go index e0f9b7a5492..e9ddf7ab366 100644 --- a/pkg/models/filter.go +++ b/pkg/models/filter.go @@ -135,6 +135,17 @@ type HierarchicalMultiCriterionInput struct { Excludes []string `json:"excludes"` } +func (i HierarchicalMultiCriterionInput) CombineExcludes() HierarchicalMultiCriterionInput { + ii := i + if ii.Modifier == CriterionModifierExcludes { + ii.Modifier = CriterionModifierIncludesAll + ii.Excludes = append(ii.Excludes, ii.Value...) + ii.Value = nil + } + + return ii +} + type MultiCriterionInput struct { Value []string `json:"value"` Modifier CriterionModifier `json:"modifier"` diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index d670dc1a781..5934b2c99d6 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -9,7 +9,6 @@ import ( "strconv" "strings" - "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/utils" "github.com/stashapp/stash/pkg/models" @@ -694,6 +693,8 @@ func (m *joinedMultiCriterionHandlerBuilder) handler(c *models.MultiCriterionInp }) havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value)) args = append(args, len(criterion.Value)) + case models.CriterionModifierNotEquals: + f.setError(fmt.Errorf("not equals modifier is not supported for multi criterion input")) case models.CriterionModifierIncludesAll: // includes all of the provided ids m.addJoinTable(f) @@ -830,6 +831,33 @@ func (m *stringListCriterionHandlerBuilder) handler(criterion *models.StringCrit } } +func studioCriterionHandler(primaryTable string, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if studios == nil { + return + } + + studiosCopy := *studios + switch studiosCopy.Modifier { + case models.CriterionModifierEquals: + studiosCopy.Modifier = models.CriterionModifierIncludesAll + case models.CriterionModifierNotEquals: + studiosCopy.Modifier = models.CriterionModifierExcludes + } + + hh := hierarchicalMultiCriterionHandlerBuilder{ + tx: dbWrapper{}, + + primaryTable: primaryTable, + foreignTable: studioTable, + foreignFK: studioIDColumn, + parentFK: "parent_id", + } + + hh.handler(&studiosCopy)(ctx, f) + } +} + type hierarchicalMultiCriterionHandlerBuilder struct { tx dbWrapper @@ -838,12 +866,20 @@ type hierarchicalMultiCriterionHandlerBuilder struct { foreignFK string parentFK string + childFK string relationsTable string } -func getHierarchicalValues(ctx context.Context, tx dbWrapper, values []string, table, relationsTable, parentFK string, depth *int) string { +func getHierarchicalValues(ctx context.Context, tx dbWrapper, values []string, table, relationsTable, parentFK string, childFK string, depth *int) (string, error) { var args []interface{} + if parentFK == "" { + parentFK = "parent_id" + } + if childFK == "" { + childFK = "child_id" + } + depthVal := 0 if depth != nil { depthVal = *depth @@ -865,7 +901,7 @@ func getHierarchicalValues(ctx context.Context, tx dbWrapper, values []string, t } if valid { - return "VALUES" + strings.Join(valuesClauses, ",") + return "VALUES" + strings.Join(valuesClauses, ","), nil } } @@ -885,13 +921,14 @@ func getHierarchicalValues(ctx context.Context, tx dbWrapper, values []string, t "inBinding": getInBinding(inCount), "recursiveSelect": "", "parentFK": parentFK, + "childFK": childFK, "depthCondition": depthCondition, "unionClause": "", } if relationsTable != "" { - withClauseMap["recursiveSelect"] = utils.StrFormat(`SELECT p.root_id, c.child_id, depth + 1 FROM {relationsTable} AS c -INNER JOIN items as p ON c.parent_id = p.item_id + withClauseMap["recursiveSelect"] = utils.StrFormat(`SELECT p.root_id, c.{childFK}, depth + 1 FROM {relationsTable} AS c +INNER JOIN items as p ON c.{parentFK} = p.item_id `, withClauseMap) } else { withClauseMap["recursiveSelect"] = utils.StrFormat(`SELECT p.root_id, c.id, depth + 1 FROM {table} as c @@ -916,12 +953,10 @@ WHERE id in {inBinding} var valuesClause string err := tx.Get(ctx, &valuesClause, query, args...) if err != nil { - logger.Error(err) - // return record which never matches so we don't have to handle error here - return "VALUES(NULL, NULL)" + return "", fmt.Errorf("failed to get hierarchical values: %w", err) } - return valuesClause + return valuesClause, nil } func addHierarchicalConditionClauses(f *filterBuilder, criterion models.HierarchicalMultiCriterionInput, table, idColumn string) { @@ -942,6 +977,12 @@ func (m *hierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hierarchica // make a copy so we don't modify the original criterion := *c + // don't support equals/not equals + if criterion.Modifier == models.CriterionModifierEquals || criterion.Modifier == models.CriterionModifierNotEquals { + f.setError(fmt.Errorf("modifier %s is not supported for hierarchical multi criterion", criterion.Modifier)) + return + } + if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { var notClause string if criterion.Modifier == models.CriterionModifierNotNull { @@ -968,7 +1009,11 @@ func (m *hierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hierarchica } if len(criterion.Value) > 0 { - valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) + valuesClause, err := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) + if err != nil { + f.setError(err) + return + } switch criterion.Modifier { case models.CriterionModifierIncludes: @@ -980,7 +1025,11 @@ func (m *hierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hierarchica } if len(criterion.Excludes) > 0 { - valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) + valuesClause, err := getHierarchicalValues(ctx, m.tx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) + if err != nil { + f.setError(err) + return + } f.addWhere(fmt.Sprintf("%s.%s NOT IN (SELECT column2 FROM (%s)) OR %[1]s.%[2]s IS NULL", m.primaryTable, m.foreignFK, valuesClause)) } @@ -992,10 +1041,12 @@ type joinedHierarchicalMultiCriterionHandlerBuilder struct { tx dbWrapper primaryTable string + primaryKey string foreignTable string foreignFK string parentFK string + childFK string relationsTable string joinAs string @@ -1004,16 +1055,25 @@ type joinedHierarchicalMultiCriterionHandlerBuilder struct { } func (m *joinedHierarchicalMultiCriterionHandlerBuilder) addHierarchicalConditionClauses(f *filterBuilder, criterion models.HierarchicalMultiCriterionInput, table, idColumn string) { - if criterion.Modifier == models.CriterionModifierEquals { + primaryKey := m.primaryKey + if primaryKey == "" { + primaryKey = "id" + } + + switch criterion.Modifier { + case models.CriterionModifierEquals: // includes only the provided ids f.addWhere(fmt.Sprintf("%s.%s IS NOT NULL", table, idColumn)) f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", table, idColumn, len(criterion.Value))) - f.addWhere(utils.StrFormat("(SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.id) = ?", utils.StrFormatMap{ + f.addWhere(utils.StrFormat("(SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.{primaryKey}) = ?", utils.StrFormatMap{ "joinTable": m.joinTable, "primaryFK": m.primaryFK, "primaryTable": m.primaryTable, + "primaryKey": primaryKey, }), len(criterion.Value)) - } else { + case models.CriterionModifierNotEquals: + f.setError(fmt.Errorf("not equals modifier is not supported for hierarchical multi criterion input")) + default: addHierarchicalConditionClauses(f, criterion, table, idColumn) } } @@ -1024,6 +1084,15 @@ func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hiera // make a copy so we don't modify the original criterion := *c joinAlias := m.joinAs + primaryKey := m.primaryKey + if primaryKey == "" { + primaryKey = "id" + } + + if criterion.Modifier == models.CriterionModifierEquals && criterion.Depth != nil && *criterion.Depth != 0 { + f.setError(fmt.Errorf("depth is not supported for equals modifier in hierarchical multi criterion input")) + return + } if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { var notClause string @@ -1031,7 +1100,7 @@ func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hiera notClause = "NOT" } - f.addLeftJoin(m.joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.id", joinAlias, m.primaryFK, m.primaryTable)) + f.addLeftJoin(m.joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.%s", joinAlias, m.primaryFK, m.primaryTable, primaryKey)) f.addWhere(utils.StrFormat("{table}.{column} IS {not} NULL", utils.StrFormatMap{ "table": joinAlias, @@ -1053,7 +1122,11 @@ func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hiera } if len(criterion.Value) > 0 { - valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) + valuesClause, err := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) + if err != nil { + f.setError(err) + return + } joinTable := utils.StrFormat(`( SELECT j.*, d.column1 AS root_id, d.column2 AS item_id FROM {joinTable} AS j @@ -1065,13 +1138,17 @@ func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hiera "valuesClause": valuesClause, }) - f.addLeftJoin(joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.id", joinAlias, m.primaryFK, m.primaryTable)) + f.addLeftJoin(joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.%s", joinAlias, m.primaryFK, m.primaryTable, primaryKey)) m.addHierarchicalConditionClauses(f, criterion, joinAlias, "root_id") } if len(criterion.Excludes) > 0 { - valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) + valuesClause, err := getHierarchicalValues(ctx, m.tx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) + if err != nil { + f.setError(err) + return + } joinTable := utils.StrFormat(`( SELECT j2.*, e.column1 AS root_id, e.column2 AS item_id FROM {joinTable} AS j2 @@ -1085,7 +1162,7 @@ func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hiera joinAlias2 := joinAlias + "2" - f.addLeftJoin(joinTable, joinAlias2, fmt.Sprintf("%s.%s = %s.id", joinAlias2, m.primaryFK, m.primaryTable)) + f.addLeftJoin(joinTable, joinAlias2, fmt.Sprintf("%s.%s = %s.%s", joinAlias2, m.primaryFK, m.primaryTable, primaryKey)) // modify for exclusion criterionCopy := criterion @@ -1098,6 +1175,83 @@ func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hiera } } +type joinedPerformerTagsHandler struct { + criterion *models.HierarchicalMultiCriterionInput + + primaryTable string // eg scenes + joinTable string // eg performers_scenes + joinPrimaryKey string // eg scene_id +} + +func (h *joinedPerformerTagsHandler) handle(ctx context.Context, f *filterBuilder) { + tags := h.criterion + + if tags != nil { + criterion := tags.CombineExcludes() + + // validate the modifier + switch criterion.Modifier { + case models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull: + // valid + default: + f.setError(fmt.Errorf("invalid modifier %s for performer tags", criterion.Modifier)) + } + + strFormatMap := utils.StrFormatMap{ + "primaryTable": h.primaryTable, + "joinTable": h.joinTable, + "joinPrimaryKey": h.joinPrimaryKey, + "inBinding": getInBinding(len(criterion.Value)), + } + + if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { + var notClause string + if criterion.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addLeftJoin(h.joinTable, "", utils.StrFormat("{primaryTable}.id = {joinTable}.{joinPrimaryKey}", strFormatMap)) + f.addLeftJoin("performers_tags", "", utils.StrFormat("{joinTable}.performer_id = performers_tags.performer_id", strFormatMap)) + + f.addWhere(fmt.Sprintf("performers_tags.tag_id IS %s NULL", notClause)) + return + } + + if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { + return + } + + if len(criterion.Value) > 0 { + valuesClause, err := getHierarchicalValues(ctx, dbWrapper{}, criterion.Value, tagTable, "tags_relations", "", "", criterion.Depth) + if err != nil { + f.setError(err) + return + } + + f.addWith(utils.StrFormat(`performer_tags AS ( +SELECT ps.{joinPrimaryKey} as primaryID, t.column1 AS root_tag_id FROM {joinTable} ps +INNER JOIN performers_tags pt ON pt.performer_id = ps.performer_id +INNER JOIN (`+valuesClause+`) t ON t.column2 = pt.tag_id +)`, strFormatMap)) + + f.addLeftJoin("performer_tags", "", utils.StrFormat("performer_tags.primaryID = {primaryTable}.id", strFormatMap)) + + addHierarchicalConditionClauses(f, criterion, "performer_tags", "root_tag_id") + } + + if len(criterion.Excludes) > 0 { + valuesClause, err := getHierarchicalValues(ctx, dbWrapper{}, criterion.Excludes, tagTable, "tags_relations", "", "", criterion.Depth) + if err != nil { + f.setError(err) + return + } + + clause := utils.StrFormat("{primaryTable}.id NOT IN (SELECT {joinTable}.{joinPrimaryKey} FROM {joinTable} INNER JOIN performers_tags ON {joinTable}.performer_id = performers_tags.performer_id WHERE performers_tags.tag_id IN (SELECT column2 FROM (%s)))", strFormatMap) + f.addWhere(fmt.Sprintf(clause, valuesClause)) + } + } +} + type stashIDCriterionHandler struct { c *models.StashIDCriterionInput stashIDRepository *stashIDRepository diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 5f5291053f4..2e857cc34b9 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -670,7 +670,7 @@ func (qb *GalleryStore) makeFilter(ctx context.Context, galleryFilter *models.Ga query.handleCriterion(ctx, galleryPerformersCriterionHandler(qb, galleryFilter.Performers)) query.handleCriterion(ctx, galleryPerformerCountCriterionHandler(qb, galleryFilter.PerformerCount)) query.handleCriterion(ctx, hasChaptersCriterionHandler(galleryFilter.HasChapters)) - query.handleCriterion(ctx, galleryStudioCriterionHandler(qb, galleryFilter.Studios)) + query.handleCriterion(ctx, studioCriterionHandler(galleryTable, galleryFilter.Studios)) query.handleCriterion(ctx, galleryPerformerTagsCriterionHandler(qb, galleryFilter.PerformerTags)) query.handleCriterion(ctx, galleryAverageResolutionCriterionHandler(qb, galleryFilter.AverageResolution)) query.handleCriterion(ctx, galleryImageCountCriterionHandler(qb, galleryFilter.ImageCount)) @@ -968,51 +968,12 @@ func hasChaptersCriterionHandler(hasChapters *string) criterionHandlerFunc { } } -func galleryStudioCriterionHandler(qb *GalleryStore, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - h := hierarchicalMultiCriterionHandlerBuilder{ - tx: qb.tx, - - primaryTable: galleryTable, - foreignTable: studioTable, - foreignFK: studioIDColumn, - parentFK: "parent_id", - } - - return h.handler(studios) -} - -func galleryPerformerTagsCriterionHandler(qb *GalleryStore, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if tags != nil { - if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { - var notClause string - if tags.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - f.addLeftJoin("performers_galleries", "", "galleries.id = performers_galleries.gallery_id") - f.addLeftJoin("performers_tags", "", "performers_galleries.performer_id = performers_tags.performer_id") - - f.addWhere(fmt.Sprintf("performers_tags.tag_id IS %s NULL", notClause)) - return - } - - if len(tags.Value) == 0 { - return - } - - valuesClause := getHierarchicalValues(ctx, qb.tx, tags.Value, tagTable, "tags_relations", "", tags.Depth) - - f.addWith(`performer_tags AS ( -SELECT pg.gallery_id, t.column1 AS root_tag_id FROM performers_galleries pg -INNER JOIN performers_tags pt ON pt.performer_id = pg.performer_id -INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id -)`) - - f.addLeftJoin("performer_tags", "", "performer_tags.gallery_id = galleries.id") - - addHierarchicalConditionClauses(f, *tags, "performer_tags", "root_tag_id") - } +func galleryPerformerTagsCriterionHandler(qb *GalleryStore, tags *models.HierarchicalMultiCriterionInput) criterionHandler { + return &joinedPerformerTagsHandler{ + criterion: tags, + primaryTable: galleryTable, + joinTable: performersGalleriesTable, + joinPrimaryKey: galleryIDColumn, } } diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index 6d145cb1ba1..bad75d0356d 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -1945,154 +1945,369 @@ func TestGalleryQueryIsMissingDate(t *testing.T) { } func TestGalleryQueryPerformers(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Gallery - performerCriterion := models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdxWithGallery]), - strconv.Itoa(performerIDs[performerIdx1WithGallery]), + tests := []struct { + name string + filter models.MultiCriterionInput + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(performerIDs[performerIdxWithGallery]), + strconv.Itoa(performerIDs[performerIdx1WithGallery]), + }, + Modifier: models.CriterionModifierIncludes, }, - Modifier: models.CriterionModifierIncludes, - } - - galleryFilter := models.GalleryFilterType{ - Performers: &performerCriterion, - } - - galleries := queryGallery(ctx, t, sqb, &galleryFilter, nil) - - assert.Len(t, galleries, 2) - - // ensure ids are correct - for _, gallery := range galleries { - assert.True(t, gallery.ID == galleryIDs[galleryIdxWithPerformer] || gallery.ID == galleryIDs[galleryIdxWithTwoPerformers]) - } - - performerCriterion = models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdx1WithGallery]), - strconv.Itoa(performerIDs[performerIdx2WithGallery]), + []int{ + galleryIdxWithPerformer, + galleryIdxWithTwoPerformers, }, - Modifier: models.CriterionModifierIncludesAll, - } - - galleries = queryGallery(ctx, t, sqb, &galleryFilter, nil) + []int{ + galleryIdxWithImage, + }, + false, + }, + { + "includes all", + models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(performerIDs[performerIdx1WithGallery]), + strconv.Itoa(performerIDs[performerIdx2WithGallery]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + []int{ + galleryIdxWithTwoPerformers, + }, + []int{ + galleryIdxWithPerformer, + }, + false, + }, + { + "excludes", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[performerIdx1WithGallery])}, + }, + nil, + []int{galleryIdxWithTwoPerformers}, + false, + }, + { + "is null", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + []int{galleryIdxWithTag}, + []int{ + galleryIdxWithPerformer, + galleryIdxWithTwoPerformers, + galleryIdxWithPerformerTwoTags, + }, + false, + }, + { + "not null", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + []int{ + galleryIdxWithPerformer, + galleryIdxWithTwoPerformers, + galleryIdxWithPerformerTwoTags, + }, + []int{galleryIdxWithTag}, + false, + }, + { + "equals", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[performerIdx1WithGallery]), + strconv.Itoa(tagIDs[performerIdx2WithGallery]), + }, + }, + []int{galleryIdxWithTwoPerformers}, + []int{ + galleryIdxWithThreePerformers, + }, + false, + }, + { + "not equals", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[performerIdx1WithGallery]), + strconv.Itoa(tagIDs[performerIdx2WithGallery]), + }, + }, + nil, + nil, + true, + }, + } - assert.Len(t, galleries, 1) - assert.Equal(t, galleryIDs[galleryIdxWithTwoPerformers], galleries[0].ID) + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - performerCriterion = models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdx1WithGallery]), - }, - Modifier: models.CriterionModifierExcludes, - } + results, _, err := db.Gallery.Query(ctx, &models.GalleryFilterType{ + Performers: &tt.filter, + }, nil) + if (err != nil) != tt.wantErr { + t.Errorf("GalleryStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - q := getGalleryStringValue(galleryIdxWithTwoPerformers, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + ids := galleriesToIDs(results) - galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) - assert.Len(t, galleries, 0) + include := indexesToIDs(galleryIDs, tt.includeIdxs) + exclude := indexesToIDs(galleryIDs, tt.excludeIdxs) - return nil - }) + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } } func TestGalleryQueryTags(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Gallery - tagCriterion := models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdxWithGallery]), - strconv.Itoa(tagIDs[tagIdx1WithGallery]), + tests := []struct { + name string + filter models.HierarchicalMultiCriterionInput + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithGallery]), + strconv.Itoa(tagIDs[tagIdx1WithGallery]), + }, + Modifier: models.CriterionModifierIncludes, }, - Modifier: models.CriterionModifierIncludes, - } - - galleryFilter := models.GalleryFilterType{ - Tags: &tagCriterion, - } - - galleries := queryGallery(ctx, t, sqb, &galleryFilter, nil) - assert.Len(t, galleries, 2) - - // ensure ids are correct - for _, gallery := range galleries { - assert.True(t, gallery.ID == galleryIDs[galleryIdxWithTag] || gallery.ID == galleryIDs[galleryIdxWithTwoTags]) - } - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithGallery]), - strconv.Itoa(tagIDs[tagIdx2WithGallery]), + []int{ + galleryIdxWithTag, + galleryIdxWithTwoTags, }, - Modifier: models.CriterionModifierIncludesAll, - } - - galleries = queryGallery(ctx, t, sqb, &galleryFilter, nil) + []int{ + galleryIdxWithImage, + }, + false, + }, + { + "includes all", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithGallery]), + strconv.Itoa(tagIDs[tagIdx2WithGallery]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + []int{ + galleryIdxWithTwoTags, + }, + []int{ + galleryIdxWithTag, + }, + false, + }, + { + "excludes", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[tagIdx1WithGallery])}, + }, + nil, + []int{galleryIdxWithTwoTags}, + false, + }, + { + "is null", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + []int{galleryIdx1WithPerformer}, + []int{ + galleryIdxWithTag, + galleryIdxWithTwoTags, + galleryIdxWithThreeTags, + }, + false, + }, + { + "not null", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + []int{ + galleryIdxWithTag, + galleryIdxWithTwoTags, + galleryIdxWithThreeTags, + }, + []int{galleryIdx1WithPerformer}, + false, + }, + { + "equals", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithGallery]), + strconv.Itoa(tagIDs[tagIdx2WithGallery]), + }, + }, + []int{galleryIdxWithTwoTags}, + []int{ + galleryIdxWithThreeTags, + }, + false, + }, + { + "not equals", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithGallery]), + strconv.Itoa(tagIDs[tagIdx2WithGallery]), + }, + }, + nil, + nil, + true, + }, + } - assert.Len(t, galleries, 1) - assert.Equal(t, galleryIDs[galleryIdxWithTwoTags], galleries[0].ID) + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithGallery]), - }, - Modifier: models.CriterionModifierExcludes, - } + results, _, err := db.Gallery.Query(ctx, &models.GalleryFilterType{ + Tags: &tt.filter, + }, nil) + if (err != nil) != tt.wantErr { + t.Errorf("GalleryStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - q := getGalleryStringValue(galleryIdxWithTwoTags, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + ids := galleriesToIDs(results) - galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) - assert.Len(t, galleries, 0) + include := indexesToIDs(imageIDs, tt.includeIdxs) + exclude := indexesToIDs(imageIDs, tt.excludeIdxs) - return nil - }) + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } } func TestGalleryQueryStudio(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Gallery - studioCriterion := models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(studioIDs[studioIdxWithGallery]), + tests := []struct { + name string + q string + studioCriterion models.HierarchicalMultiCriterionInput + expectedIDs []int + wantErr bool + }{ + { + "includes", + "", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithGallery]), + }, + Modifier: models.CriterionModifierIncludes, }, - Modifier: models.CriterionModifierIncludes, - } - - galleryFilter := models.GalleryFilterType{ - Studios: &studioCriterion, - } - - galleries := queryGallery(ctx, t, sqb, &galleryFilter, nil) + []int{galleryIDs[galleryIdxWithStudio]}, + false, + }, + { + "excludes", + getGalleryStringValue(galleryIdxWithStudio, titleField), + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithGallery]), + }, + Modifier: models.CriterionModifierExcludes, + }, + []int{}, + false, + }, + { + "excludes includes null", + getGalleryStringValue(galleryIdxWithImage, titleField), + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithGallery]), + }, + Modifier: models.CriterionModifierExcludes, + }, + []int{galleryIDs[galleryIdxWithImage]}, + false, + }, + { + "equals", + "", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithGallery]), + }, + Modifier: models.CriterionModifierEquals, + }, + []int{galleryIDs[galleryIdxWithStudio]}, + false, + }, + { + "not equals", + getGalleryStringValue(galleryIdxWithStudio, titleField), + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithGallery]), + }, + Modifier: models.CriterionModifierNotEquals, + }, + []int{}, + false, + }, + } - assert.Len(t, galleries, 1) + qb := db.Gallery - // ensure id is correct - assert.Equal(t, galleryIDs[galleryIdxWithStudio], galleries[0].ID) + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + studioCriterion := tt.studioCriterion - studioCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(studioIDs[studioIdxWithGallery]), - }, - Modifier: models.CriterionModifierExcludes, - } + galleryFilter := models.GalleryFilterType{ + Studios: &studioCriterion, + } - q := getGalleryStringValue(galleryIdxWithStudio, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + var findFilter *models.FindFilterType + if tt.q != "" { + findFilter = &models.FindFilterType{ + Q: &tt.q, + } + } - galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) - assert.Len(t, galleries, 0) + gallerys := queryGallery(ctx, t, qb, &galleryFilter, findFilter) - return nil - }) + assert.ElementsMatch(t, galleriesToIDs(gallerys), tt.expectedIDs) + }) + } } func TestGalleryQueryStudioDepth(t *testing.T) { @@ -2157,81 +2372,198 @@ func TestGalleryQueryStudioDepth(t *testing.T) { } func TestGalleryQueryPerformerTags(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Gallery - tagCriterion := models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdxWithPerformer]), - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), - }, - Modifier: models.CriterionModifierIncludes, - } - - galleryFilter := models.GalleryFilterType{ - PerformerTags: &tagCriterion, - } - - galleries := queryGallery(ctx, t, sqb, &galleryFilter, nil) - assert.Len(t, galleries, 2) - - // ensure ids are correct - for _, gallery := range galleries { - assert.True(t, gallery.ID == galleryIDs[galleryIdxWithPerformerTag] || gallery.ID == galleryIDs[galleryIdxWithPerformerTwoTags]) - } + allDepth := -1 - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), - strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + tests := []struct { + name string + findFilter *models.FindFilterType + filter *models.GalleryFilterType + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithPerformer]), + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + }, + Modifier: models.CriterionModifierIncludes, + }, }, - Modifier: models.CriterionModifierIncludesAll, - } - - galleries = queryGallery(ctx, t, sqb, &galleryFilter, nil) - - assert.Len(t, galleries, 1) - assert.Equal(t, galleryIDs[galleryIdxWithPerformerTwoTags], galleries[0].ID) - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + []int{ + galleryIdxWithPerformerTag, + galleryIdxWithPerformerTwoTags, + galleryIdxWithTwoPerformerTag, }, - Modifier: models.CriterionModifierExcludes, - } - - q := getGalleryStringValue(galleryIdxWithPerformerTwoTags, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } - - galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) - assert.Len(t, galleries, 0) - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Modifier: models.CriterionModifierIsNull, - } - q = getGalleryStringValue(galleryIdx1WithImage, titleField) - - galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) - assert.Len(t, galleries, 1) - assert.Equal(t, galleryIDs[galleryIdx1WithImage], galleries[0].ID) + []int{ + galleryIdxWithPerformer, + }, + false, + }, + { + "includes sub-tags", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithParentAndChild]), + }, + Depth: &allDepth, + Modifier: models.CriterionModifierIncludes, + }, + }, + []int{ + galleryIdxWithPerformerParentTag, + }, + []int{ + galleryIdxWithPerformer, + galleryIdxWithPerformerTag, + galleryIdxWithPerformerTwoTags, + galleryIdxWithTwoPerformerTag, + }, + false, + }, + { + "includes all", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + }, + []int{ + galleryIdxWithPerformerTwoTags, + }, + []int{ + galleryIdxWithPerformer, + galleryIdxWithPerformerTag, + galleryIdxWithTwoPerformerTag, + }, + false, + }, + { + "excludes performer tag tagIdx2WithPerformer", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[tagIdx2WithPerformer])}, + }, + }, + nil, + []int{galleryIdxWithTwoPerformerTag}, + false, + }, + { + "excludes sub-tags", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithParentAndChild]), + }, + Depth: &allDepth, + Modifier: models.CriterionModifierExcludes, + }, + }, + []int{ + galleryIdxWithPerformer, + galleryIdxWithPerformerTag, + galleryIdxWithPerformerTwoTags, + galleryIdxWithTwoPerformerTag, + }, + []int{ + galleryIdxWithPerformerParentTag, + }, + false, + }, + { + "is null", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + }, + []int{galleryIdx1WithImage}, + []int{galleryIdxWithPerformerTag}, + false, + }, + { + "not null", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + }, + []int{galleryIdxWithPerformerTag}, + []int{galleryIdx1WithImage}, + false, + }, + { + "equals", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + }, + }, + nil, + nil, + true, + }, + { + "not equals", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + }, + }, + nil, + nil, + true, + }, + } - q = getGalleryStringValue(galleryIdxWithPerformerTag, titleField) - galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) - assert.Len(t, galleries, 0) + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - tagCriterion.Modifier = models.CriterionModifierNotNull + results, _, err := db.Gallery.Query(ctx, tt.filter, tt.findFilter) + if (err != nil) != tt.wantErr { + t.Errorf("ImageStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) - assert.Len(t, galleries, 1) - assert.Equal(t, galleryIDs[galleryIdxWithPerformerTag], galleries[0].ID) + ids := galleriesToIDs(results) - q = getGalleryStringValue(galleryIdx1WithImage, titleField) - galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) - assert.Len(t, galleries, 0) + include := indexesToIDs(galleryIDs, tt.includeIdxs) + exclude := indexesToIDs(galleryIDs, tt.excludeIdxs) - return nil - }) + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } } func TestGalleryQueryTagCount(t *testing.T) { diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index d42de9f85a7..9dee5ed282f 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -669,7 +669,7 @@ func (qb *ImageStore) makeFilter(ctx context.Context, imageFilter *models.ImageF query.handleCriterion(ctx, imageGalleriesCriterionHandler(qb, imageFilter.Galleries)) query.handleCriterion(ctx, imagePerformersCriterionHandler(qb, imageFilter.Performers)) query.handleCriterion(ctx, imagePerformerCountCriterionHandler(qb, imageFilter.PerformerCount)) - query.handleCriterion(ctx, imageStudioCriterionHandler(qb, imageFilter.Studios)) + query.handleCriterion(ctx, studioCriterionHandler(imageTable, imageFilter.Studios)) query.handleCriterion(ctx, imagePerformerTagsCriterionHandler(qb, imageFilter.PerformerTags)) query.handleCriterion(ctx, imagePerformerFavoriteCriterionHandler(imageFilter.PerformerFavorite)) query.handleCriterion(ctx, timestampCriterionHandler(imageFilter.CreatedAt, "images.created_at")) @@ -946,51 +946,12 @@ GROUP BY performers_images.image_id HAVING SUM(performers.favorite) = 0)`, "nofa } } -func imageStudioCriterionHandler(qb *ImageStore, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - h := hierarchicalMultiCriterionHandlerBuilder{ - tx: qb.tx, - - primaryTable: imageTable, - foreignTable: studioTable, - foreignFK: studioIDColumn, - parentFK: "parent_id", - } - - return h.handler(studios) -} - -func imagePerformerTagsCriterionHandler(qb *ImageStore, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if tags != nil { - if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { - var notClause string - if tags.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - f.addLeftJoin("performers_images", "", "images.id = performers_images.image_id") - f.addLeftJoin("performers_tags", "", "performers_images.performer_id = performers_tags.performer_id") - - f.addWhere(fmt.Sprintf("performers_tags.tag_id IS %s NULL", notClause)) - return - } - - if len(tags.Value) == 0 { - return - } - - valuesClause := getHierarchicalValues(ctx, qb.tx, tags.Value, tagTable, "tags_relations", "", tags.Depth) - - f.addWith(`performer_tags AS ( -SELECT pi.image_id, t.column1 AS root_tag_id FROM performers_images pi -INNER JOIN performers_tags pt ON pt.performer_id = pi.performer_id -INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id -)`) - - f.addLeftJoin("performer_tags", "", "performer_tags.image_id = images.id") - - addHierarchicalConditionClauses(f, *tags, "performer_tags", "root_tag_id") - } +func imagePerformerTagsCriterionHandler(qb *ImageStore, tags *models.HierarchicalMultiCriterionInput) criterionHandler { + return &joinedPerformerTagsHandler{ + criterion: tags, + primaryTable: imageTable, + joinTable: performersImagesTable, + joinPrimaryKey: imageIDColumn, } } diff --git a/pkg/sqlite/image_test.go b/pkg/sqlite/image_test.go index 1a0fceb2963..3ec1598774d 100644 --- a/pkg/sqlite/image_test.go +++ b/pkg/sqlite/image_test.go @@ -2124,203 +2124,369 @@ func TestImageQueryGallery(t *testing.T) { } func TestImageQueryPerformers(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Image - performerCriterion := models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdxWithImage]), - strconv.Itoa(performerIDs[performerIdx1WithImage]), + tests := []struct { + name string + filter models.MultiCriterionInput + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(performerIDs[performerIdxWithImage]), + strconv.Itoa(performerIDs[performerIdx1WithImage]), + }, + Modifier: models.CriterionModifierIncludes, }, - Modifier: models.CriterionModifierIncludes, - } - - imageFilter := models.ImageFilterType{ - Performers: &performerCriterion, - } - - images := queryImages(ctx, t, sqb, &imageFilter, nil) - assert.Len(t, images, 2) - - // ensure ids are correct - for _, image := range images { - assert.True(t, image.ID == imageIDs[imageIdxWithPerformer] || image.ID == imageIDs[imageIdxWithTwoPerformers]) - } - - performerCriterion = models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdx1WithImage]), - strconv.Itoa(performerIDs[performerIdx2WithImage]), + []int{ + imageIdxWithPerformer, + imageIdxWithTwoPerformers, }, - Modifier: models.CriterionModifierIncludesAll, - } - - images = queryImages(ctx, t, sqb, &imageFilter, nil) - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithTwoPerformers], images[0].ID) - - performerCriterion = models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdx1WithImage]), + []int{ + imageIdxWithGallery, }, - Modifier: models.CriterionModifierExcludes, - } - - q := getImageStringValue(imageIdxWithTwoPerformers, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } - - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) - - performerCriterion = models.MultiCriterionInput{ - Modifier: models.CriterionModifierIsNull, - } - q = getImageStringValue(imageIdxWithGallery, titleField) - - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithGallery], images[0].ID) - - q = getImageStringValue(imageIdxWithPerformerTag, titleField) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) + false, + }, + { + "includes all", + models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(performerIDs[performerIdx1WithImage]), + strconv.Itoa(performerIDs[performerIdx2WithImage]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + []int{ + imageIdxWithTwoPerformers, + }, + []int{ + imageIdxWithPerformer, + }, + false, + }, + { + "excludes", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[performerIdx1WithImage])}, + }, + nil, + []int{imageIdxWithTwoPerformers}, + false, + }, + { + "is null", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + []int{imageIdxWithTag}, + []int{ + imageIdxWithPerformer, + imageIdxWithTwoPerformers, + imageIdxWithPerformerTwoTags, + }, + false, + }, + { + "not null", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + []int{ + imageIdxWithPerformer, + imageIdxWithTwoPerformers, + imageIdxWithPerformerTwoTags, + }, + []int{imageIdxWithTag}, + false, + }, + { + "equals", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[performerIdx1WithImage]), + strconv.Itoa(tagIDs[performerIdx2WithImage]), + }, + }, + []int{imageIdxWithTwoPerformers}, + []int{ + imageIdxWithThreePerformers, + }, + false, + }, + { + "not equals", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[performerIdx1WithImage]), + strconv.Itoa(tagIDs[performerIdx2WithImage]), + }, + }, + nil, + nil, + true, + }, + } - performerCriterion.Modifier = models.CriterionModifierNotNull + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithPerformerTag], images[0].ID) + results, err := db.Image.Query(ctx, models.ImageQueryOptions{ + ImageFilter: &models.ImageFilterType{ + Performers: &tt.filter, + }, + }) + if (err != nil) != tt.wantErr { + t.Errorf("ImageStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - q = getImageStringValue(imageIdxWithGallery, titleField) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) + include := indexesToIDs(imageIDs, tt.includeIdxs) + exclude := indexesToIDs(imageIDs, tt.excludeIdxs) - return nil - }) + for _, i := range include { + assert.Contains(results.IDs, i) + } + for _, e := range exclude { + assert.NotContains(results.IDs, e) + } + }) + } } func TestImageQueryTags(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Image - tagCriterion := models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdxWithImage]), - strconv.Itoa(tagIDs[tagIdx1WithImage]), + tests := []struct { + name string + filter models.HierarchicalMultiCriterionInput + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithImage]), + strconv.Itoa(tagIDs[tagIdx1WithImage]), + }, + Modifier: models.CriterionModifierIncludes, }, - Modifier: models.CriterionModifierIncludes, - } - - imageFilter := models.ImageFilterType{ - Tags: &tagCriterion, - } - - images := queryImages(ctx, t, sqb, &imageFilter, nil) - assert.Len(t, images, 2) - - // ensure ids are correct - for _, image := range images { - assert.True(t, image.ID == imageIDs[imageIdxWithTag] || image.ID == imageIDs[imageIdxWithTwoTags]) - } - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithImage]), - strconv.Itoa(tagIDs[tagIdx2WithImage]), + []int{ + imageIdxWithTag, + imageIdxWithTwoTags, }, - Modifier: models.CriterionModifierIncludesAll, - } - - images = queryImages(ctx, t, sqb, &imageFilter, nil) - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithTwoTags], images[0].ID) - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithImage]), + []int{ + imageIdxWithGallery, }, - Modifier: models.CriterionModifierExcludes, - } - - q := getImageStringValue(imageIdxWithTwoTags, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } - - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Modifier: models.CriterionModifierIsNull, - } - q = getImageStringValue(imageIdxWithGallery, titleField) - - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithGallery], images[0].ID) - - q = getImageStringValue(imageIdxWithTag, titleField) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) + false, + }, + { + "includes all", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithImage]), + strconv.Itoa(tagIDs[tagIdx2WithImage]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + []int{ + imageIdxWithTwoTags, + }, + []int{ + imageIdxWithTag, + }, + false, + }, + { + "excludes", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[tagIdx1WithImage])}, + }, + nil, + []int{imageIdxWithTwoTags}, + false, + }, + { + "is null", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + []int{imageIdx1WithPerformer}, + []int{ + imageIdxWithTag, + imageIdxWithTwoTags, + imageIdxWithThreeTags, + }, + false, + }, + { + "not null", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + []int{ + imageIdxWithTag, + imageIdxWithTwoTags, + imageIdxWithThreeTags, + }, + []int{imageIdx1WithPerformer}, + false, + }, + { + "equals", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithImage]), + strconv.Itoa(tagIDs[tagIdx2WithImage]), + }, + }, + []int{imageIdxWithTwoTags}, + []int{ + imageIdxWithThreeTags, + }, + false, + }, + { + "not equals", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithImage]), + strconv.Itoa(tagIDs[tagIdx2WithImage]), + }, + }, + nil, + nil, + true, + }, + } - tagCriterion.Modifier = models.CriterionModifierNotNull + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithTag], images[0].ID) + results, err := db.Image.Query(ctx, models.ImageQueryOptions{ + ImageFilter: &models.ImageFilterType{ + Tags: &tt.filter, + }, + }) + if (err != nil) != tt.wantErr { + t.Errorf("ImageStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - q = getImageStringValue(imageIdxWithGallery, titleField) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) + include := indexesToIDs(imageIDs, tt.includeIdxs) + exclude := indexesToIDs(imageIDs, tt.excludeIdxs) - return nil - }) + for _, i := range include { + assert.Contains(results.IDs, i) + } + for _, e := range exclude { + assert.NotContains(results.IDs, e) + } + }) + } } func TestImageQueryStudio(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Image - studioCriterion := models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(studioIDs[studioIdxWithImage]), + tests := []struct { + name string + q string + studioCriterion models.HierarchicalMultiCriterionInput + expectedIDs []int + wantErr bool + }{ + { + "includes", + "", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithImage]), + }, + Modifier: models.CriterionModifierIncludes, }, - Modifier: models.CriterionModifierIncludes, - } - - imageFilter := models.ImageFilterType{ - Studios: &studioCriterion, - } - - images, _, err := queryImagesWithCount(ctx, sqb, &imageFilter, nil) - if err != nil { - t.Errorf("Error querying image: %s", err.Error()) - } + []int{imageIDs[imageIdxWithStudio]}, + false, + }, + { + "excludes", + getImageStringValue(imageIdxWithStudio, titleField), + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithImage]), + }, + Modifier: models.CriterionModifierExcludes, + }, + []int{}, + false, + }, + { + "excludes includes null", + getImageStringValue(imageIdxWithGallery, titleField), + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithImage]), + }, + Modifier: models.CriterionModifierExcludes, + }, + []int{imageIDs[imageIdxWithGallery]}, + false, + }, + { + "equals", + "", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithImage]), + }, + Modifier: models.CriterionModifierEquals, + }, + []int{imageIDs[imageIdxWithStudio]}, + false, + }, + { + "not equals", + getImageStringValue(imageIdxWithStudio, titleField), + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithImage]), + }, + Modifier: models.CriterionModifierNotEquals, + }, + []int{}, + false, + }, + } - assert.Len(t, images, 1) + qb := db.Image - // ensure id is correct - assert.Equal(t, imageIDs[imageIdxWithStudio], images[0].ID) + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + studioCriterion := tt.studioCriterion - studioCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(studioIDs[studioIdxWithImage]), - }, - Modifier: models.CriterionModifierExcludes, - } + imageFilter := models.ImageFilterType{ + Studios: &studioCriterion, + } - q := getImageStringValue(imageIdxWithStudio, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + var findFilter *models.FindFilterType + if tt.q != "" { + findFilter = &models.FindFilterType{ + Q: &tt.q, + } + } - images, _, err = queryImagesWithCount(ctx, sqb, &imageFilter, &findFilter) - if err != nil { - t.Errorf("Error querying image: %s", err.Error()) - } - assert.Len(t, images, 0) + images := queryImages(ctx, t, qb, &imageFilter, findFilter) - return nil - }) + assert.ElementsMatch(t, imagesToIDs(images), tt.expectedIDs) + }) + } } func TestImageQueryStudioDepth(t *testing.T) { @@ -2394,81 +2560,201 @@ func queryImages(ctx context.Context, t *testing.T, sqb models.ImageReader, imag } func TestImageQueryPerformerTags(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Image - tagCriterion := models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdxWithPerformer]), - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), - }, - Modifier: models.CriterionModifierIncludes, - } - - imageFilter := models.ImageFilterType{ - PerformerTags: &tagCriterion, - } + allDepth := -1 - images := queryImages(ctx, t, sqb, &imageFilter, nil) - assert.Len(t, images, 2) - - // ensure ids are correct - for _, image := range images { - assert.True(t, image.ID == imageIDs[imageIdxWithPerformerTag] || image.ID == imageIDs[imageIdxWithPerformerTwoTags]) - } - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), - strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + tests := []struct { + name string + findFilter *models.FindFilterType + filter *models.ImageFilterType + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithPerformer]), + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + }, + Modifier: models.CriterionModifierIncludes, + }, }, - Modifier: models.CriterionModifierIncludesAll, - } - - images = queryImages(ctx, t, sqb, &imageFilter, nil) - - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithPerformerTwoTags], images[0].ID) - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + []int{ + imageIdxWithPerformerTag, + imageIdxWithPerformerTwoTags, + imageIdxWithTwoPerformerTag, }, - Modifier: models.CriterionModifierExcludes, - } - - q := getImageStringValue(imageIdxWithPerformerTwoTags, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } - - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Modifier: models.CriterionModifierIsNull, - } - q = getImageStringValue(imageIdxWithGallery, titleField) - - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithGallery], images[0].ID) - - q = getImageStringValue(imageIdxWithPerformerTag, titleField) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) + []int{ + imageIdxWithPerformer, + }, + false, + }, + { + "includes sub-tags", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithParentAndChild]), + }, + Depth: &allDepth, + Modifier: models.CriterionModifierIncludes, + }, + }, + []int{ + imageIdxWithPerformerParentTag, + }, + []int{ + imageIdxWithPerformer, + imageIdxWithPerformerTag, + imageIdxWithPerformerTwoTags, + imageIdxWithTwoPerformerTag, + }, + false, + }, + { + "includes all", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + }, + []int{ + imageIdxWithPerformerTwoTags, + }, + []int{ + imageIdxWithPerformer, + imageIdxWithPerformerTag, + imageIdxWithTwoPerformerTag, + }, + false, + }, + { + "excludes performer tag tagIdx2WithPerformer", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[tagIdx2WithPerformer])}, + }, + }, + nil, + []int{imageIdxWithTwoPerformerTag}, + false, + }, + { + "excludes sub-tags", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithParentAndChild]), + }, + Depth: &allDepth, + Modifier: models.CriterionModifierExcludes, + }, + }, + []int{ + imageIdxWithPerformer, + imageIdxWithPerformerTag, + imageIdxWithPerformerTwoTags, + imageIdxWithTwoPerformerTag, + }, + []int{ + imageIdxWithPerformerParentTag, + }, + false, + }, + { + "is null", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + }, + []int{imageIdxWithGallery}, + []int{imageIdxWithPerformerTag}, + false, + }, + { + "not null", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + }, + []int{imageIdxWithPerformerTag}, + []int{imageIdxWithGallery}, + false, + }, + { + "equals", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + }, + }, + nil, + nil, + true, + }, + { + "not equals", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + }, + }, + nil, + nil, + true, + }, + } - tagCriterion.Modifier = models.CriterionModifierNotNull + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithPerformerTag], images[0].ID) + results, err := db.Image.Query(ctx, models.ImageQueryOptions{ + ImageFilter: tt.filter, + QueryOptions: models.QueryOptions{ + FindFilter: tt.findFilter, + }, + }) + if (err != nil) != tt.wantErr { + t.Errorf("ImageStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - q = getImageStringValue(imageIdxWithGallery, titleField) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) + include := indexesToIDs(imageIDs, tt.includeIdxs) + exclude := indexesToIDs(imageIDs, tt.excludeIdxs) - return nil - }) + for _, i := range include { + assert.Contains(results.IDs, i) + } + for _, e := range exclude { + assert.NotContains(results.IDs, e) + } + }) + } } func TestImageQueryTagCount(t *testing.T) { @@ -2587,7 +2873,7 @@ func TestImageQuerySorting(t *testing.T) { "date", models.SortDirectionEnumDesc, imageIdxWithTwoGalleries, - imageIdxWithGrandChildStudio, + imageIdxWithPerformerParentTag, }, } diff --git a/pkg/sqlite/movies.go b/pkg/sqlite/movies.go index 7ff13c2e34f..3bc273cbfa1 100644 --- a/pkg/sqlite/movies.go +++ b/pkg/sqlite/movies.go @@ -176,7 +176,7 @@ func (qb *movieQueryBuilder) makeFilter(ctx context.Context, movieFilter *models query.handleCriterion(ctx, floatIntCriterionHandler(movieFilter.Duration, "movies.duration", nil)) query.handleCriterion(ctx, movieIsMissingCriterionHandler(qb, movieFilter.IsMissing)) query.handleCriterion(ctx, stringCriterionHandler(movieFilter.URL, "movies.url")) - query.handleCriterion(ctx, movieStudioCriterionHandler(qb, movieFilter.Studios)) + query.handleCriterion(ctx, studioCriterionHandler(movieTable, movieFilter.Studios)) query.handleCriterion(ctx, moviePerformersCriterionHandler(qb, movieFilter.Performers)) query.handleCriterion(ctx, dateCriterionHandler(movieFilter.Date, "movies.date")) query.handleCriterion(ctx, timestampCriterionHandler(movieFilter.CreatedAt, "movies.created_at")) @@ -239,19 +239,6 @@ func movieIsMissingCriterionHandler(qb *movieQueryBuilder, isMissing *string) cr } } -func movieStudioCriterionHandler(qb *movieQueryBuilder, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - h := hierarchicalMultiCriterionHandlerBuilder{ - tx: qb.tx, - - primaryTable: movieTable, - foreignTable: studioTable, - foreignFK: studioIDColumn, - parentFK: "parent_id", - } - - return h.handler(studios) -} - func moviePerformersCriterionHandler(qb *movieQueryBuilder, performers *models.MultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if performers != nil { diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index d1079eac02f..f4f11e6843e 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -908,7 +908,11 @@ func performerStudiosCriterionHandler(qb *PerformerStore, studios *models.Hierar } const derivedPerformerStudioTable = "performer_studio" - valuesClause := getHierarchicalValues(ctx, qb.tx, studios.Value, studioTable, "", "parent_id", studios.Depth) + valuesClause, err := getHierarchicalValues(ctx, qb.tx, studios.Value, studioTable, "", "parent_id", "child_id", studios.Depth) + if err != nil { + f.setError(err) + return + } f.addWith("studio(root_id, item_id) AS (" + valuesClause + ")") templStr := `SELECT performer_id FROM {primaryTable} diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index a874f3967e3..89605ac8960 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -513,12 +513,13 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { performerIDs[performerIdxWithTwoTags], clearPerformerPartial(), models.Performer{ - ID: performerIDs[performerIdxWithTwoTags], - Name: getPerformerStringValue(performerIdxWithTwoTags, "Name"), - Favorite: true, - Aliases: models.NewRelatedStrings([]string{}), - TagIDs: models.NewRelatedIDs([]int{}), - StashIDs: models.NewRelatedStashIDs([]models.StashID{}), + ID: performerIDs[performerIdxWithTwoTags], + Name: getPerformerStringValue(performerIdxWithTwoTags, "Name"), + Favorite: getPerformerBoolValue(performerIdxWithTwoTags), + Aliases: models.NewRelatedStrings([]string{}), + TagIDs: models.NewRelatedIDs([]int{}), + StashIDs: models.NewRelatedStashIDs([]models.StashID{}), + IgnoreAutoTag: getIgnoreAutoTag(performerIdxWithTwoTags), }, false, }, @@ -1904,10 +1905,10 @@ func TestPerformerQuerySortScenesCount(t *testing.T) { assert.True(t, len(performers) > 0) - // first performer should be performerIdxWithTwoScenes + // first performer should be performerIdx1WithScene firstPerformer := performers[0] - assert.Equal(t, performerIDs[performerIdxWithTwoScenes], firstPerformer.ID) + assert.Equal(t, performerIDs[performerIdx1WithScene], firstPerformer.ID) // sort in ascending order direction = models.SortDirectionEnumAsc @@ -1920,7 +1921,7 @@ func TestPerformerQuerySortScenesCount(t *testing.T) { assert.True(t, len(performers) > 0) lastPerformer := performers[len(performers)-1] - assert.Equal(t, performerIDs[performerIdxWithTwoScenes], lastPerformer.ID) + assert.Equal(t, performerIDs[performerIdxWithTag], lastPerformer.ID) return nil }) @@ -2060,7 +2061,7 @@ func TestPerformerStore_FindByStashIDStatus(t *testing.T) { name: "!hasStashID", hasStashID: false, stashboxEndpoint: getPerformerStringValue(performerIdxWithScene, "endpoint"), - include: []int{performerIdxWithImage}, + include: []int{performerIdxWithTwoScenes}, exclude: []int{performerIdx2WithScene}, wantErr: false, }, diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 1a735bcd20c..1fe5bcdb0f2 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -959,7 +959,7 @@ func (qb *SceneStore) makeFilter(ctx context.Context, sceneFilter *models.SceneF query.handleCriterion(ctx, sceneTagCountCriterionHandler(qb, sceneFilter.TagCount)) query.handleCriterion(ctx, scenePerformersCriterionHandler(qb, sceneFilter.Performers)) query.handleCriterion(ctx, scenePerformerCountCriterionHandler(qb, sceneFilter.PerformerCount)) - query.handleCriterion(ctx, sceneStudioCriterionHandler(qb, sceneFilter.Studios)) + query.handleCriterion(ctx, studioCriterionHandler(sceneTable, sceneFilter.Studios)) query.handleCriterion(ctx, sceneMoviesCriterionHandler(qb, sceneFilter.Movies)) query.handleCriterion(ctx, scenePerformerTagsCriterionHandler(qb, sceneFilter.PerformerTags)) query.handleCriterion(ctx, scenePerformerFavoriteCriterionHandler(sceneFilter.PerformerFavorite)) @@ -1352,19 +1352,6 @@ func scenePerformerAgeCriterionHandler(performerAge *models.IntCriterionInput) c } } -func sceneStudioCriterionHandler(qb *SceneStore, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - h := hierarchicalMultiCriterionHandlerBuilder{ - tx: qb.tx, - - primaryTable: sceneTable, - foreignTable: studioTable, - foreignFK: studioIDColumn, - parentFK: "parent_id", - } - - return h.handler(studios) -} - func sceneMoviesCriterionHandler(qb *SceneStore, movies *models.MultiCriterionInput) criterionHandlerFunc { addJoinsFunc := func(f *filterBuilder) { qb.moviesRepository().join(f, "", "scenes.id") @@ -1374,38 +1361,12 @@ func sceneMoviesCriterionHandler(qb *SceneStore, movies *models.MultiCriterionIn return h.handler(movies) } -func scenePerformerTagsCriterionHandler(qb *SceneStore, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if tags != nil { - if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { - var notClause string - if tags.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - f.addLeftJoin("performers_scenes", "", "scenes.id = performers_scenes.scene_id") - f.addLeftJoin("performers_tags", "", "performers_scenes.performer_id = performers_tags.performer_id") - - f.addWhere(fmt.Sprintf("performers_tags.tag_id IS %s NULL", notClause)) - return - } - - if len(tags.Value) == 0 { - return - } - - valuesClause := getHierarchicalValues(ctx, qb.tx, tags.Value, tagTable, "tags_relations", "", tags.Depth) - - f.addWith(`performer_tags AS ( -SELECT ps.scene_id, t.column1 AS root_tag_id FROM performers_scenes ps -INNER JOIN performers_tags pt ON pt.performer_id = ps.performer_id -INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id -)`) - - f.addLeftJoin("performer_tags", "", "performer_tags.scene_id = scenes.id") - - addHierarchicalConditionClauses(f, *tags, "performer_tags", "root_tag_id") - } +func scenePerformerTagsCriterionHandler(qb *SceneStore, tags *models.HierarchicalMultiCriterionInput) criterionHandler { + return &joinedPerformerTagsHandler{ + criterion: tags, + primaryTable: sceneTable, + joinTable: performersScenesTable, + joinPrimaryKey: sceneIDColumn, } } diff --git a/pkg/sqlite/scene_marker.go b/pkg/sqlite/scene_marker.go index c4ae7dda720..04eeb1e3a44 100644 --- a/pkg/sqlite/scene_marker.go +++ b/pkg/sqlite/scene_marker.go @@ -209,7 +209,11 @@ func sceneMarkerTagsCriterionHandler(qb *sceneMarkerQueryBuilder, tags *models.H if len(tags.Value) == 0 { return } - valuesClause := getHierarchicalValues(ctx, qb.tx, tags.Value, tagTable, "tags_relations", "", tags.Depth) + valuesClause, err := getHierarchicalValues(ctx, qb.tx, tags.Value, tagTable, "tags_relations", "parent_id", "child_id", tags.Depth) + if err != nil { + f.setError(err) + return + } f.addWith(`marker_tags AS ( SELECT mt.scene_marker_id, t.column1 AS root_tag_id FROM scene_markers_tags mt @@ -229,32 +233,23 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = m.primary_tag_id func sceneMarkerSceneTagsCriterionHandler(qb *sceneMarkerQueryBuilder, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if tags != nil { - if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { - var notClause string - if tags.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } + f.addLeftJoin("scenes_tags", "", "scene_markers.scene_id = scenes_tags.scene_id") - f.addLeftJoin("scenes_tags", "", "scene_markers.scene_id = scenes_tags.scene_id") + h := joinedHierarchicalMultiCriterionHandlerBuilder{ + tx: qb.tx, - f.addWhere(fmt.Sprintf("scenes_tags.tag_id IS %s NULL", notClause)) - return - } + primaryTable: "scene_markers", + primaryKey: sceneIDColumn, + foreignTable: tagTable, + foreignFK: tagIDColumn, - if len(tags.Value) == 0 { - return + relationsTable: "tags_relations", + joinTable: "scenes_tags", + joinAs: "marker_scenes_tags", + primaryFK: sceneIDColumn, } - valuesClause := getHierarchicalValues(ctx, qb.tx, tags.Value, tagTable, "tags_relations", "", tags.Depth) - - f.addWith(`scene_tags AS ( -SELECT st.scene_id, t.column1 AS root_tag_id FROM scenes_tags st -INNER JOIN (` + valuesClause + `) t ON t.column2 = st.tag_id -)`) - - f.addLeftJoin("scene_tags", "", "scene_tags.scene_id = scene_markers.scene_id") - - addHierarchicalConditionClauses(f, *tags, "scene_tags", "root_tag_id") + h.handler(tags).handle(ctx, f) } } } diff --git a/pkg/sqlite/scene_marker_test.go b/pkg/sqlite/scene_marker_test.go index 9c5ae866fa5..b2f7b2ee670 100644 --- a/pkg/sqlite/scene_marker_test.go +++ b/pkg/sqlite/scene_marker_test.go @@ -5,9 +5,12 @@ package sqlite_test import ( "context" + "strconv" "testing" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/sliceutil/intslice" + "github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/sqlite" "github.com/stretchr/testify/assert" ) @@ -50,7 +53,7 @@ func TestMarkerCountByTagID(t *testing.T) { t.Errorf("error calling CountByTagID: %s", err.Error()) } - assert.Equal(t, 3, markerCount) + assert.Equal(t, 4, markerCount) markerCount, err = mqb.CountByTagID(ctx, tagIDs[tagIdxWithMarkers]) @@ -151,7 +154,7 @@ func TestMarkerQuerySceneTags(t *testing.T) { } withTxn(func(ctx context.Context) error { - testTags := func(m *models.SceneMarker, markerFilter *models.SceneMarkerFilterType) { + testTags := func(t *testing.T, m *models.SceneMarker, markerFilter *models.SceneMarkerFilterType) { s, err := db.Scene.Find(ctx, int(m.SceneID.Int64)) if err != nil { t.Errorf("error getting marker tag ids: %v", err) @@ -164,11 +167,40 @@ func TestMarkerQuerySceneTags(t *testing.T) { } tagIDs := s.TagIDs.List() - if markerFilter.SceneTags.Modifier == models.CriterionModifierIsNull && len(tagIDs) > 0 { - t.Errorf("expected marker %d to have no scene tags - found %d", m.ID, len(tagIDs)) - } - if markerFilter.SceneTags.Modifier == models.CriterionModifierNotNull && len(tagIDs) == 0 { - t.Errorf("expected marker %d to have scene tags - found 0", m.ID) + values, _ := stringslice.StringSliceToIntSlice(markerFilter.SceneTags.Value) + switch markerFilter.SceneTags.Modifier { + case models.CriterionModifierIsNull: + if len(tagIDs) > 0 { + t.Errorf("expected marker %d to have no scene tags - found %d", m.ID, len(tagIDs)) + } + case models.CriterionModifierNotNull: + if len(tagIDs) == 0 { + t.Errorf("expected marker %d to have scene tags - found 0", m.ID) + } + case models.CriterionModifierIncludes: + for _, v := range values { + assert.Contains(t, tagIDs, v) + } + case models.CriterionModifierExcludes: + for _, v := range values { + assert.NotContains(t, tagIDs, v) + } + case models.CriterionModifierEquals: + for _, v := range values { + assert.Contains(t, tagIDs, v) + } + assert.Len(t, tagIDs, len(values)) + case models.CriterionModifierNotEquals: + foundAll := true + for _, v := range values { + if !intslice.IntInclude(tagIDs, v) { + foundAll = false + break + } + } + if foundAll && len(tagIDs) == len(values) { + t.Errorf("expected marker %d to have scene tags not equal to %v - found %v", m.ID, values, tagIDs) + } } } @@ -191,6 +223,70 @@ func TestMarkerQuerySceneTags(t *testing.T) { }, nil, }, + { + "includes", + &models.SceneMarkerFilterType{ + SceneTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIncludes, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx3WithScene]), + }, + }, + }, + nil, + }, + { + "includes all", + &models.SceneMarkerFilterType{ + SceneTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIncludesAll, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithScene]), + strconv.Itoa(tagIDs[tagIdx3WithScene]), + }, + }, + }, + nil, + }, + { + "equals", + &models.SceneMarkerFilterType{ + SceneTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithScene]), + strconv.Itoa(tagIDs[tagIdx3WithScene]), + }, + }, + }, + nil, + }, + // not equals not supported + // { + // "not equals", + // &models.SceneMarkerFilterType{ + // SceneTags: &models.HierarchicalMultiCriterionInput{ + // Modifier: models.CriterionModifierNotEquals, + // Value: []string{ + // strconv.Itoa(tagIDs[tagIdx2WithScene]), + // strconv.Itoa(tagIDs[tagIdx3WithScene]), + // }, + // }, + // }, + // nil, + // }, + { + "excludes", + &models.SceneMarkerFilterType{ + SceneTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIncludes, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithScene]), + }, + }, + }, + nil, + }, } for _, tc := range cases { @@ -198,7 +294,7 @@ func TestMarkerQuerySceneTags(t *testing.T) { markers := queryMarkers(ctx, t, sqlite.SceneMarkerReaderWriter, tc.markerFilter, tc.findFilter) assert.Greater(t, len(markers), 0) for _, m := range markers { - testTags(m, tc.markerFilter) + testTags(t, m, tc.markerFilter) } }) } diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index 137319c31f6..7b676fe7689 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -668,7 +668,8 @@ func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) { sceneIDs[sceneIdxWithSpacedName], clearScenePartial(), models.Scene{ - ID: sceneIDs[sceneIdxWithSpacedName], + ID: sceneIDs[sceneIdxWithSpacedName], + OCounter: getOCounter(sceneIdxWithSpacedName), Files: models.NewRelatedVideoFiles([]*file.VideoFile{ makeSceneFile(sceneIdxWithSpacedName), }), @@ -677,6 +678,10 @@ func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) { PerformerIDs: models.NewRelatedIDs([]int{}), Movies: models.NewRelatedMovies([]models.MoviesScenes{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), + PlayCount: getScenePlayCount(sceneIdxWithSpacedName), + PlayDuration: getScenePlayDuration(sceneIdxWithSpacedName), + LastPlayedAt: getSceneLastPlayed(sceneIdxWithSpacedName), + ResumeTime: getSceneResumeTime(sceneIdxWithSpacedName), }, false, }, @@ -2101,6 +2106,8 @@ func sceneQueryQ(ctx context.Context, t *testing.T, sqb models.SceneReader, q st // no Q should return all results filter.Q = nil + pp := totalScenes + filter.PerPage = &pp scenes = queryScene(ctx, t, sqb, nil, &filter) assert.Len(t, scenes, totalScenes) @@ -2230,8 +2237,8 @@ func TestSceneQuery(t *testing.T) { return } - include := indexesToIDs(performerIDs, tt.includeIdxs) - exclude := indexesToIDs(performerIDs, tt.excludeIdxs) + include := indexesToIDs(sceneIDs, tt.includeIdxs) + exclude := indexesToIDs(sceneIDs, tt.excludeIdxs) for _, i := range include { assert.Contains(results.IDs, i) @@ -3057,7 +3064,13 @@ func queryScenes(ctx context.Context, t *testing.T, queryBuilder models.SceneRea }, } - return queryScene(ctx, t, queryBuilder, &sceneFilter, nil) + // needed so that we don't hit the default limit of 25 scenes + pp := 1000 + findFilter := &models.FindFilterType{ + PerPage: &pp, + } + + return queryScene(ctx, t, queryBuilder, &sceneFilter, findFilter) } func createScene(ctx context.Context, width int, height int) (*models.Scene, error) { @@ -3329,192 +3342,473 @@ func TestSceneQueryIsMissingPhash(t *testing.T) { } func TestSceneQueryPerformers(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Scene - performerCriterion := models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdxWithScene]), - strconv.Itoa(performerIDs[performerIdx1WithScene]), + tests := []struct { + name string + filter models.MultiCriterionInput + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(performerIDs[performerIdxWithScene]), + strconv.Itoa(performerIDs[performerIdx1WithScene]), + }, + Modifier: models.CriterionModifierIncludes, }, - Modifier: models.CriterionModifierIncludes, - } - - sceneFilter := models.SceneFilterType{ - Performers: &performerCriterion, - } - - scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) - - assert.Len(t, scenes, 2) - - // ensure ids are correct - for _, scene := range scenes { - assert.True(t, scene.ID == sceneIDs[sceneIdxWithPerformer] || scene.ID == sceneIDs[sceneIdxWithTwoPerformers]) - } - - performerCriterion = models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdx1WithScene]), - strconv.Itoa(performerIDs[performerIdx2WithScene]), + []int{ + sceneIdxWithPerformer, + sceneIdxWithTwoPerformers, }, - Modifier: models.CriterionModifierIncludesAll, - } - - scenes = queryScene(ctx, t, sqb, &sceneFilter, nil) - - assert.Len(t, scenes, 1) - assert.Equal(t, sceneIDs[sceneIdxWithTwoPerformers], scenes[0].ID) - - performerCriterion = models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdx1WithScene]), + []int{ + sceneIdxWithGallery, }, - Modifier: models.CriterionModifierExcludes, - } + false, + }, + { + "includes all", + models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(performerIDs[performerIdx1WithScene]), + strconv.Itoa(performerIDs[performerIdx2WithScene]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + []int{ + sceneIdxWithTwoPerformers, + }, + []int{ + sceneIdxWithPerformer, + }, + false, + }, + { + "excludes", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[performerIdx1WithScene])}, + }, + nil, + []int{sceneIdxWithTwoPerformers}, + false, + }, + { + "is null", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + []int{sceneIdxWithTag}, + []int{ + sceneIdxWithPerformer, + sceneIdxWithTwoPerformers, + sceneIdxWithPerformerTwoTags, + }, + false, + }, + { + "not null", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + []int{ + sceneIdxWithPerformer, + sceneIdxWithTwoPerformers, + sceneIdxWithPerformerTwoTags, + }, + []int{sceneIdxWithTag}, + false, + }, + { + "equals", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[performerIdx1WithScene]), + strconv.Itoa(tagIDs[performerIdx2WithScene]), + }, + }, + []int{sceneIdxWithTwoPerformers}, + []int{ + sceneIdxWithThreePerformers, + }, + false, + }, + { + "not equals", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[performerIdx1WithScene]), + strconv.Itoa(tagIDs[performerIdx2WithScene]), + }, + }, + nil, + nil, + true, + }, + } - q := getSceneStringValue(sceneIdxWithTwoPerformers, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) - assert.Len(t, scenes, 0) + results, err := db.Scene.Query(ctx, models.SceneQueryOptions{ + SceneFilter: &models.SceneFilterType{ + Performers: &tt.filter, + }, + }) + if (err != nil) != tt.wantErr { + t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - return nil - }) + include := indexesToIDs(sceneIDs, tt.includeIdxs) + exclude := indexesToIDs(sceneIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(results.IDs, i) + } + for _, e := range exclude { + assert.NotContains(results.IDs, e) + } + }) + } } func TestSceneQueryTags(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Scene - tagCriterion := models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdxWithScene]), - strconv.Itoa(tagIDs[tagIdx1WithScene]), + tests := []struct { + name string + filter models.HierarchicalMultiCriterionInput + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithScene]), + strconv.Itoa(tagIDs[tagIdx1WithScene]), + }, + Modifier: models.CriterionModifierIncludes, }, - Modifier: models.CriterionModifierIncludes, - } - - sceneFilter := models.SceneFilterType{ - Tags: &tagCriterion, - } - - scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) - assert.Len(t, scenes, 2) - - // ensure ids are correct - for _, scene := range scenes { - assert.True(t, scene.ID == sceneIDs[sceneIdxWithTag] || scene.ID == sceneIDs[sceneIdxWithTwoTags]) - } - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithScene]), - strconv.Itoa(tagIDs[tagIdx2WithScene]), + []int{ + sceneIdxWithTag, + sceneIdxWithTwoTags, }, - Modifier: models.CriterionModifierIncludesAll, - } - - scenes = queryScene(ctx, t, sqb, &sceneFilter, nil) - - assert.Len(t, scenes, 1) - assert.Equal(t, sceneIDs[sceneIdxWithTwoTags], scenes[0].ID) - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithScene]), + []int{ + sceneIdxWithGallery, }, - Modifier: models.CriterionModifierExcludes, - } + false, + }, + { + "includes all", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithScene]), + strconv.Itoa(tagIDs[tagIdx2WithScene]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + []int{ + sceneIdxWithTwoTags, + }, + []int{ + sceneIdxWithTag, + }, + false, + }, + { + "excludes", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[tagIdx1WithScene])}, + }, + nil, + []int{sceneIdxWithTwoTags}, + false, + }, + { + "is null", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + []int{sceneIdx1WithPerformer}, + []int{ + sceneIdxWithTag, + sceneIdxWithTwoTags, + sceneIdxWithMarkerAndTag, + }, + false, + }, + { + "not null", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + []int{ + sceneIdxWithTag, + sceneIdxWithTwoTags, + sceneIdxWithMarkerAndTag, + }, + []int{sceneIdx1WithPerformer}, + false, + }, + { + "equals", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithScene]), + strconv.Itoa(tagIDs[tagIdx2WithScene]), + }, + }, + []int{sceneIdxWithTwoTags}, + []int{ + sceneIdxWithThreeTags, + }, + false, + }, + { + "not equals", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithScene]), + strconv.Itoa(tagIDs[tagIdx2WithScene]), + }, + }, + nil, + nil, + true, + }, + } - q := getSceneStringValue(sceneIdxWithTwoTags, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) - assert.Len(t, scenes, 0) + results, err := db.Scene.Query(ctx, models.SceneQueryOptions{ + SceneFilter: &models.SceneFilterType{ + Tags: &tt.filter, + }, + }) + if (err != nil) != tt.wantErr { + t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - return nil - }) + include := indexesToIDs(sceneIDs, tt.includeIdxs) + exclude := indexesToIDs(sceneIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(results.IDs, i) + } + for _, e := range exclude { + assert.NotContains(results.IDs, e) + } + }) + } } func TestSceneQueryPerformerTags(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Scene - tagCriterion := models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdxWithPerformer]), - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), - }, - Modifier: models.CriterionModifierIncludes, - } - - sceneFilter := models.SceneFilterType{ - PerformerTags: &tagCriterion, - } - - scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) - assert.Len(t, scenes, 2) + allDepth := -1 - // ensure ids are correct - for _, scene := range scenes { - assert.True(t, scene.ID == sceneIDs[sceneIdxWithPerformerTag] || scene.ID == sceneIDs[sceneIdxWithPerformerTwoTags]) - } - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), - strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + tests := []struct { + name string + findFilter *models.FindFilterType + filter *models.SceneFilterType + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithPerformer]), + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + }, + Modifier: models.CriterionModifierIncludes, + }, }, - Modifier: models.CriterionModifierIncludesAll, - } - - scenes = queryScene(ctx, t, sqb, &sceneFilter, nil) - - assert.Len(t, scenes, 1) - assert.Equal(t, sceneIDs[sceneIdxWithPerformerTwoTags], scenes[0].ID) - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + []int{ + sceneIdxWithPerformerTag, + sceneIdxWithPerformerTwoTags, + sceneIdxWithTwoPerformerTag, }, - Modifier: models.CriterionModifierExcludes, - } - - q := getSceneStringValue(sceneIdxWithPerformerTwoTags, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } - - scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) - assert.Len(t, scenes, 0) - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Modifier: models.CriterionModifierIsNull, - } - q = getSceneStringValue(sceneIdx1WithPerformer, titleField) - - scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) - assert.Len(t, scenes, 1) - assert.Equal(t, sceneIDs[sceneIdx1WithPerformer], scenes[0].ID) - - q = getSceneStringValue(sceneIdxWithPerformerTag, titleField) - scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) - assert.Len(t, scenes, 0) + []int{ + sceneIdxWithPerformer, + }, + false, + }, + { + "includes sub-tags", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithParentAndChild]), + }, + Depth: &allDepth, + Modifier: models.CriterionModifierIncludes, + }, + }, + []int{ + sceneIdxWithPerformerParentTag, + }, + []int{ + sceneIdxWithPerformer, + sceneIdxWithPerformerTag, + sceneIdxWithPerformerTwoTags, + sceneIdxWithTwoPerformerTag, + }, + false, + }, + { + "includes all", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + }, + []int{ + sceneIdxWithPerformerTwoTags, + }, + []int{ + sceneIdxWithPerformer, + sceneIdxWithPerformerTag, + sceneIdxWithTwoPerformerTag, + }, + false, + }, + { + "excludes performer tag tagIdx2WithPerformer", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[tagIdx2WithPerformer])}, + }, + }, + nil, + []int{sceneIdxWithTwoPerformerTag}, + false, + }, + { + "excludes sub-tags", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithParentAndChild]), + }, + Depth: &allDepth, + Modifier: models.CriterionModifierExcludes, + }, + }, + []int{ + sceneIdxWithPerformer, + sceneIdxWithPerformerTag, + sceneIdxWithPerformerTwoTags, + sceneIdxWithTwoPerformerTag, + }, + []int{ + sceneIdxWithPerformerParentTag, + }, + false, + }, + { + "is null", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + }, + []int{sceneIdx1WithPerformer}, + []int{sceneIdxWithPerformerTag}, + false, + }, + { + "not null", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + }, + []int{sceneIdxWithPerformerTag}, + []int{sceneIdx1WithPerformer}, + false, + }, + { + "equals", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + }, + }, + nil, + nil, + true, + }, + { + "not equals", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + }, + }, + nil, + nil, + true, + }, + } - tagCriterion.Modifier = models.CriterionModifierNotNull + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) - assert.Len(t, scenes, 1) - assert.Equal(t, sceneIDs[sceneIdxWithPerformerTag], scenes[0].ID) + results, err := db.Scene.Query(ctx, models.SceneQueryOptions{ + SceneFilter: tt.filter, + QueryOptions: models.QueryOptions{ + FindFilter: tt.findFilter, + }, + }) + if (err != nil) != tt.wantErr { + t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - q = getSceneStringValue(sceneIdx1WithPerformer, titleField) - scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) - assert.Len(t, scenes, 0) + include := indexesToIDs(sceneIDs, tt.includeIdxs) + exclude := indexesToIDs(sceneIDs, tt.excludeIdxs) - return nil - }) + for _, i := range include { + assert.Contains(results.IDs, i) + } + for _, e := range exclude { + assert.NotContains(results.IDs, e) + } + }) + } } func TestSceneQueryStudio(t *testing.T) { @@ -3561,6 +3855,30 @@ func TestSceneQueryStudio(t *testing.T) { []int{sceneIDs[sceneIdxWithGallery]}, false, }, + { + "equals", + "", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithScene]), + }, + Modifier: models.CriterionModifierEquals, + }, + []int{sceneIDs[sceneIdxWithStudio]}, + false, + }, + { + "not equals", + getSceneStringValue(sceneIdxWithStudio, titleField), + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithScene]), + }, + Modifier: models.CriterionModifierNotEquals, + }, + []int{}, + false, + }, } qb := db.Scene diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 94c92035b86..12a56947b4f 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -60,19 +60,24 @@ const ( sceneIdx1WithPerformer sceneIdx2WithPerformer sceneIdxWithTwoPerformers + sceneIdxWithThreePerformers sceneIdxWithTag sceneIdxWithTwoTags + sceneIdxWithThreeTags sceneIdxWithMarkerAndTag + sceneIdxWithMarkerTwoTags sceneIdxWithStudio sceneIdx1WithStudio sceneIdx2WithStudio sceneIdxWithMarkers sceneIdxWithPerformerTag + sceneIdxWithTwoPerformerTag sceneIdxWithPerformerTwoTags sceneIdxWithSpacedName sceneIdxWithStudioPerformer sceneIdxWithGrandChildStudio sceneIdxMissingPhash + sceneIdxWithPerformerParentTag // new indexes above lastSceneIdx @@ -90,16 +95,20 @@ const ( imageIdx1WithPerformer imageIdx2WithPerformer imageIdxWithTwoPerformers + imageIdxWithThreePerformers imageIdxWithTag imageIdxWithTwoTags + imageIdxWithThreeTags imageIdxWithStudio imageIdx1WithStudio imageIdx2WithStudio imageIdxWithStudioPerformer imageIdxInZip imageIdxWithPerformerTag + imageIdxWithTwoPerformerTag imageIdxWithPerformerTwoTags imageIdxWithGrandChildStudio + imageIdxWithPerformerParentTag // new indexes above totalImages ) @@ -108,20 +117,25 @@ const ( performerIdxWithScene = iota performerIdx1WithScene performerIdx2WithScene + performerIdx3WithScene performerIdxWithTwoScenes performerIdxWithImage performerIdxWithTwoImages performerIdx1WithImage performerIdx2WithImage + performerIdx3WithImage performerIdxWithTag + performerIdx2WithTag performerIdxWithTwoTags performerIdxWithGallery performerIdxWithTwoGalleries performerIdx1WithGallery performerIdx2WithGallery + performerIdx3WithGallery performerIdxWithSceneStudio performerIdxWithImageStudio performerIdxWithGalleryStudio + performerIdxWithParentTag // new indexes above // performers with dup names start from the end performerIdx1WithDupName @@ -155,16 +169,20 @@ const ( galleryIdx1WithPerformer galleryIdx2WithPerformer galleryIdxWithTwoPerformers + galleryIdxWithThreePerformers galleryIdxWithTag galleryIdxWithTwoTags + galleryIdxWithThreeTags galleryIdxWithStudio galleryIdx1WithStudio galleryIdx2WithStudio galleryIdxWithPerformerTag + galleryIdxWithTwoPerformerTag galleryIdxWithPerformerTwoTags galleryIdxWithStudioPerformer galleryIdxWithGrandChildStudio galleryIdxWithoutFile + galleryIdxWithPerformerParentTag // new indexes above lastGalleryIdx @@ -182,12 +200,14 @@ const ( tagIdxWithImage tagIdx1WithImage tagIdx2WithImage + tagIdx3WithImage tagIdxWithPerformer tagIdx1WithPerformer tagIdx2WithPerformer tagIdxWithGallery tagIdx1WithGallery tagIdx2WithGallery + tagIdx3WithGallery tagIdxWithChildTag tagIdxWithParentTag tagIdxWithGrandChild @@ -332,19 +352,24 @@ var ( var ( sceneTags = linkMap{ - sceneIdxWithTag: {tagIdxWithScene}, - sceneIdxWithTwoTags: {tagIdx1WithScene, tagIdx2WithScene}, - sceneIdxWithMarkerAndTag: {tagIdx3WithScene}, + sceneIdxWithTag: {tagIdxWithScene}, + sceneIdxWithTwoTags: {tagIdx1WithScene, tagIdx2WithScene}, + sceneIdxWithThreeTags: {tagIdx1WithScene, tagIdx2WithScene, tagIdx3WithScene}, + sceneIdxWithMarkerAndTag: {tagIdx3WithScene}, + sceneIdxWithMarkerTwoTags: {tagIdx2WithScene, tagIdx3WithScene}, } scenePerformers = linkMap{ - sceneIdxWithPerformer: {performerIdxWithScene}, - sceneIdxWithTwoPerformers: {performerIdx1WithScene, performerIdx2WithScene}, - sceneIdxWithPerformerTag: {performerIdxWithTag}, - sceneIdxWithPerformerTwoTags: {performerIdxWithTwoTags}, - sceneIdx1WithPerformer: {performerIdxWithTwoScenes}, - sceneIdx2WithPerformer: {performerIdxWithTwoScenes}, - sceneIdxWithStudioPerformer: {performerIdxWithSceneStudio}, + sceneIdxWithPerformer: {performerIdxWithScene}, + sceneIdxWithTwoPerformers: {performerIdx1WithScene, performerIdx2WithScene}, + sceneIdxWithThreePerformers: {performerIdx1WithScene, performerIdx2WithScene, performerIdx3WithScene}, + sceneIdxWithPerformerTag: {performerIdxWithTag}, + sceneIdxWithTwoPerformerTag: {performerIdxWithTag, performerIdx2WithTag}, + sceneIdxWithPerformerTwoTags: {performerIdxWithTwoTags}, + sceneIdx1WithPerformer: {performerIdxWithTwoScenes}, + sceneIdx2WithPerformer: {performerIdxWithTwoScenes}, + sceneIdxWithStudioPerformer: {performerIdxWithSceneStudio}, + sceneIdxWithPerformerParentTag: {performerIdxWithParentTag}, } sceneGalleries = linkMap{ @@ -376,6 +401,7 @@ var ( {sceneIdxWithMarkers, tagIdxWithPrimaryMarkers, nil}, {sceneIdxWithMarkers, tagIdxWithPrimaryMarkers, []int{tagIdxWithMarkers}}, {sceneIdxWithMarkerAndTag, tagIdxWithPrimaryMarkers, nil}, + {sceneIdxWithMarkerTwoTags, tagIdxWithPrimaryMarkers, nil}, } ) @@ -407,29 +433,36 @@ var ( imageIdxWithGrandChildStudio: studioIdxWithGrandParent, } imageTags = linkMap{ - imageIdxWithTag: {tagIdxWithImage}, - imageIdxWithTwoTags: {tagIdx1WithImage, tagIdx2WithImage}, + imageIdxWithTag: {tagIdxWithImage}, + imageIdxWithTwoTags: {tagIdx1WithImage, tagIdx2WithImage}, + imageIdxWithThreeTags: {tagIdx1WithImage, tagIdx2WithImage, tagIdx3WithImage}, } imagePerformers = linkMap{ - imageIdxWithPerformer: {performerIdxWithImage}, - imageIdxWithTwoPerformers: {performerIdx1WithImage, performerIdx2WithImage}, - imageIdxWithPerformerTag: {performerIdxWithTag}, - imageIdxWithPerformerTwoTags: {performerIdxWithTwoTags}, - imageIdx1WithPerformer: {performerIdxWithTwoImages}, - imageIdx2WithPerformer: {performerIdxWithTwoImages}, - imageIdxWithStudioPerformer: {performerIdxWithImageStudio}, + imageIdxWithPerformer: {performerIdxWithImage}, + imageIdxWithTwoPerformers: {performerIdx1WithImage, performerIdx2WithImage}, + imageIdxWithThreePerformers: {performerIdx1WithImage, performerIdx2WithImage, performerIdx3WithImage}, + imageIdxWithPerformerTag: {performerIdxWithTag}, + imageIdxWithTwoPerformerTag: {performerIdxWithTag, performerIdx2WithTag}, + imageIdxWithPerformerTwoTags: {performerIdxWithTwoTags}, + imageIdx1WithPerformer: {performerIdxWithTwoImages}, + imageIdx2WithPerformer: {performerIdxWithTwoImages}, + imageIdxWithStudioPerformer: {performerIdxWithImageStudio}, + imageIdxWithPerformerParentTag: {performerIdxWithParentTag}, } ) var ( galleryPerformers = linkMap{ - galleryIdxWithPerformer: {performerIdxWithGallery}, - galleryIdxWithTwoPerformers: {performerIdx1WithGallery, performerIdx2WithGallery}, - galleryIdxWithPerformerTag: {performerIdxWithTag}, - galleryIdxWithPerformerTwoTags: {performerIdxWithTwoTags}, - galleryIdx1WithPerformer: {performerIdxWithTwoGalleries}, - galleryIdx2WithPerformer: {performerIdxWithTwoGalleries}, - galleryIdxWithStudioPerformer: {performerIdxWithGalleryStudio}, + galleryIdxWithPerformer: {performerIdxWithGallery}, + galleryIdxWithTwoPerformers: {performerIdx1WithGallery, performerIdx2WithGallery}, + galleryIdxWithThreePerformers: {performerIdx1WithGallery, performerIdx2WithGallery, performerIdx3WithGallery}, + galleryIdxWithPerformerTag: {performerIdxWithTag}, + galleryIdxWithTwoPerformerTag: {performerIdxWithTag, performerIdx2WithTag}, + galleryIdxWithPerformerTwoTags: {performerIdxWithTwoTags}, + galleryIdx1WithPerformer: {performerIdxWithTwoGalleries}, + galleryIdx2WithPerformer: {performerIdxWithTwoGalleries}, + galleryIdxWithStudioPerformer: {performerIdxWithGalleryStudio}, + galleryIdxWithPerformerParentTag: {performerIdxWithParentTag}, } galleryStudios = map[int]int{ @@ -441,8 +474,9 @@ var ( } galleryTags = linkMap{ - galleryIdxWithTag: {tagIdxWithGallery}, - galleryIdxWithTwoTags: {tagIdx1WithGallery, tagIdx2WithGallery}, + galleryIdxWithTag: {tagIdxWithGallery}, + galleryIdxWithTwoTags: {tagIdx1WithGallery, tagIdx2WithGallery}, + galleryIdxWithThreeTags: {tagIdx1WithGallery, tagIdx2WithGallery, tagIdx3WithGallery}, } ) @@ -462,8 +496,10 @@ var ( var ( performerTags = linkMap{ - performerIdxWithTag: {tagIdxWithPerformer}, - performerIdxWithTwoTags: {tagIdx1WithPerformer, tagIdx2WithPerformer}, + performerIdxWithTag: {tagIdxWithPerformer}, + performerIdx2WithTag: {tagIdx2WithPerformer}, + performerIdxWithTwoTags: {tagIdx1WithPerformer, tagIdx2WithPerformer}, + performerIdxWithParentTag: {tagIdxWithParentAndChild}, } ) @@ -484,6 +520,16 @@ func indexesToIDs(ids []int, indexes []int) []int { return ret } +func indexFromID(ids []int, id int) int { + for i, v := range ids { + if v == id { + return i + } + } + + return -1 +} + var db *sqlite.Database func TestMain(m *testing.M) { @@ -1431,11 +1477,8 @@ func getTagStringValue(index int, field string) string { } func getTagSceneCount(id int) int { - if id == tagIDs[tagIdx1WithScene] || id == tagIDs[tagIdx2WithScene] || id == tagIDs[tagIdxWithScene] || id == tagIDs[tagIdx3WithScene] { - return 1 - } - - return 0 + idx := indexFromID(tagIDs, id) + return len(sceneTags.reverseLookup(idx)) } func getTagMarkerCount(id int) int { @@ -1451,27 +1494,18 @@ func getTagMarkerCount(id int) int { } func getTagImageCount(id int) int { - if id == tagIDs[tagIdx1WithImage] || id == tagIDs[tagIdx2WithImage] || id == tagIDs[tagIdxWithImage] { - return 1 - } - - return 0 + idx := indexFromID(tagIDs, id) + return len(imageTags.reverseLookup(idx)) } func getTagGalleryCount(id int) int { - if id == tagIDs[tagIdx1WithGallery] || id == tagIDs[tagIdx2WithGallery] || id == tagIDs[tagIdxWithGallery] { - return 1 - } - - return 0 + idx := indexFromID(tagIDs, id) + return len(galleryTags.reverseLookup(idx)) } func getTagPerformerCount(id int) int { - if id == tagIDs[tagIdx1WithPerformer] || id == tagIDs[tagIdx2WithPerformer] || id == tagIDs[tagIdxWithPerformer] { - return 1 - } - - return 0 + idx := indexFromID(tagIDs, id) + return len(performerTags.reverseLookup(idx)) } func getTagParentCount(id int) int { diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 22f7bde1c9c..0c9f7422e54 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -474,9 +474,19 @@ func tagMarkerCountCriterionHandler(qb *tagQueryBuilder, markerCount *models.Int } } -func tagParentsCriterionHandler(qb *tagQueryBuilder, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { +func tagParentsCriterionHandler(qb *tagQueryBuilder, criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { - if tags != nil { + if criterion != nil { + tags := criterion.CombineExcludes() + + // validate the modifier + switch tags.Modifier { + case models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull: + // valid + default: + f.setError(fmt.Errorf("invalid modifier %s for tag parent/children", criterion.Modifier)) + } + if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { var notClause string if tags.Modifier == models.CriterionModifierNotNull { @@ -489,43 +499,88 @@ func tagParentsCriterionHandler(qb *tagQueryBuilder, tags *models.HierarchicalMu return } - if len(tags.Value) == 0 { + if len(tags.Value) == 0 && len(tags.Excludes) == 0 { return } - var args []interface{} - for _, val := range tags.Value { - args = append(args, val) - } + if len(tags.Value) > 0 { + var args []interface{} + for _, val := range tags.Value { + args = append(args, val) + } - depthVal := 0 - if tags.Depth != nil { - depthVal = *tags.Depth - } + depthVal := 0 + if tags.Depth != nil { + depthVal = *tags.Depth + } + + var depthCondition string + if depthVal != -1 { + depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) + } + + query := `parents AS ( + SELECT parent_id AS root_id, child_id AS item_id, 0 AS depth FROM tags_relations WHERE parent_id IN` + getInBinding(len(tags.Value)) + ` + UNION + SELECT root_id, child_id, depth + 1 FROM tags_relations INNER JOIN parents ON item_id = parent_id ` + depthCondition + ` + )` - var depthCondition string - if depthVal != -1 { - depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) + f.addRecursiveWith(query, args...) + + f.addLeftJoin("parents", "", "parents.item_id = tags.id") + + addHierarchicalConditionClauses(f, tags, "parents", "root_id") } - query := `parents AS ( - SELECT parent_id AS root_id, child_id AS item_id, 0 AS depth FROM tags_relations WHERE parent_id IN` + getInBinding(len(tags.Value)) + ` - UNION - SELECT root_id, child_id, depth + 1 FROM tags_relations INNER JOIN parents ON item_id = parent_id ` + depthCondition + ` -)` + if len(tags.Excludes) > 0 { + var args []interface{} + for _, val := range tags.Excludes { + args = append(args, val) + } + + depthVal := 0 + if tags.Depth != nil { + depthVal = *tags.Depth + } + + var depthCondition string + if depthVal != -1 { + depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) + } - f.addRecursiveWith(query, args...) + query := `parents2 AS ( + SELECT parent_id AS root_id, child_id AS item_id, 0 AS depth FROM tags_relations WHERE parent_id IN` + getInBinding(len(tags.Excludes)) + ` + UNION + SELECT root_id, child_id, depth + 1 FROM tags_relations INNER JOIN parents2 ON item_id = parent_id ` + depthCondition + ` + )` - f.addLeftJoin("parents", "", "parents.item_id = tags.id") + f.addRecursiveWith(query, args...) - addHierarchicalConditionClauses(f, *tags, "parents", "root_id") + f.addLeftJoin("parents2", "", "parents2.item_id = tags.id") + + addHierarchicalConditionClauses(f, models.HierarchicalMultiCriterionInput{ + Value: tags.Excludes, + Depth: tags.Depth, + Modifier: models.CriterionModifierExcludes, + }, "parents2", "root_id") + } } } } -func tagChildrenCriterionHandler(qb *tagQueryBuilder, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { +func tagChildrenCriterionHandler(qb *tagQueryBuilder, criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { - if tags != nil { + if criterion != nil { + tags := criterion.CombineExcludes() + + // validate the modifier + switch tags.Modifier { + case models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull: + // valid + default: + f.setError(fmt.Errorf("invalid modifier %s for tag parent/children", criterion.Modifier)) + } + if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { var notClause string if tags.Modifier == models.CriterionModifierNotNull { @@ -538,36 +593,71 @@ func tagChildrenCriterionHandler(qb *tagQueryBuilder, tags *models.HierarchicalM return } - if len(tags.Value) == 0 { + if len(tags.Value) == 0 && len(tags.Excludes) == 0 { return } - var args []interface{} - for _, val := range tags.Value { - args = append(args, val) - } + if len(tags.Value) > 0 { + var args []interface{} + for _, val := range tags.Value { + args = append(args, val) + } - depthVal := 0 - if tags.Depth != nil { - depthVal = *tags.Depth - } + depthVal := 0 + if tags.Depth != nil { + depthVal = *tags.Depth + } + + var depthCondition string + if depthVal != -1 { + depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) + } + + query := `children AS ( + SELECT child_id AS root_id, parent_id AS item_id, 0 AS depth FROM tags_relations WHERE child_id IN` + getInBinding(len(tags.Value)) + ` + UNION + SELECT root_id, parent_id, depth + 1 FROM tags_relations INNER JOIN children ON item_id = child_id ` + depthCondition + ` + )` - var depthCondition string - if depthVal != -1 { - depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) + f.addRecursiveWith(query, args...) + + f.addLeftJoin("children", "", "children.item_id = tags.id") + + addHierarchicalConditionClauses(f, tags, "children", "root_id") } - query := `children AS ( - SELECT child_id AS root_id, parent_id AS item_id, 0 AS depth FROM tags_relations WHERE child_id IN` + getInBinding(len(tags.Value)) + ` - UNION - SELECT root_id, parent_id, depth + 1 FROM tags_relations INNER JOIN children ON item_id = child_id ` + depthCondition + ` -)` + if len(tags.Excludes) > 0 { + var args []interface{} + for _, val := range tags.Excludes { + args = append(args, val) + } + + depthVal := 0 + if tags.Depth != nil { + depthVal = *tags.Depth + } + + var depthCondition string + if depthVal != -1 { + depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) + } - f.addRecursiveWith(query, args...) + query := `children2 AS ( + SELECT child_id AS root_id, parent_id AS item_id, 0 AS depth FROM tags_relations WHERE child_id IN` + getInBinding(len(tags.Excludes)) + ` + UNION + SELECT root_id, parent_id, depth + 1 FROM tags_relations INNER JOIN children2 ON item_id = child_id ` + depthCondition + ` + )` - f.addLeftJoin("children", "", "children.item_id = tags.id") + f.addRecursiveWith(query, args...) - addHierarchicalConditionClauses(f, *tags, "children", "root_id") + f.addLeftJoin("children2", "", "children2.item_id = tags.id") + + addHierarchicalConditionClauses(f, models.HierarchicalMultiCriterionInput{ + Value: tags.Excludes, + Depth: tags.Depth, + Modifier: models.CriterionModifierExcludes, + }, "children2", "root_id") + } } } } diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index d3ff5459fc6..5c601ca80fb 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -187,7 +187,7 @@ func TestTagQuerySort(t *testing.T) { tags := queryTags(ctx, t, sqb, nil, findFilter) assert := assert.New(t) - assert.Equal(tagIDs[tagIdxWithScene], tags[0].ID) + assert.Equal(tagIDs[tagIdx2WithScene], tags[0].ID) sortBy = "scene_markers_count" tags = queryTags(ctx, t, sqb, nil, findFilter) @@ -195,15 +195,15 @@ func TestTagQuerySort(t *testing.T) { sortBy = "images_count" tags = queryTags(ctx, t, sqb, nil, findFilter) - assert.Equal(tagIDs[tagIdxWithImage], tags[0].ID) + assert.Equal(tagIDs[tagIdx1WithImage], tags[0].ID) sortBy = "galleries_count" tags = queryTags(ctx, t, sqb, nil, findFilter) - assert.Equal(tagIDs[tagIdxWithGallery], tags[0].ID) + assert.Equal(tagIDs[tagIdx1WithGallery], tags[0].ID) sortBy = "performers_count" tags = queryTags(ctx, t, sqb, nil, findFilter) - assert.Equal(tagIDs[tagIdxWithPerformer], tags[0].ID) + assert.Equal(tagIDs[tagIdx2WithPerformer], tags[0].ID) return nil }) diff --git a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx index d14997ef6f1..48caccb16d8 100644 --- a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx @@ -309,14 +309,18 @@ export const HierarchicalObjectsFilter = < return (
- - onDepthChanged(criterion.value.depth !== 0 ? 0 : -1)} - /> - + {criterion.modifier !== CriterionModifier.Equals && ( + + + onDepthChanged(criterion.value.depth !== 0 ? 0 : -1) + } + /> + + )} {criterion.value.depth !== 0 && ( diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index fdf12995b04..b8572909bed 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -567,6 +567,11 @@ export class IHierarchicalLabeledIdCriterion extends Criterion v.id); } @@ -574,7 +579,7 @@ export class IHierarchicalLabeledIdCriterion extends Criterion v.id), excludes: excludes, modifier: this.modifier, - depth: this.value.depth, + depth, }; } diff --git a/ui/v2.5/src/models/list-filter/criteria/tags.ts b/ui/v2.5/src/models/list-filter/criteria/tags.ts index 7266fcf3d5e..eae7386ecfb 100644 --- a/ui/v2.5/src/models/list-filter/criteria/tags.ts +++ b/ui/v2.5/src/models/list-filter/criteria/tags.ts @@ -4,14 +4,24 @@ import { CriterionOption, IHierarchicalLabeledIdCriterion } from "./criterion"; export class TagsCriterion extends IHierarchicalLabeledIdCriterion {} -class tagsCriterionOption extends CriterionOption { - constructor(messageID: string, value: CriterionType, parameterName: string) { - const modifierOptions = [ - CriterionModifier.Includes, - CriterionModifier.IncludesAll, - CriterionModifier.Equals, - ]; +const tagsModifierOptions = [ + CriterionModifier.Includes, + CriterionModifier.IncludesAll, + CriterionModifier.Equals, +]; + +const withoutEqualsModifierOptions = [ + CriterionModifier.Includes, + CriterionModifier.IncludesAll, +]; +class tagsCriterionOption extends CriterionOption { + constructor( + messageID: string, + value: CriterionType, + parameterName: string, + modifierOptions: CriterionModifier[] + ) { let defaultModifier = CriterionModifier.IncludesAll; super({ @@ -27,25 +37,30 @@ class tagsCriterionOption extends CriterionOption { export const TagsCriterionOption = new tagsCriterionOption( "tags", "tags", - "tags" + "tags", + tagsModifierOptions ); export const SceneTagsCriterionOption = new tagsCriterionOption( "sceneTags", "sceneTags", - "scene_tags" + "scene_tags", + tagsModifierOptions ); export const PerformerTagsCriterionOption = new tagsCriterionOption( "performerTags", "performerTags", - "performer_tags" + "performer_tags", + withoutEqualsModifierOptions ); export const ParentTagsCriterionOption = new tagsCriterionOption( "parent_tags", "parentTags", - "parents" + "parents", + withoutEqualsModifierOptions ); export const ChildTagsCriterionOption = new tagsCriterionOption( "sub_tags", "childTags", - "children" + "children", + withoutEqualsModifierOptions ); From e22291d91212bc19acd1069b1f496b41190ef424 Mon Sep 17 00:00:00 2001 From: NodudeWasTaken <75137537+NodudeWasTaken@users.noreply.github.com> Date: Tue, 6 Jun 2023 05:24:13 +0200 Subject: [PATCH 051/135] Fix iOS captions (#3729) * Fix iOS captions and fix sceneplayer exceeding container size --- ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx | 1 - ui/v2.5/src/components/ScenePlayer/styles.scss | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index b4699e454c7..249111b92b4 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -278,7 +278,6 @@ export const ScenePlayer: React.FC = ({ chaptersButton: false, }, html5: { - nativeTextTracks: false, dash: { updateSettings: [ { diff --git a/ui/v2.5/src/components/ScenePlayer/styles.scss b/ui/v2.5/src/components/ScenePlayer/styles.scss index 63cc0bc3c4c..7fcd6c27b27 100644 --- a/ui/v2.5/src/components/ScenePlayer/styles.scss +++ b/ui/v2.5/src/components/ScenePlayer/styles.scss @@ -23,6 +23,7 @@ $sceneTabWidth: 450px; .video-wrapper { height: 56.25vw; + max-height: calc(100vh - #{$menuHeight}); overflow: hidden; width: 100%; From 0c999080c27a03769d034a190daa04a807986db2 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 6 Jun 2023 13:25:11 +1000 Subject: [PATCH 052/135] Update gallery when adding image via scan (#3802) --- pkg/image/scan.go | 49 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/pkg/image/scan.go b/pkg/image/scan.go index 55eafdd97a0..d28d94a86c0 100644 --- a/pkg/image/scan.go +++ b/pkg/image/scan.go @@ -36,6 +36,7 @@ type GalleryFinderCreator interface { FindByFileID(ctx context.Context, fileID file.ID) ([]*models.Gallery, error) FindByFolderID(ctx context.Context, folderID file.FolderID) ([]*models.Gallery, error) Create(ctx context.Context, newObject *models.Gallery, fileIDs []file.ID) error + UpdatePartial(ctx context.Context, id int, updatedGallery models.GalleryPartial) (*models.Gallery, error) } type ScanConfig interface { @@ -117,10 +118,16 @@ func (h *ScanHandler) Handle(ctx context.Context, f file.File, oldFile file.File logger.Infof("%s doesn't exist. Creating new image...", f.Base().Path) - if _, err := h.associateGallery(ctx, newImage, imageFile); err != nil { + g, err := h.getGalleryToAssociate(ctx, newImage, f) + if err != nil { return err } + if g != nil { + newImage.GalleryIDs.Add(g.ID) + logger.Infof("Adding %s to gallery %s", f.Base().Path, g.Path) + } + if err := h.CreatorUpdater.Create(ctx, &models.ImageCreateInput{ Image: newImage, FileIDs: []file.ID{imageFile.ID}, @@ -128,6 +135,15 @@ func (h *ScanHandler) Handle(ctx context.Context, f file.File, oldFile file.File return fmt.Errorf("creating new image: %w", err) } + // update the gallery updated at timestamp if applicable + if g != nil { + if _, err := h.GalleryFinder.UpdatePartial(ctx, g.ID, models.GalleryPartial{ + UpdatedAt: models.NewOptionalTime(time.Now()), + }); err != nil { + return fmt.Errorf("updating gallery updated at timestamp: %w", err) + } + } + h.PluginCache.RegisterPostHooks(ctx, newImage.ID, plugin.ImageCreatePost, nil, nil) existing = []*models.Image{newImage} @@ -172,16 +188,18 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models. } // associate with gallery if applicable - changed, err := h.associateGallery(ctx, i, f) + g, err := h.getGalleryToAssociate(ctx, i, f) if err != nil { return err } var galleryIDs *models.UpdateIDs - if changed { + changed := false + if g != nil { + changed = true galleryIDs = &models.UpdateIDs{ - IDs: i.GalleryIDs.List(), - Mode: models.RelationshipUpdateModeSet, + IDs: []int{g.ID}, + Mode: models.RelationshipUpdateModeAdd, } } @@ -203,6 +221,14 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models. }); err != nil { return fmt.Errorf("updating image: %w", err) } + + if g != nil { + if _, err := h.GalleryFinder.UpdatePartial(ctx, g.ID, models.GalleryPartial{ + UpdatedAt: models.NewOptionalTime(time.Now()), + }); err != nil { + return fmt.Errorf("updating gallery updated at timestamp: %w", err) + } + } } if changed || updateExisting { @@ -331,22 +357,19 @@ func (h *ScanHandler) getOrCreateGallery(ctx context.Context, f file.File) (*mod return nil, nil } -func (h *ScanHandler) associateGallery(ctx context.Context, newImage *models.Image, f file.File) (bool, error) { +func (h *ScanHandler) getGalleryToAssociate(ctx context.Context, newImage *models.Image, f file.File) (*models.Gallery, error) { g, err := h.getOrCreateGallery(ctx, f) if err != nil { - return false, err + return nil, err } if err := newImage.LoadGalleryIDs(ctx, h.CreatorUpdater); err != nil { - return false, err + return nil, err } - ret := false if g != nil && !intslice.IntInclude(newImage.GalleryIDs.List(), g.ID) { - ret = true - newImage.GalleryIDs.Add(g.ID) - logger.Infof("Adding %s to gallery %s", f.Base().Path, g.Path) + return g, nil } - return ret, nil + return nil, nil } From de4237e6266c88658c6ee1523ce16c84776a9274 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 6 Jun 2023 14:06:46 +1000 Subject: [PATCH 053/135] Update changelog --- ui/v2.5/src/docs/en/Changelog/v0210.md | 29 ++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/ui/v2.5/src/docs/en/Changelog/v0210.md b/ui/v2.5/src/docs/en/Changelog/v0210.md index a44dd5f0417..51f3ba27781 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0210.md +++ b/ui/v2.5/src/docs/en/Changelog/v0210.md @@ -1,7 +1,25 @@ ### ✨ New Features +* Added VR button to the scene player when the scene tag includes a configurable tag. ([#3636](https://github.com/stashapp/stash/pull/3636)) +* Added ability to include and exclude performers, studios and tags in the same filter. ([#3619](https://github.com/stashapp/stash/pull/3619)) +* Added penis length and circumcision status for Performers. ([#3627](https://github.com/stashapp/stash/pull/3627)) +* Added text field to search criteria in the edit filter dialog. ([#3740](https://github.com/stashapp/stash/pull/3740)) +* Added ability to add (short) video files as images. ([#3583](https://github.com/stashapp/stash/pull/3583)) +* Added ability to force gallery creation by adding `.forcegallery` to directory. ([#3715](https://github.com/stashapp/stash/pull/3715)) +* Added ability to ignore gallery creation by adding `.nogallery` to directory. ([#3715](https://github.com/stashapp/stash/pull/3715)) +* Added Maximum Duration Difference option to the Duplicate Scene Checker. ([#3663](https://github.com/stashapp/stash/pull/3663)) +* Added ability to configure the default sort order for videos served by DLNA. ([#3645](https://github.com/stashapp/stash/pull/3645)) +* Support pinning filter criteria to the top of the edit filter page. ([#3675](https://github.com/stashapp/stash/pull/3675)) +* Added Appears With tab to Performer page showing other performers that appear in the same scenes. ([#3563](https://github.com/stashapp/stash/pull/3563)) +* Added derived Performer O-Counter field. ([#3588](https://github.com/stashapp/stash/pull/3588)) * Added distance parameter to phash filter. ([#3596](https://github.com/stashapp/stash/pull/3596)) ### 🎨 Improvements +* Gallery Updated At timestamp is now updated when its contents are changed. ([#3771](https://github.com/stashapp/stash/pull/3771)) +* Added male performer images that are consistent with the other performer images. ([#3770](https://github.com/stashapp/stash/pull/3770)) +* Improved the UX when navigating the edit filter dialog using keyboard. ([#3739](https://github.com/stashapp/stash/pull/3739)) +* Changed modifier selector to a set of clickable pills. ([#3598](https://github.com/stashapp/stash/pull/3598)) +* Movie covers can now be shown in the Lightbox when clicking on them. ([#3705](https://github.com/stashapp/stash/pull/3705)) +* Scrapers are now sorted by name in the Scraper UI. ([#3691](https://github.com/stashapp/stash/pull/3691)) * Changed source selector menu to require click instead of mouseover. ([#3578](https://github.com/stashapp/stash/pull/3578)) * Updated default studio icon to be consistent with other icons. ([#3577](https://github.com/stashapp/stash/pull/3577)) * Make cards use up the full width of the screen on mobile. ([#3576](https://github.com/stashapp/stash/pull/3576)) @@ -10,4 +28,15 @@ * Default date sorting in descending order. ([#3560](https://github.com/stashapp/stash/pull/3560)) ### 🐛 Bug fixes +* Fixed captions not appearing on iOS devices. ([#3729](https://github.com/stashapp/stash/pull/3729)) +* Fixed folder selector appearing for name criterion. ([#3788](https://github.com/stashapp/stash/pull/3788)) +* Fixed generation of interactive heatmaps to match scene duration. ([#3758](https://github.com/stashapp/stash/pull/3758)) +* Fixed incorrect plugin hook being triggered during bulk performer update. ([#3754](https://github.com/stashapp/stash/pull/3754)) +* Fixed error when removing file over network on Windows. ([#3714](https://github.com/stashapp/stash/pull/3714)) +* Fixed scene cards being sized incorrectly on the front page. ([#3724](https://github.com/stashapp/stash/pull/3724)) +* Fixed hair colour not being populated during Batch Update Performers. ([#3718](https://github.com/stashapp/stash/pull/3718)) +* Fixed Create Missing checkbox not appearing in the Identify dialog. ([#3260](https://github.com/stashapp/stash/issues/3260)) +* Fixed override option not being honoured when generating scene covers. ([#3661](https://github.com/stashapp/stash/pull/3661)) +* Fixed error when creating a movie in the scrape scene dialog. ([#3633](https://github.com/stashapp/stash/pull/3633)) +* Fixed issues when scanning a renamed zip file. ([#3610](https://github.com/stashapp/stash/pull/3579)) * Fixed incorrect Twitter/Instagram URLs sent to stash-box. ([#3579](https://github.com/stashapp/stash/pull/3579)) From 09df203bcfb39780ca6fd702955bac4efd82397d Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Tue, 6 Jun 2023 06:10:14 +0200 Subject: [PATCH 054/135] Filter tweaks (#3772) * Use debounce hook * Wait until search request complete before refreshing results * Add back null modifiers * Convert old excludes criterion to includes criterion * Display criteria with only excludes items as excludes * Fix depth display * Reset search after selection * Add back is modifier to tag filter * Focus the input dialog after select/unselect * Update unsupported modifiers --- .../src/components/List/CriterionEditor.tsx | 12 +- .../List/Filters/PerformersFilter.tsx | 24 ++-- .../List/Filters/SelectableFilter.tsx | 133 +++++++++++------- .../components/List/Filters/StudiosFilter.tsx | 24 ++-- .../components/List/Filters/TagsFilter.tsx | 26 ++-- .../src/components/Shared/ClearableInput.tsx | 4 +- ui/v2.5/src/locales/en-GB.json | 2 + .../models/list-filter/criteria/country.ts | 2 +- .../models/list-filter/criteria/criterion.ts | 132 ++++++++++++----- .../models/list-filter/criteria/is-missing.ts | 2 +- .../src/models/list-filter/criteria/none.ts | 3 +- .../models/list-filter/criteria/performers.ts | 74 +++++++--- .../src/models/list-filter/criteria/phash.ts | 2 +- .../src/models/list-filter/criteria/rating.ts | 2 +- .../models/list-filter/criteria/stash-ids.ts | 2 +- .../models/list-filter/criteria/studios.ts | 6 +- .../src/models/list-filter/criteria/tags.ts | 97 ++++++------- ui/v2.5/src/models/list-filter/filter.ts | 5 +- 18 files changed, 340 insertions(+), 212 deletions(-) diff --git a/ui/v2.5/src/components/List/CriterionEditor.tsx b/ui/v2.5/src/components/List/CriterionEditor.tsx index fdf5bcad7f1..e35ba50c2fc 100644 --- a/ui/v2.5/src/components/List/CriterionEditor.tsx +++ b/ui/v2.5/src/components/List/CriterionEditor.tsx @@ -77,17 +77,15 @@ const GenericCriterionEditor: React.FC = ({ return ( - {modifierOptions.map((c) => ( + {modifierOptions.map((m) => ( ))} diff --git a/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx b/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx index 8d056af0d1c..483cb7400a3 100644 --- a/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useMemo } from "react"; import { PerformersCriterion } from "src/models/list-filter/criteria/performers"; import { useFindPerformersQuery } from "src/core/generated-graphql"; import { ObjectsFilter } from "./SelectableFilter"; @@ -9,7 +9,7 @@ interface IPerformersFilter { } function usePerformerQuery(query: string) { - const results = useFindPerformersQuery({ + const { data, loading } = useFindPerformersQuery({ variables: { filter: { q: query, @@ -18,14 +18,18 @@ function usePerformerQuery(query: string) { }, }); - return ( - results.data?.findPerformers.performers.map((p) => { - return { - id: p.id, - label: p.name, - }; - }) ?? [] + const results = useMemo( + () => + data?.findPerformers.performers.map((p) => { + return { + id: p.id, + label: p.name, + }; + }) ?? [], + [data] ); + + return { results, loading }; } const PerformersFilter: React.FC = ({ @@ -36,7 +40,7 @@ const PerformersFilter: React.FC = ({ ); }; diff --git a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx index 48caccb16d8..2c13eb57e81 100644 --- a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Button, Form } from "react-bootstrap"; import { Icon } from "src/components/Shared/Icon"; import { @@ -14,7 +14,7 @@ import { ILabeledId, ILabeledValueListValue, } from "src/models/list-filter/types"; -import { cloneDeep, debounce } from "lodash-es"; +import { cloneDeep } from "lodash-es"; import { Criterion, IHierarchicalLabeledIdCriterion, @@ -22,6 +22,8 @@ import { import { defineMessages, MessageDescriptor, useIntl } from "react-intl"; import { CriterionModifier } from "src/core/generated-graphql"; import { keyboardClickHandler } from "src/utils/keyboard"; +import { useDebouncedSetState } from "src/hooks/debounce"; +import useFocus from "src/utils/focus"; interface ISelectedItem { item: ILabeledId; @@ -77,40 +79,29 @@ const SelectedItem: React.FC = ({ interface ISelectableFilter { query: string; - setQuery: (query: string) => void; - single: boolean; - includeOnly: boolean; + onQueryChange: (query: string) => void; + modifier: CriterionModifier; + inputFocus: ReturnType; + canExclude: boolean; queryResults: ILabeledId[]; selected: ILabeledId[]; excluded: ILabeledId[]; - onSelect: (value: ILabeledId, include: boolean) => void; + onSelect: (value: ILabeledId, exclude: boolean) => void; onUnselect: (value: ILabeledId) => void; } const SelectableFilter: React.FC = ({ query, - setQuery, - single, + onQueryChange, + modifier, + inputFocus, + canExclude, queryResults, selected, excluded, - includeOnly, onSelect, onUnselect, }) => { - const [internalQuery, setInternalQuery] = useState(query); - - const onInputChange = useMemo(() => { - return debounce((input: string) => { - setQuery(input); - }, 250); - }, [setQuery]); - - function onInternalInputChange(input: string) { - setInternalQuery(input); - onInputChange(input); - } - const objects = useMemo(() => { return queryResults.filter( (p) => @@ -119,8 +110,10 @@ const SelectableFilter: React.FC = ({ ); }, [queryResults, selected, excluded]); - const includingOnly = includeOnly || (selected.length > 0 && single); - const excludingOnly = excluded.length > 0 && single; + const includingOnly = modifier == CriterionModifier.Equals; + const excludingOnly = + modifier == CriterionModifier.Excludes || + modifier == CriterionModifier.NotEquals; const includeIcon = ; const excludeIcon = ; @@ -128,13 +121,18 @@ const SelectableFilter: React.FC = ({ return (
onInternalInputChange(v)} + focus={inputFocus} + value={query} + setValue={(v) => onQueryChange(v)} />