feat(recipes): add sort by random option with seeded stable pagination

This commit is contained in:
Will Miao
2026-08-15 09:50:46 +08:00
parent ae185ee714
commit 93472e5d67
5 changed files with 348 additions and 10 deletions
+11 -2
View File
@@ -8,6 +8,7 @@ import asyncio
import json import json
import logging import logging
import os import os
import random
import time import time
from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union, cast from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union, cast
from ..config import config from ..config import config
@@ -2781,7 +2782,9 @@ class RecipeScanner:
Args: Args:
page: Current page number (1-based) page: Current page number (1-based)
page_size: Number of items per page page_size: Number of items per page
sort_by: Sort method ('name' or 'date') sort_by: Sort method ('name', 'date', 'loras_count', or 'random'
with an optional seed like 'random:abc123'; the part after
'random:' is the shuffle seed, not a direction)
search: Search term search: Search term
filters: Dictionary of filters to apply filters: Dictionary of filters to apply
search_options: Dictionary of search options to apply search_options: Dictionary of search options to apply
@@ -2962,7 +2965,7 @@ class RecipeScanner:
] ]
# Apply sorting if not already handled by pre-sorted cache # Apply sorting if not already handled by pre-sorted cache
if ":" in sort_by or sort_field == "loras_count": if ":" in sort_by or sort_field in ("loras_count", "random"):
field, order = (sort_by.split(":") + ["desc"])[:2] field, order = (sort_by.split(":") + ["desc"])[:2]
reverse = order.lower() == "desc" reverse = order.lower() == "desc"
@@ -2985,6 +2988,12 @@ class RecipeScanner:
filtered_data.sort( filtered_data.sort(
key=lambda x: len(x.get("loras", [])), reverse=reverse key=lambda x: len(x.get("loras", [])), reverse=reverse
) )
elif field == "random":
# Seeded random shuffle: same seed -> same order (stable
# pagination across requests), matching the model pages.
seed = order if order.lower() not in ("asc", "desc") else None
rng = random.Random(seed or "random")
rng.shuffle(filtered_data)
# Calculate pagination # Calculate pagination
total_items = len(filtered_data) total_items = len(filtered_data)
+51 -3
View File
@@ -245,10 +245,20 @@ class RecipeManager {
this.pageState.sortBy = savedSort; this.pageState.sortBy = savedSort;
} }
initSortDropdown(sortSelect); initSortDropdown(sortSelect);
sortSelect.value = this.pageState.sortBy || 'date:desc'; this.applySortToSelect(this.pageState.sortBy || 'date:desc');
sortSelect.addEventListener('change', () => { sortSelect.addEventListener('change', () => {
this.pageState.sortBy = sortSelect.value; let value = sortSelect.value;
setStorageItem('recipes_sort', sortSelect.value); if (value.startsWith('random')) {
// Every pick of Random reshuffles the list: generate a
// fresh seed so the backend keeps a stable order across
// paginated requests.
value = this._randomizeSortValue();
}
this.pageState.sortBy = value;
setStorageItem('recipes_sort', value);
// Reset the seeded Random option when switching away from
// Random, or re-apply the fresh seed when picking it again.
this.applySortToSelect(value);
refreshVirtualScroll(); refreshVirtualScroll();
}); });
} }
@@ -341,6 +351,44 @@ class RecipeManager {
}); });
} }
/**
* Apply a sort value to the native sort <select>, keeping the Random
* option's value in sync when the persisted value carries a seed
* (e.g. "random:abc123"). Must be used instead of assigning
* sortSelect.value directly whenever the value may be a seeded random
* sort, otherwise the native select has no matching option.
* @param {string} sortValue - Sort value like "date:desc" or "random:<seed>"
*/
applySortToSelect(sortValue) {
const sortSelect = document.getElementById('sortSelect');
if (!sortSelect) return;
const randomOpt = sortSelect.querySelector('option[value="random"], option[value^="random:"]');
if (randomOpt) {
randomOpt.value = String(sortValue).startsWith('random') ? sortValue : 'random';
}
sortSelect.value = sortValue;
}
/**
* Generate a fresh seeded random sort value ("random:<seed>") and keep
* the native <select> in sync so its value matches the persisted sort
* string and the dropdown shows the selected label.
* @returns {string} The new sort value, e.g. "random:abc123xyz"
*/
_randomizeSortValue() {
const seed = Math.random().toString(36).slice(2, 12);
const value = `random:${seed}`;
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
const randomOpt = sortSelect.querySelector('option[value="random"], option[value^="random:"]');
if (randomOpt) {
randomOpt.value = value;
}
sortSelect.value = value;
}
return value;
}
normalizeLoadRecipesOptions(options = true) { normalizeLoadRecipesOptions(options = true) {
if (typeof options === 'boolean') { if (typeof options === 'boolean') {
return { return {
+3 -5
View File
@@ -48,17 +48,15 @@
<option value="versions_count:asc">{{ t('loras.controls.sort.versionsCountAsc', default='Fewest versions first') }}</option> <option value="versions_count:asc">{{ t('loras.controls.sort.versionsCountAsc', default='Fewest versions first') }}</option>
</optgroup> </optgroup>
{% endif %} {% endif %}
{% if page_id != 'recipes' %}
<optgroup label="{{ t('loras.controls.sort.random', default='Random') }}">
<option value="random">{{ t('loras.controls.sort.randomAction', default='Randomize (shuffle)') }}</option>
</optgroup>
{% endif %}
{% if page_id == 'recipes' %} {% if page_id == 'recipes' %}
<optgroup label="{{ t('recipes.controls.sort.lorasCount') }}"> <optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
<option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option> <option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>
<option value="loras_count:asc">{{ t('recipes.controls.sort.lorasCountAsc') }}</option> <option value="loras_count:asc">{{ t('recipes.controls.sort.lorasCountAsc') }}</option>
</optgroup> </optgroup>
{% endif %} {% endif %}
<optgroup label="{{ t('loras.controls.sort.random', default='Random') }}">
<option value="random">{{ t('loras.controls.sort.randomAction', default='Randomize (shuffle)') }}</option>
</optgroup>
</select> </select>
</div> </div>
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.title') }}{% else %}{{ t('loras.controls.refresh.title') }}{% endif %}" class="control-group dropdown-group"> <div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.title') }}{% else %}{{ t('loras.controls.refresh.title') }}{% endif %}" class="control-group dropdown-group">
@@ -0,0 +1,235 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderRecipesPage } from '../utils/pageFixtures.js';
const initializeAppMock = vi.fn();
const initializePageFeaturesMock = vi.fn();
const getCurrentPageStateMock = vi.fn();
const getSessionItemMock = vi.fn();
const removeSessionItemMock = vi.fn();
const getStorageItemMock = vi.fn();
const setStorageItemMock = vi.fn();
const removeStorageItemMock = vi.fn();
const refreshVirtualScrollMock = vi.fn();
const refreshRecipesMock = vi.fn();
let importManagerInstance;
let recipeModalInstance;
let duplicatesManagerInstance;
const ImportManagerMock = vi.fn(() => importManagerInstance);
const RecipeModalMock = vi.fn(() => recipeModalInstance);
const DuplicatesManagerMock = vi.fn(() => duplicatesManagerInstance);
vi.mock('../../../static/js/core.js', () => ({
appCore: {
initialize: initializeAppMock,
initializePageFeatures: initializePageFeaturesMock,
},
}));
vi.mock('../../../static/js/managers/ImportManager.js', () => ({
ImportManager: ImportManagerMock,
}));
vi.mock('../../../static/js/components/RecipeModal.js', () => ({
RecipeModal: RecipeModalMock,
}));
vi.mock('../../../static/js/state/index.js', () => ({
getCurrentPageState: getCurrentPageStateMock,
state: {
currentPageType: 'recipes',
global: { settings: {} },
virtualScroller: {
removeItemByFilePath: vi.fn(),
updateSingleItem: vi.fn(),
refreshWithData: vi.fn(),
},
},
}));
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
getSessionItem: getSessionItemMock,
removeSessionItem: removeSessionItemMock,
getStorageItem: getStorageItemMock,
setStorageItem: setStorageItemMock,
removeStorageItem: removeStorageItemMock,
}));
vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({
RecipeContextMenu: vi.fn(),
}));
vi.mock('../../../static/js/components/DuplicatesManager.js', () => ({
DuplicatesManager: DuplicatesManagerMock,
}));
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
refreshVirtualScroll: refreshVirtualScrollMock,
recreateVirtualScroll: vi.fn(),
}));
vi.mock('../../../static/js/api/recipeApi.js', () => ({
refreshRecipes: refreshRecipesMock,
RecipeSidebarApiClient: vi.fn(() => ({
apiConfig: { config: { displayName: 'Recipes', supportsMove: true } },
fetchUnifiedFolderTree: vi.fn().mockResolvedValue({ success: true, tree: {} }),
fetchModelFolders: vi.fn().mockResolvedValue({ success: true, folders: [] }),
fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/recipes'] }),
moveBulkModels: vi.fn(),
moveSingleModel: vi.fn(),
})),
}));
vi.mock('../../../static/js/components/SidebarManager.js', () => ({
sidebarManager: {
setHostPageControls: vi.fn(),
initialize: vi.fn(async () => {}),
refresh: vi.fn(async () => {}),
cleanup: vi.fn(),
},
}));
function renderSortSelect() {
const sortSelectElement = document.createElement('select');
sortSelectElement.id = 'sortSelect';
sortSelectElement.innerHTML = `
<option value="date:desc">Newest</option>
<option value="name:asc">Name A-Z</option>
<option value="random">Randomize (shuffle)</option>
`;
document.body.appendChild(sortSelectElement);
return sortSelectElement;
}
describe('RecipeManager Random sort', () => {
let RecipeManager;
let pageState;
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
importManagerInstance = { showImportModal: vi.fn() };
recipeModalInstance = { showRecipeDetails: vi.fn() };
duplicatesManagerInstance = {
findDuplicates: vi.fn(),
selectLatestDuplicates: vi.fn(),
deleteSelectedDuplicates: vi.fn(),
confirmDeleteDuplicates: vi.fn(),
exitDuplicateMode: vi.fn(),
};
pageState = {
sortBy: 'date:desc',
searchOptions: undefined,
customFilter: undefined,
duplicatesMode: false,
};
getCurrentPageStateMock.mockImplementation(() => pageState);
initializeAppMock.mockResolvedValue(undefined);
initializePageFeaturesMock.mockResolvedValue(undefined);
refreshVirtualScrollMock.mockImplementation(() => {});
refreshRecipesMock.mockResolvedValue('refreshed');
getSessionItemMock.mockImplementation(() => null);
removeSessionItemMock.mockImplementation(() => {});
getStorageItemMock.mockImplementation(() => null);
setStorageItemMock.mockImplementation(() => {});
renderRecipesPage();
});
afterEach(() => {
document.body.innerHTML = '';
delete window.recipeManager;
delete window.importManager;
});
async function createManager() {
({ RecipeManager } = await import('../../../static/js/recipes.js'));
const manager = new RecipeManager();
await manager.initialize();
return manager;
}
it('generates a seeded sort value when Random is picked', async () => {
const sortSelect = renderSortSelect();
const randomOpt = sortSelect.querySelector('option[value="random"]');
await createManager();
sortSelect.value = 'random';
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
expect(pageState.sortBy).toMatch(/^random:[a-z0-9]+$/);
expect(setStorageItemMock).toHaveBeenCalledWith('recipes_sort', pageState.sortBy);
expect(randomOpt.value).toBe(pageState.sortBy);
expect(sortSelect.value).toBe(pageState.sortBy);
expect(refreshVirtualScrollMock).toHaveBeenCalled();
});
it('reshuffles with a fresh seed every time Random is picked again', async () => {
const sortSelect = renderSortSelect();
const randomOpt = sortSelect.querySelector('option[value="random"]');
await createManager();
sortSelect.value = 'random';
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
const firstSeed = pageState.sortBy;
sortSelect.value = randomOpt.value;
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
expect(pageState.sortBy).toMatch(/^random:[a-z0-9]+$/);
expect(pageState.sortBy).not.toBe(firstSeed);
});
it('restores a persisted seeded random sort on load', async () => {
const sortSelect = renderSortSelect();
const savedSort = 'random:persistedseed';
getStorageItemMock.mockImplementation((key) =>
key === 'recipes_sort' ? savedSort : null
);
await createManager();
expect(pageState.sortBy).toBe(savedSort);
expect(sortSelect.value).toBe(savedSort);
expect(sortSelect.querySelector('option[value="random:persistedseed"]')).not.toBeNull();
});
it('applies a non-random sort back to the plain random option', async () => {
const sortSelect = renderSortSelect();
const randomOpt = sortSelect.querySelector('option[value="random"]');
const manager = await createManager();
sortSelect.value = 'random';
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
manager.applySortToSelect('name:asc');
expect(sortSelect.value).toBe('name:asc');
expect(randomOpt.value).toBe('random');
});
it('resets the seeded option when switching away from Random via the change handler', async () => {
const sortSelect = renderSortSelect();
const randomOpt = sortSelect.querySelector('option[value="random"]');
await createManager();
sortSelect.value = 'random';
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
expect(randomOpt.value).toMatch(/^random:[a-z0-9]+$/);
sortSelect.value = 'name:asc';
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
expect(pageState.sortBy).toBe('name:asc');
expect(sortSelect.value).toBe('name:asc');
expect(randomOpt.value).toBe('random');
});
});
+48
View File
@@ -1047,6 +1047,54 @@ async def test_get_paginated_data_sorting(recipe_scanner):
assert [i["id"] for i in res["items"]] == ["C", "A", "B"] assert [i["id"] for i in res["items"]] == ["C", "A", "B"]
@pytest.mark.asyncio
async def test_get_paginated_data_random_sort(recipe_scanner):
scanner, _ = recipe_scanner
# Add test recipes
for rid, title in [("A", "Alpha"), ("B", "Beta"), ("C", "Gamma")]:
await scanner.add_recipe(
{
"id": rid,
"title": title,
"created_date": 10.0,
"loras": [{}],
"file_path": f"{rid.lower()}.png",
}
)
await asyncio.sleep(0)
await _wait_for_resort(scanner)
# Same seed -> same order (deterministic, stable pagination)
res1 = await scanner.get_paginated_data(
page=1, page_size=10, sort_by="random:seed123"
)
res2 = await scanner.get_paginated_data(
page=1, page_size=10, sort_by="random:seed123"
)
ids1 = [i["id"] for i in res1["items"]]
ids2 = [i["id"] for i in res2["items"]]
assert ids1 == ids2
assert sorted(ids1) == ["A", "B", "C"]
# Plain "random" (no seed) also returns the full set
res3 = await scanner.get_paginated_data(page=1, page_size=10, sort_by="random")
assert sorted(i["id"] for i in res3["items"]) == ["A", "B", "C"]
# Stable pagination: page1 + page2 with the same seed concatenate to the
# full seeded order, with no duplicates across pages
p1 = await scanner.get_paginated_data(
page=1, page_size=2, sort_by="random:seed123"
)
p2 = await scanner.get_paginated_data(
page=2, page_size=2, sort_by="random:seed123"
)
combined = [i["id"] for i in p1["items"]] + [i["id"] for i in p2["items"]]
assert combined == ids1
assert len(set(combined)) == 3
async def test_build_image_id_map_filters_correctly(recipe_scanner): async def test_build_image_id_map_filters_correctly(recipe_scanner):
"""Only recipes with valid CivitAI source_path appear in image_id_map. """Only recipes with valid CivitAI source_path appear in image_id_map.