diff --git a/pkg/sqlite/filter_internal_test.go b/pkg/sqlite/filter_internal_test.go index f416b661c..305580746 100644 --- a/pkg/sqlite/filter_internal_test.go +++ b/pkg/sqlite/filter_internal_test.go @@ -640,3 +640,134 @@ func TestStringCriterionHandlerNotNull(t *testing.T) { assert.Equal(fmt.Sprintf("(%[1]s IS NOT NULL AND TRIM(%[1]s) != '')", column), f.whereClauses[0].sql) assert.Len(f.whereClauses[0].args, 0) } + +func TestValidateSortFavoritesFirstPrefix(t *testing.T) { + opts := sortOptions{"name", "rating", "random", "scenes_count"} + assert := assert.New(t) + + // valid: known sorts with the favorites_first_ prefix pass + assert.NoError(opts.validateSort("favorites_first_name")) + assert.NoError(opts.validateSort("favorites_first_rating")) + assert.NoError(opts.validateSort("favorites_first_scenes_count")) + + // valid: random with prefix passes (random itself is valid) + assert.NoError(opts.validateSort("favorites_first_random")) + assert.NoError(opts.validateSort("favorites_first_random_42")) + + // invalid: prefixed random with invalid seed + assert.Error(opts.validateSort("favorites_first_random_not-a-number")) + + // invalid: unknown sort after prefix + assert.Error(opts.validateSort("favorites_first_unknown_sort")) + + // invalid: nothing after prefix + assert.Error(opts.validateSort("favorites_first_")) + + // plain sorts still work unchanged + assert.NoError(opts.validateSort("name")) + assert.NoError(opts.validateSort("random_42")) + assert.Error(opts.validateSort("unknown")) +} + +func TestGetPerformerSortFavoritesFirstPrefix(t *testing.T) { + qb := &PerformerStore{} + assert := assert.New(t) + + favNameSort := "favorites_first_name" + favRatingSort := "favorites_first_rating" + favRandomSort := "favorites_first_random_42" + plainSort := "name" + invalidSort := "favorites_first_not_a_real_sort" + + // favorites_first_name → ORDER BY performers.favorite DESC, performers.name … + sort, err := qb.getPerformerSort(&models.FindFilterType{Sort: &favNameSort}) + assert.NoError(err) + assert.Contains(sort, "performers.favorite DESC") + assert.Contains(sort, "performers.name") + + // favorites_first_rating → ORDER BY performers.favorite DESC, performers.rating … + sort, err = qb.getPerformerSort(&models.FindFilterType{Sort: &favRatingSort}) + assert.NoError(err) + assert.Contains(sort, "performers.favorite DESC") + assert.Contains(sort, "performers.rating") + + // favorites_first_random_ keeps favorites first and randomizes within groups + sort, err = qb.getPerformerSort(&models.FindFilterType{Sort: &favRandomSort}) + assert.NoError(err) + assert.Contains(sort, "performers.favorite DESC") + assert.Contains(sort, "mod((performers.id + 42)") + + // plain name sort must NOT inject favorite + sort, err = qb.getPerformerSort(&models.FindFilterType{Sort: &plainSort}) + assert.NoError(err) + assert.NotContains(sort, "favorite") + + // unknown sort after prefix must error + _, err = qb.getPerformerSort(&models.FindFilterType{Sort: &invalidSort}) + assert.Error(err) +} + +func TestGetStudioSortFavoritesFirstPrefix(t *testing.T) { + qb := &StudioStore{} + assert := assert.New(t) + + favNameSort := "favorites_first_name" + favRandomSort := "favorites_first_random_42" + plainSort := "name" + invalidSort := "favorites_first_not_a_real_sort" + + // favorites_first_name → ORDER BY studios.favorite DESC, studios.name … + sort, err := qb.getStudioSort(&models.FindFilterType{Sort: &favNameSort}) + assert.NoError(err) + assert.Contains(sort, "studios.favorite DESC") + assert.Contains(sort, "studios.name") + + // favorites_first_random_ keeps favorites first and randomizes within groups + sort, err = qb.getStudioSort(&models.FindFilterType{Sort: &favRandomSort}) + assert.NoError(err) + assert.Contains(sort, "studios.favorite DESC") + assert.Contains(sort, "mod((studios.id + 42)") + + // plain name sort must NOT inject favorite + sort, err = qb.getStudioSort(&models.FindFilterType{Sort: &plainSort}) + assert.NoError(err) + assert.NotContains(sort, "favorite") + + // unknown sort after prefix must error + _, err = qb.getStudioSort(&models.FindFilterType{Sort: &invalidSort}) + assert.Error(err) +} + +func TestGetTagSortFavoritesFirstPrefix(t *testing.T) { + qb := &TagStore{} + assert := assert.New(t) + + favNameSort := "favorites_first_name" + favRandomSort := "favorites_first_random_42" + plainSort := "name" + invalidSort := "favorites_first_not_a_real_sort" + + query := &queryBuilder{} + + // favorites_first_name → ORDER BY tags.favorite DESC, COALESCE(tags.sort_name, tags.name) … + sort, err := qb.getTagSort(query, &models.FindFilterType{Sort: &favNameSort}) + assert.NoError(err) + assert.Contains(sort, "tags.favorite DESC") + assert.Contains(sort, "tags.sort_name") // tag name uses COALESCE(sort_name, name) + + // favorites_first_random_ keeps favorites first and randomizes within groups + sort, err = qb.getTagSort(query, &models.FindFilterType{Sort: &favRandomSort}) + assert.NoError(err) + assert.Contains(sort, "tags.favorite DESC") + assert.Contains(sort, "mod((tags.id + 42)") + + // plain name sort must NOT inject favorite + sort, err = qb.getTagSort(query, &models.FindFilterType{Sort: &plainSort}) + assert.NoError(err) + assert.NotContains(sort, "favorite") + + // unknown sort after prefix must error + _, err = qb.getTagSort(query, &models.FindFilterType{Sort: &invalidSort}) + assert.Error(err) +} + diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index aacd9172f..2ab3235a6 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "slices" + "strings" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" @@ -842,6 +843,12 @@ func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) (s direction = findFilter.GetDirection() } + // check for favorites-first prefix and strip it before further processing + favoritesFirst := strings.HasPrefix(sort, FavoritesFirstPrefix) + if favoritesFirst { + sort = sort[len(FavoritesFirstPrefix):] + } + // CVE-2024-32231 - ensure sort is in the list of allowed sorts if err := performerSortOptions.validateSort(sort); err != nil { return "", err @@ -877,6 +884,11 @@ func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) (s // Whatever the sorting, always use name/id as a final sort sortQuery += ", COALESCE(performers.name, performers.id) COLLATE NATURAL_CI ASC" + + if favoritesFirst { + sortQuery = strings.Replace(sortQuery, " ORDER BY ", " ORDER BY performers.favorite DESC, ", 1) + } + return sortQuery, nil } diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index 87376c2c1..2273646b4 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -47,11 +47,17 @@ func getPaginationSQL(page int, perPage int) string { return " LIMIT " + strconv.Itoa(perPage) + " OFFSET " + strconv.Itoa(page) + " " } -const randomSeedPrefix = "random_" // prefix for random sort +const randomSeedPrefix = "random_" // prefix for random sort +const FavoritesFirstPrefix = "favorites_first_" // prefix for favorites-first sort type sortOptions []string func (o sortOptions) validateSort(sort string) error { + // strip favorites-first prefix before validating the underlying sort + if strings.HasPrefix(sort, FavoritesFirstPrefix) { + sort = sort[len(FavoritesFirstPrefix):] + } + if strings.HasPrefix(sort, randomSeedPrefix) { // seed as a parameter from the UI seedStr := sort[len(randomSeedPrefix):] @@ -62,6 +68,7 @@ func (o sortOptions) validateSort(sort string) error { return nil } + for _, v := range o { if v == sort { return nil diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index 87f905935..23ec57a1f 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "slices" + "strings" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" @@ -686,6 +687,12 @@ func (qb *StudioStore) getStudioSort(findFilter *models.FindFilterType) (string, direction = findFilter.GetDirection() } + // check for favorites-first prefix and strip it before further processing + favoritesFirst := strings.HasPrefix(sort, FavoritesFirstPrefix) + if favoritesFirst { + sort = sort[len(FavoritesFirstPrefix):] + } + // CVE-2024-32231 - ensure sort is in the list of allowed sorts if err := studioSortOptions.validateSort(sort); err != nil { return "", err @@ -715,6 +722,11 @@ func (qb *StudioStore) getStudioSort(findFilter *models.FindFilterType) (string, // Whatever the sorting, always use name/id as a final sort sortQuery += ", COALESCE(studios.name, studios.id) COLLATE NATURAL_CI ASC" + + if favoritesFirst { + sortQuery = strings.Replace(sortQuery, " ORDER BY ", " ORDER BY studios.favorite DESC, ", 1) + } + return sortQuery, nil } diff --git a/pkg/sqlite/studio_test.go b/pkg/sqlite/studio_test.go index eebc677c3..97893d79c 100644 --- a/pkg/sqlite/studio_test.go +++ b/pkg/sqlite/studio_test.go @@ -1955,3 +1955,4 @@ func TestStudioQueryCustomFields(t *testing.T) { // TODO All // TODO AllSlim // TODO Query + diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index f6a542c91..c6ffa911b 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -811,6 +811,12 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte direction = findFilter.GetDirection() } + // check for favorites-first prefix and strip it before further processing + favoritesFirst := strings.HasPrefix(sort, FavoritesFirstPrefix) + if favoritesFirst { + sort = sort[len(FavoritesFirstPrefix):] + } + // CVE-2024-32231 - ensure sort is in the list of allowed sorts if err := tagSortOptions.validateSort(sort); err != nil { return "", err @@ -844,6 +850,11 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte // Whatever the sorting, always use sort_name/name/id as a final sort sortQuery += ", COALESCE(tags.sort_name, tags.name, tags.id) COLLATE NATURAL_CI ASC" + + if favoritesFirst { + sortQuery = strings.Replace(sortQuery, " ORDER BY ", " ORDER BY tags.favorite DESC, ", 1) + } + return sortQuery, nil } diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index 179969fd6..b5e5b7638 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -1958,3 +1958,4 @@ func TestTagQueryCustomFields(t *testing.T) { // TODO All // TODO AllSlim // TODO Query + diff --git a/ui/v2.5/src/components/List/views.ts b/ui/v2.5/src/components/List/views.ts index 4ea4e46d8..b6205a516 100644 --- a/ui/v2.5/src/components/List/views.ts +++ b/ui/v2.5/src/components/List/views.ts @@ -13,6 +13,7 @@ export enum View { TagScenes = "tag_scenes", TagImages = "tag_images", TagPerformers = "tag_performers", + TagStudios = "tag_studios", TagGroups = "tag_groups", PerformerScenes = "performer_scenes", diff --git a/ui/v2.5/src/components/Performers/PerformerList.tsx b/ui/v2.5/src/components/Performers/PerformerList.tsx index c2db288bc..879ce634e 100644 --- a/ui/v2.5/src/components/Performers/PerformerList.tsx +++ b/ui/v2.5/src/components/Performers/PerformerList.tsx @@ -58,6 +58,9 @@ import { FavoritePerformerCriterionOption } from "src/models/list-filter/criteri import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter"; import { SidebarOptionFilter } from "../List/Filters/OptionFilter"; import { GenderCriterionOption } from "src/models/list-filter/criteria/gender"; +import { useConfigurationContext } from "src/hooks/Config"; +import { useFavoritesFirstFilterHook } from "src/hooks/useFavoritesFirstFilterHook"; +import { IUIConfig } from "src/core/config"; export const FormatHeight = (height?: number | null) => { const intl = useIntl(); @@ -377,6 +380,22 @@ export const FilteredPerformerList = PatchComponent( extraOperations = [], } = props; + // favorites-first: applies on the main Performers page and related performer tabs + const { configuration } = useConfigurationContext(); + const uiConfig = configuration?.ui as IUIConfig; + const favoritesFirstApplicable = + view === View.Performers || + view === View.TagPerformers || + view === View.StudioPerformers; + const showFavoritesFirst = + favoritesFirstApplicable && + (uiConfig?.showFavoritesFirstPerformers ?? false); + + const combinedFilterHook = useFavoritesFirstFilterHook( + showFavoritesFirst, + filterHook + ); + // States const { showSidebar, @@ -397,7 +416,7 @@ export const FilteredPerformerList = PatchComponent( useResult: useFindPerformers, getCount: (r) => r.data?.findPerformers.count ?? 0, getItems: (r) => r.data?.findPerformers.performers ?? [], - filterHook, + filterHook: combinedFilterHook, }, }); diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index 5b4e8c5de..153bf6231 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -702,6 +702,39 @@ export const SettingsInterfacePanel: React.FC = PatchComponent( /> + +
+
+
+
+ {intl.formatMessage({ + id: "config.ui.favorites_first.description", + })} +
+
+
+
+ saveUI({ showFavoritesFirstPerformers: v })} + /> + saveUI({ showFavoritesFirstStudios: v })} + /> + saveUI({ showFavoritesFirstTags: v })} + /> +
+ +
diff --git a/ui/v2.5/src/components/Studios/StudioList.tsx b/ui/v2.5/src/components/Studios/StudioList.tsx index b75e9782b..855402942 100644 --- a/ui/v2.5/src/components/Studios/StudioList.tsx +++ b/ui/v2.5/src/components/Studios/StudioList.tsx @@ -46,6 +46,9 @@ import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter"; import { FavoriteStudioCriterionOption } from "src/models/list-filter/criteria/favorite"; import { Button } from "react-bootstrap"; import cx from "classnames"; +import { useConfigurationContext } from "src/hooks/Config"; +import { useFavoritesFirstFilterHook } from "src/hooks/useFavoritesFirstFilterHook"; +import { IUIConfig } from "src/core/config"; const StudioList: React.FC<{ studios: GQL.StudioDataFragment[]; @@ -204,6 +207,20 @@ export const FilteredStudioList = PatchComponent( const { filterHook, view, alterQuery, extraOperations = [] } = props; + // favorites-first: applies on the main Studios page and the Tag → Studios tab + const { configuration } = useConfigurationContext(); + const uiConfig = configuration?.ui as IUIConfig; + const favoritesFirstApplicable = + view === View.Studios || view === View.TagStudios; + const showFavoritesFirst = + favoritesFirstApplicable && + (uiConfig?.showFavoritesFirstStudios ?? false); + + const combinedFilterHook = useFavoritesFirstFilterHook( + showFavoritesFirst, + filterHook + ); + // States const { showSidebar, @@ -224,7 +241,7 @@ export const FilteredStudioList = PatchComponent( useResult: useFindStudios, getCount: (r) => r.data?.findStudios.count ?? 0, getItems: (r) => r.data?.findStudios.studios ?? [], - filterHook, + filterHook: combinedFilterHook, }, }); diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx index 045d55481..e5d959fb6 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useTagFilterHook } from "src/core/tags"; import { FilteredStudioList } from "src/components/Studios/StudioList"; +import { View } from "src/components/List/views"; interface ITagStudiosPanel { active: boolean; @@ -15,5 +16,11 @@ export const TagStudiosPanel: React.FC = ({ showSubTagContent, }) => { const filterHook = useTagFilterHook(tag, showSubTagContent); - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Tags/TagList.tsx b/ui/v2.5/src/components/Tags/TagList.tsx index 8d6cbbeee..a76b4b465 100644 --- a/ui/v2.5/src/components/Tags/TagList.tsx +++ b/ui/v2.5/src/components/Tags/TagList.tsx @@ -47,6 +47,9 @@ import { LoadedContent } from "../List/PagedList"; import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter"; import { FavoriteTagCriterionOption } from "src/models/list-filter/criteria/favorite"; import { TagListTable } from "./TagListTable"; +import { useConfigurationContext } from "src/hooks/Config"; +import { useFavoritesFirstFilterHook } from "src/hooks/useFavoritesFirstFilterHook"; +import { IUIConfig } from "src/core/config"; const TagList: React.FC<{ tags: GQL.TagListDataFragment[]; @@ -206,6 +209,16 @@ export const FilteredTagList = PatchComponent( const view = View.Tags; + // favorites-first: applies on the main Tags page + const { configuration } = useConfigurationContext(); + const uiConfig = configuration?.ui as IUIConfig; + const showFavoritesFirst = uiConfig?.showFavoritesFirstTags ?? false; + + const combinedFilterHook = useFavoritesFirstFilterHook( + showFavoritesFirst, + filterHook + ); + // States const { showSidebar, @@ -226,7 +239,7 @@ export const FilteredTagList = PatchComponent( useResult: useFindTagsForList, getCount: (r) => r.data?.findTags.count ?? 0, getItems: (r) => r.data?.findTags.tags ?? [], - filterHook, + filterHook: combinedFilterHook, }, }); diff --git a/ui/v2.5/src/core/config.ts b/ui/v2.5/src/core/config.ts index e0cf008b5..5663e4c6c 100644 --- a/ui/v2.5/src/core/config.ts +++ b/ui/v2.5/src/core/config.ts @@ -70,6 +70,13 @@ export interface IUIConfig { // if true show all content details by default showAllDetails?: boolean; + // if true show favorites first on the performers list page and tag performers tab + showFavoritesFirstPerformers?: boolean; + // if true show favorites first on the studios list page and tag studios tab + showFavoritesFirstStudios?: boolean; + // if true show favorites first on the tags list page + showFavoritesFirstTags?: boolean; + // if true the chromecast option will enabled enableChromecast?: boolean; diff --git a/ui/v2.5/src/hooks/useFavoritesFirstFilterHook.ts b/ui/v2.5/src/hooks/useFavoritesFirstFilterHook.ts new file mode 100644 index 000000000..37b6b499d --- /dev/null +++ b/ui/v2.5/src/hooks/useFavoritesFirstFilterHook.ts @@ -0,0 +1,40 @@ +import { useCallback, useMemo } from "react"; +import { ListFilterModel } from "src/models/list-filter/filter"; + +type ListFilterHook = (filter: ListFilterModel) => ListFilterModel; + +export const useFavoritesFirstFilterHook = ( + showFavoritesFirst: boolean, + filterHook?: ListFilterHook +) => { + const favoritesFirstHook = useCallback((f: ListFilterModel) => { + const sortBy = f.sortBy ?? "name"; + + if (sortBy.startsWith("favorites_first_")) { + return f; + } + + if (sortBy === "random") { + if (f.randomSeed === -1) { + // Match ListFilterModel random seed generation for stable paging. + f.randomSeed = Math.floor(Math.random() * 10 ** 8); + } + f.sortBy = `favorites_first_random_${f.randomSeed.toString()}`; + return f; + } + + f.sortBy = `favorites_first_${sortBy}`; + return f; + }, []); + + return useMemo(() => { + if (!showFavoritesFirst) { + return filterHook; + } + if (!filterHook) { + return favoritesFirstHook; + } + + return (f: ListFilterModel) => favoritesFirstHook(filterHook(f)); + }, [showFavoritesFirst, filterHook, favoritesFirstHook]); +}; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 37b6b6d44..ea63635fa 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -718,6 +718,10 @@ "heading": "Show studio as text" } }, + "favorites_first": { + "description": "When enabled, favourites will appear at the top of the list while respecting the current sort order within each group.", + "heading": "Show Favourites First" + }, "editing": { "disable_dropdown_create": { "description": "Remove the ability to create new objects from the dropdown selectors.", diff --git a/ui/v2.5/src/locales/en-US.json b/ui/v2.5/src/locales/en-US.json index 7d730601c..d33f10768 100644 --- a/ui/v2.5/src/locales/en-US.json +++ b/ui/v2.5/src/locales/en-US.json @@ -15,6 +15,10 @@ "custom_locales": { "heading": "Custom localization", "option_label": "Custom localization enabled" + }, + "favorites_first": { + "description": "When enabled, favorites will appear at the top of the list while respecting the current sort order within each group.", + "heading": "Show Favorites First" } } },