diff --git a/locales/de.json b/locales/de.json
index 03392548..a4963a3b 100644
--- a/locales/de.json
+++ b/locales/de.json
@@ -260,7 +260,11 @@
"any": "Beliebig",
"all": "Alle",
"tagLogicAny": "Jedes Tag abgleichen (ODER)",
- "tagLogicAll": "Alle Tags abgleichen (UND)"
+ "tagLogicAll": "Alle Tags abgleichen (UND)",
+ "loraAvailability": "LoRA-Verfügbarkeit",
+ "availabilityReady": "Einsatzbereit",
+ "availabilityMissing": "Mit fehlenden LoRAs",
+ "availabilityDeleted": "Mit gelöschten LoRAs"
},
"theme": {
"toggle": "Theme wechseln",
diff --git a/locales/en.json b/locales/en.json
index 98842270..2e2eff37 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -260,7 +260,11 @@
"any": "Any",
"all": "All",
"tagLogicAny": "Match any tag (OR)",
- "tagLogicAll": "Match all tags (AND)"
+ "tagLogicAll": "Match all tags (AND)",
+ "loraAvailability": "Lora Availability",
+ "availabilityReady": "Ready to use",
+ "availabilityMissing": "Has missing",
+ "availabilityDeleted": "Has deleted"
},
"theme": {
"toggle": "Toggle theme",
diff --git a/locales/es.json b/locales/es.json
index d5bc973c..8c7ca0f7 100644
--- a/locales/es.json
+++ b/locales/es.json
@@ -260,7 +260,11 @@
"any": "Cualquiera",
"all": "Todos",
"tagLogicAny": "Coincidir con cualquier etiqueta (O)",
- "tagLogicAll": "Coincidir con todas las etiquetas (Y)"
+ "tagLogicAll": "Coincidir con todas las etiquetas (Y)",
+ "loraAvailability": "Disponibilidad de LoRAs",
+ "availabilityReady": "Listos para usar",
+ "availabilityMissing": "Con LoRAs faltantes",
+ "availabilityDeleted": "Con LoRAs eliminados"
},
"theme": {
"toggle": "Cambiar tema",
diff --git a/locales/fr.json b/locales/fr.json
index daa10e16..a88a7570 100644
--- a/locales/fr.json
+++ b/locales/fr.json
@@ -260,7 +260,11 @@
"any": "N'importe quel",
"all": "Tous",
"tagLogicAny": "Correspondre à n'importe quel tag (OU)",
- "tagLogicAll": "Correspondre à tous les tags (ET)"
+ "tagLogicAll": "Correspondre à tous les tags (ET)",
+ "loraAvailability": "Disponibilité des LoRAs",
+ "availabilityReady": "Prêts à l'emploi",
+ "availabilityMissing": "Avec LoRAs manquants",
+ "availabilityDeleted": "Avec LoRAs supprimés"
},
"theme": {
"toggle": "Basculer le thème",
diff --git a/locales/he.json b/locales/he.json
index fb938242..df596458 100644
--- a/locales/he.json
+++ b/locales/he.json
@@ -260,7 +260,11 @@
"any": "כלשהו",
"all": "כל התגים",
"tagLogicAny": "התאם כל תג (או)",
- "tagLogicAll": "התאם את כל התגים (וגם)"
+ "tagLogicAll": "התאם את כל התגים (וגם)",
+ "loraAvailability": "זמינות LoRA",
+ "availabilityReady": "מוכנים לשימוש",
+ "availabilityMissing": "עם LoRAs חסרים",
+ "availabilityDeleted": "עם LoRAs שנמחקו"
},
"theme": {
"toggle": "החלף ערכת נושא",
diff --git a/locales/ja.json b/locales/ja.json
index 2351c5af..7f5bf71c 100644
--- a/locales/ja.json
+++ b/locales/ja.json
@@ -260,7 +260,11 @@
"any": "いずれか",
"all": "すべて",
"tagLogicAny": "いずれかのタグに一致 (OR)",
- "tagLogicAll": "すべてのタグに一致 (AND)"
+ "tagLogicAll": "すべてのタグに一致 (AND)",
+ "loraAvailability": "LoRA の利用状況",
+ "availabilityReady": "使用可能",
+ "availabilityMissing": "不足 LoRA あり",
+ "availabilityDeleted": "削除済み LoRA あり"
},
"theme": {
"toggle": "テーマの切り替え",
diff --git a/locales/ko.json b/locales/ko.json
index afbb25f8..343c52ee 100644
--- a/locales/ko.json
+++ b/locales/ko.json
@@ -260,7 +260,11 @@
"any": "아무",
"all": "모두",
"tagLogicAny": "모든 태그 일치 (OR)",
- "tagLogicAll": "모든 태그 일치 (AND)"
+ "tagLogicAll": "모든 태그 일치 (AND)",
+ "loraAvailability": "LoRA 가용성",
+ "availabilityReady": "바로 사용 가능",
+ "availabilityMissing": "누락된 LoRA 있음",
+ "availabilityDeleted": "삭제된 LoRA 있음"
},
"theme": {
"toggle": "테마 토글",
diff --git a/locales/ru.json b/locales/ru.json
index fa48c048..0964f34d 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -260,7 +260,11 @@
"any": "Любой",
"all": "Все",
"tagLogicAny": "Совпадение с любым тегом (ИЛИ)",
- "tagLogicAll": "Совпадение со всеми тегами (И)"
+ "tagLogicAll": "Совпадение со всеми тегами (И)",
+ "loraAvailability": "Доступность LoRAs",
+ "availabilityReady": "Готовы к использованию",
+ "availabilityMissing": "Есть отсутствующие",
+ "availabilityDeleted": "Есть удалённые"
},
"theme": {
"toggle": "Переключить тему",
diff --git a/locales/zh-CN.json b/locales/zh-CN.json
index 56b44066..7be918bf 100644
--- a/locales/zh-CN.json
+++ b/locales/zh-CN.json
@@ -260,7 +260,11 @@
"any": "任一",
"all": "全部",
"tagLogicAny": "匹配任一标签 (或)",
- "tagLogicAll": "匹配所有标签 (与)"
+ "tagLogicAll": "匹配所有标签 (与)",
+ "loraAvailability": "LoRA 可用性",
+ "availabilityReady": "可直接使用",
+ "availabilityMissing": "包含缺失 LoRA",
+ "availabilityDeleted": "包含已删除 LoRA"
},
"theme": {
"toggle": "切换主题",
diff --git a/locales/zh-TW.json b/locales/zh-TW.json
index f57c482a..e9e4a779 100644
--- a/locales/zh-TW.json
+++ b/locales/zh-TW.json
@@ -260,7 +260,11 @@
"any": "任一",
"all": "全部",
"tagLogicAny": "符合任一票籤 (或)",
- "tagLogicAll": "符合所有標籤 (與)"
+ "tagLogicAll": "符合所有標籤 (與)",
+ "loraAvailability": "LoRA 可用性",
+ "availabilityReady": "可直接使用",
+ "availabilityMissing": "包含缺少的 LoRA",
+ "availabilityDeleted": "包含已刪除的 LoRA"
},
"theme": {
"toggle": "切換主題",
diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py
index af04a0e1..8f8ba243 100644
--- a/py/routes/handlers/recipe_handlers.py
+++ b/py/routes/handlers/recipe_handlers.py
@@ -274,6 +274,14 @@ class RecipeListingHandler:
if tag_filters:
filters["tags"] = tag_filters
+ lora_availability = {
+ status.strip()
+ for status in request.query.get("lora_availability", "").split(",")
+ if status.strip() in ("ready", "missing", "deleted")
+ }
+ if lora_availability:
+ filters["lora_availability"] = lora_availability
+
lora_hash = request.query.get("lora_hash")
checkpoint_hash = request.query.get("checkpoint_hash")
diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py
index 8ed2b9ca..57cbf576 100644
--- a/py/services/recipe_scanner.py
+++ b/py/services/recipe_scanner.py
@@ -39,6 +39,9 @@ logger = logging.getLogger(__name__)
# explicitly to "diffusion_model" (mirrors Oracle R2-F1).
_CHECKPOINT_MODEL_TYPE_ALIASES = {"diffusionmodel": "diffusion_model"}
+# Valid LoRA availability statuses for the recipe listing filter.
+_VALID_LORA_AVAILABILITY_STATUSES = frozenset({"ready", "missing", "deleted"})
+
class RecipeScanner:
"""Service for scanning and managing recipe images"""
@@ -2994,6 +2997,43 @@ class RecipeScanner:
return lora
+ def _compute_availability_statuses(self, recipe: Dict[str, Any]) -> Set[str]:
+ """Compute the LoRA availability status set for a recipe.
+
+ Returns ``{"ready"}`` when every non-excluded LoRA resolves to the
+ local library (recipes without LoRAs count as ready); otherwise a
+ subset of ``{"missing", "deleted"}``. Uses the same inLibrary
+ resolution as ``_enrich_lora_entry`` (hash index with modelVersionId
+ fallback) but performs only in-memory lookups.
+ """
+
+ statuses: Set[str] = set()
+ for lora in recipe.get("loras") or []:
+ if not isinstance(lora, dict) or lora.get("exclude"):
+ continue
+
+ in_library = False
+ if self._lora_scanner:
+ hash_value = (lora.get("hash") or "").lower()
+ if hash_value:
+ in_library = self._lora_scanner.has_hash(hash_value)
+ elif lora.get("modelVersionId") is not None:
+ in_library = (
+ self._get_lora_from_version_index(lora.get("modelVersionId"))
+ is not None
+ )
+
+ if in_library:
+ continue
+ if lora.get("isDeleted"):
+ statuses.add("deleted")
+ else:
+ statuses.add("missing")
+
+ if not statuses:
+ statuses.add("ready")
+ return statuses
+
def _normalize_preview_url(self, preview_url: Optional[str]) -> Optional[str]:
"""Return a preview URL that is reachable from the browser."""
@@ -3261,6 +3301,22 @@ class RecipeScanner:
if not matches_exclude(item.get("tags"))
]
+ # Filter by LoRA availability status
+ availability = filters.get("lora_availability")
+ if availability:
+ selected = {
+ status
+ for status in availability
+ if status in _VALID_LORA_AVAILABILITY_STATUSES
+ }
+ # Selecting every status (or none) means no filtering.
+ if 0 < len(selected) < len(_VALID_LORA_AVAILABILITY_STATUSES):
+ filtered_data = [
+ item
+ for item in filtered_data
+ if self._compute_availability_statuses(item) & selected
+ ]
+
# Apply sorting if not already handled by pre-sorted cache
if ":" in sort_by or sort_field in ("loras_count", "random", "opened"):
field, order = (sort_by.split(":") + ["desc"])[:2]
diff --git a/static/js/api/recipeApi.js b/static/js/api/recipeApi.js
index 626368e8..ee15b7a7 100644
--- a/static/js/api/recipeApi.js
+++ b/static/js/api/recipeApi.js
@@ -174,6 +174,11 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
}
});
}
+
+ // Add LoRA availability filter (no statuses selected = no filtering)
+ if (pageState.filters?.loraAvailability && pageState.filters.loraAvailability.length > 0) {
+ params.append('lora_availability', pageState.filters.loraAvailability.join(','));
+ }
}
// Fetch recipes
diff --git a/static/js/managers/FilterManager.js b/static/js/managers/FilterManager.js
index 5d3fbd61..5cc064e2 100644
--- a/static/js/managers/FilterManager.js
+++ b/static/js/managers/FilterManager.js
@@ -7,6 +7,10 @@ import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js';
import { translate } from '../utils/i18nHelpers.js';
import { FilterPresetManager, EMPTY_WILDCARD_MARKER } from './FilterPresetManager.js';
+// LoRA availability statuses available on the recipes page. No statuses
+// selected (the default) means no filtering.
+const LORA_AVAILABILITY_STATUSES = ['ready', 'missing', 'deleted'];
+
export class FilterManager {
constructor(options = {}) {
this.options = {
@@ -74,6 +78,11 @@ export class FilterManager {
this.initializeLicenseFilters();
}
+ // Add click handlers for LoRA availability tags (recipes page only)
+ if (this.shouldShowLoraAvailabilityFilter()) {
+ this.initializeLoraAvailabilityFilters();
+ }
+
// Initialize tag logic toggle
this.initializeTagLogicToggle();
@@ -421,6 +430,42 @@ export class FilterManager {
});
}
+ initializeLoraAvailabilityFilters() {
+ const availabilityTags = document.querySelectorAll('.lora-availability-tag');
+ availabilityTags.forEach(tag => {
+ tag.addEventListener('click', async () => {
+ const status = tag.dataset.availability;
+ const selected = this.filters.loraAvailability || [];
+
+ if (selected.includes(status)) {
+ this.filters.loraAvailability = selected.filter(value => value !== status);
+ tag.classList.remove('active');
+ } else {
+ this.filters.loraAvailability = [...selected, status];
+ tag.classList.add('active');
+ }
+
+ this.updateActiveFiltersCount();
+ await this.applyFilters(false);
+ });
+ });
+
+ // Update selections based on stored filters
+ this.updateLoraAvailabilitySelections();
+ }
+
+ updateLoraAvailabilitySelections() {
+ const availabilityTags = document.querySelectorAll('.lora-availability-tag');
+ const selected = this.filters.loraAvailability || [];
+ availabilityTags.forEach(tag => {
+ if (selected.includes(tag.dataset.availability)) {
+ tag.classList.add('active');
+ } else {
+ tag.classList.remove('active');
+ }
+ });
+ }
+
createBaseModelTags() {
const baseModelTagsContainer = document.getElementById('baseModelTags');
if (!baseModelTagsContainer) return;
@@ -681,6 +726,11 @@ export class FilterManager {
}
this.updateModelTypeSelections();
+ // Update LoRA availability tags if visible on this page
+ if (this.shouldShowLoraAvailabilityFilter()) {
+ this.updateLoraAvailabilitySelections();
+ }
+
const autoTagEls = document.querySelectorAll('.auto-tag-filter');
autoTagEls.forEach(el => {
const tag = el.dataset.autoTag;
@@ -708,7 +758,9 @@ export class FilterManager {
const modelTypeFilterCount = this.filters.modelTypes.length;
// Exclude EMPTY_WILDCARD_MARKER from base model count
const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length;
- const totalActiveFilters = baseModelCount + tagFilterCount + autoTagFilterCount + licenseFilterCount + modelTypeFilterCount;
+ // Active when at least one availability status is deselected
+ const loraAvailabilityCount = this.filters.loraAvailability?.length ?? 0;
+ const totalActiveFilters = baseModelCount + tagFilterCount + autoTagFilterCount + licenseFilterCount + modelTypeFilterCount + loraAvailabilityCount;
if (this.activeFiltersCount) {
if (totalActiveFilters > 0) {
@@ -805,6 +857,7 @@ export class FilterManager {
autoTags: {},
license: {},
modelTypes: [],
+ loraAvailability: [],
tagLogic: 'any'
});
@@ -891,12 +944,14 @@ export class FilterManager {
const modelTypeCount = this.filters.modelTypes.length;
// Exclude EMPTY_WILDCARD_MARKER from base model count
const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length;
+ const loraAvailabilityCount = this.filters.loraAvailability?.length ?? 0;
return (
baseModelCount > 0 ||
tagCount > 0 ||
autoTagCount > 0 ||
licenseCount > 0 ||
- modelTypeCount > 0
+ modelTypeCount > 0 ||
+ loraAvailabilityCount > 0
);
}
@@ -909,6 +964,7 @@ export class FilterManager {
autoTags: this.normalizeTagFilters(source.autoTags),
license: this.shouldShowLicenseFilters() ? this.normalizeLicenseFilters(source.license) : {},
modelTypes: this.normalizeModelTypeFilters(source.modelTypes),
+ loraAvailability: this.normalizeLoraAvailabilityFilters(source.loraAvailability),
tagLogic: source.tagLogic || 'any'
};
}
@@ -917,6 +973,33 @@ export class FilterManager {
return this.currentPage !== 'recipes';
}
+ shouldShowLoraAvailabilityFilter() {
+ return this.currentPage === 'recipes';
+ }
+
+ normalizeLoraAvailabilityFilters(loraAvailability) {
+ // Default to no statuses selected (= no filtering)
+ if (!Array.isArray(loraAvailability)) {
+ return [];
+ }
+
+ const seen = new Set();
+ return loraAvailability.reduce((acc, status) => {
+ if (typeof status !== 'string') {
+ return acc;
+ }
+
+ const normalized = status.trim().toLowerCase();
+ if (!LORA_AVAILABILITY_STATUSES.includes(normalized) || seen.has(normalized)) {
+ return acc;
+ }
+
+ seen.add(normalized);
+ acc.push(normalized);
+ return acc;
+ }, []);
+ }
+
normalizeTagFilters(tagFilters) {
if (!tagFilters) {
return {};
@@ -994,6 +1077,7 @@ export class FilterManager {
autoTags: { ...(this.filters.autoTags || {}) },
license: { ...(this.filters.license || {}) },
modelTypes: [...(this.filters.modelTypes || [])],
+ loraAvailability: [...(this.filters.loraAvailability || [])],
tagLogic: this.filters.tagLogic || 'any',
search: pageState?.filters?.search ?? ''
};
diff --git a/static/js/state/index.js b/static/js/state/index.js
index d32f773a..dc7815e1 100644
--- a/static/js/state/index.js
+++ b/static/js/state/index.js
@@ -148,6 +148,7 @@ export const state = {
tags: {},
license: {},
modelTypes: [],
+ loraAvailability: [],
search: ''
},
pageSize: 20,
diff --git a/templates/components/header.html b/templates/components/header.html
index 14049c02..05912cd1 100644
--- a/templates/components/header.html
+++ b/templates/components/header.html
@@ -264,6 +264,22 @@
{{ t('header.filter.noTagMatches') }}
+ {% if current_page == 'recipes' %}
+
+
{{ t('header.filter.loraAvailability') }}
+
+
+ {% endif %}
{% if current_page == 'loras' or current_page == 'checkpoints' %}
{{ t('header.filter.modelTypes') }}
diff --git a/tests/frontend/api/recipeApi.loraAvailability.test.js b/tests/frontend/api/recipeApi.loraAvailability.test.js
new file mode 100644
index 00000000..32451435
--- /dev/null
+++ b/tests/frontend/api/recipeApi.loraAvailability.test.js
@@ -0,0 +1,100 @@
+import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
+
+const getCurrentPageStateMock = vi.hoisted(() => vi.fn());
+
+vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
+ showToast: vi.fn(),
+}));
+
+vi.mock('../../../static/js/components/RecipeCard.js', () => ({
+ RecipeCard: vi.fn(() => ({ element: document.createElement('div') })),
+}));
+
+vi.mock('../../../static/js/state/index.js', () => ({
+ state: {
+ loadingManager: {
+ showSimpleLoading: vi.fn(),
+ hide: vi.fn(),
+ },
+ },
+ getCurrentPageState: getCurrentPageStateMock,
+}));
+
+vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
+ captureScrollPosition: vi.fn(),
+ restoreScrollPosition: vi.fn(),
+ recreateVirtualScroll: vi.fn(),
+}));
+
+import { fetchRecipesPage } from '../../../static/js/api/recipeApi.js';
+
+function makePageState(loraAvailability) {
+ return {
+ pageSize: 50,
+ currentPage: 1,
+ hasMore: true,
+ isLoading: false,
+ sortBy: 'date:desc',
+ showFavoritesOnly: false,
+ activeFolder: null,
+ searchOptions: { recursive: true },
+ customFilter: { active: false },
+ filters: { loraAvailability },
+ };
+}
+
+describe('fetchRecipesPage lora_availability param', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ items: [], total: 0, total_pages: 0 }),
+ });
+ });
+
+ afterEach(() => {
+ delete global.fetch;
+ });
+
+ it('appends lora_availability when a subset of statuses is selected', async () => {
+ getCurrentPageStateMock.mockReturnValue(makePageState(['missing', 'deleted']));
+
+ await fetchRecipesPage(1, 50);
+
+ const url = global.fetch.mock.calls[0][0];
+ const params = new URL(url, 'http://localhost').searchParams;
+ expect(params.get('lora_availability')).toBe('missing,deleted');
+ });
+
+ it('appends lora_availability when all statuses are selected (backend treats it as show-all)', async () => {
+ getCurrentPageStateMock.mockReturnValue(
+ makePageState(['ready', 'missing', 'deleted'])
+ );
+
+ await fetchRecipesPage(1, 50);
+
+ const url = global.fetch.mock.calls[0][0];
+ const params = new URL(url, 'http://localhost').searchParams;
+ expect(params.get('lora_availability')).toBe('ready,missing,deleted');
+ });
+
+ it('omits lora_availability when no statuses are selected', async () => {
+ getCurrentPageStateMock.mockReturnValue(makePageState([]));
+
+ await fetchRecipesPage(1, 50);
+
+ const url = global.fetch.mock.calls[0][0];
+ const params = new URL(url, 'http://localhost').searchParams;
+ expect(params.get('lora_availability')).toBeNull();
+ });
+
+ it('omits lora_availability when the filter is absent', async () => {
+ getCurrentPageStateMock.mockReturnValue(makePageState(undefined));
+
+ await fetchRecipesPage(1, 50);
+
+ const url = global.fetch.mock.calls[0][0];
+ const params = new URL(url, 'http://localhost').searchParams;
+ expect(params.get('lora_availability')).toBeNull();
+ });
+});
diff --git a/tests/frontend/managers/FilterManager.loraAvailability.test.js b/tests/frontend/managers/FilterManager.loraAvailability.test.js
new file mode 100644
index 00000000..36399890
--- /dev/null
+++ b/tests/frontend/managers/FilterManager.loraAvailability.test.js
@@ -0,0 +1,266 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+
+// Mock dependencies
+vi.mock('../../../static/js/state/index.js', () => ({
+ getCurrentPageState: vi.fn(() => ({
+ filters: {},
+ })),
+ state: {
+ currentPageType: 'recipes',
+ loadingManager: {
+ showSimpleLoading: vi.fn(),
+ hide: vi.fn(),
+ },
+ },
+}));
+
+vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
+ showToast: vi.fn(),
+ updatePanelPositions: vi.fn(),
+}));
+
+vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
+ getModelApiClient: vi.fn(() => ({
+ loadMoreWithVirtualScroll: vi.fn().mockResolvedValue(),
+ })),
+}));
+
+vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
+ getStorageItem: vi.fn(),
+ setStorageItem: vi.fn(),
+ removeStorageItem: vi.fn(),
+}));
+
+vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
+ translate: vi.fn((key, _params, fallback) => fallback || key),
+}));
+
+vi.mock('../../../static/js/managers/FilterPresetManager.js', () => ({
+ FilterPresetManager: vi.fn().mockImplementation(() => ({
+ renderPresets: vi.fn(),
+ saveActivePreset: vi.fn(),
+ restoreActivePreset: vi.fn(),
+ updateAddButtonState: vi.fn(),
+ hasEmptyWildcardResult: vi.fn(() => false),
+ })),
+ EMPTY_WILDCARD_MARKER: '__EMPTY_WILDCARD_RESULT__',
+}));
+
+import { FilterManager } from '../../../static/js/managers/FilterManager.js';
+import { getStorageItem } from '../../../static/js/utils/storageHelpers.js';
+
+const ALL_STATUSES = ['ready', 'missing', 'deleted'];
+
+describe('FilterManager - LoRA Availability', () => {
+ let manager;
+ let mockFilterPanel;
+ let mockActiveFiltersCount;
+
+ function createAvailabilityTags() {
+ const container = document.createElement('div');
+ container.id = 'loraAvailabilityTags';
+ ALL_STATUSES.forEach(status => {
+ const tag = document.createElement('div');
+ tag.className = 'filter-tag lora-availability-tag';
+ tag.dataset.availability = status;
+ container.appendChild(tag);
+ });
+ document.body.appendChild(container);
+ return container;
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ getStorageItem.mockReturnValue(undefined);
+ document.body.innerHTML = '';
+
+ mockFilterPanel = document.createElement('div');
+ mockFilterPanel.id = 'filterPanel';
+ mockFilterPanel.classList.add('hidden');
+ document.body.appendChild(mockFilterPanel);
+
+ mockActiveFiltersCount = document.createElement('span');
+ createAvailabilityTags();
+
+ const originalGetElementById = document.getElementById;
+ document.getElementById = vi.fn((id) => {
+ if (id === 'filterPanel') return mockFilterPanel;
+ if (id === 'filterButton') return document.createElement('button');
+ if (id === 'activeFiltersCount') return mockActiveFiltersCount;
+ if (id === 'baseModelTags') return document.createElement('div');
+ if (id === 'modelTypeTags') return document.createElement('div');
+ return originalGetElementById.call(document, id);
+ });
+ });
+
+ describe('initializeFilters', () => {
+ it('should default to no statuses selected on the recipes page', () => {
+ manager = new FilterManager({ page: 'recipes' });
+
+ expect(manager.filters.loraAvailability).toEqual([]);
+ });
+
+ it('should restore a saved selection from storage', () => {
+ getStorageItem.mockReturnValue({
+ baseModel: [],
+ tags: {},
+ loraAvailability: ['missing'],
+ });
+
+ manager = new FilterManager({ page: 'recipes' });
+
+ expect(manager.filters.loraAvailability).toEqual(['missing']);
+ });
+
+ it('should drop invalid stored values', () => {
+ getStorageItem.mockReturnValue({
+ baseModel: [],
+ tags: {},
+ loraAvailability: ['missing', 'bogus', 'missing'],
+ });
+
+ manager = new FilterManager({ page: 'recipes' });
+
+ expect(manager.filters.loraAvailability).toEqual(['missing']);
+ });
+
+ it('should default to no statuses when the stored value is not an array', () => {
+ getStorageItem.mockReturnValue({
+ baseModel: [],
+ tags: {},
+ loraAvailability: 'missing',
+ });
+
+ manager = new FilterManager({ page: 'recipes' });
+
+ expect(manager.filters.loraAvailability).toEqual([]);
+ });
+ });
+
+ describe('hasActiveFilters', () => {
+ it('should be inactive when no statuses are selected', () => {
+ manager = new FilterManager({ page: 'recipes' });
+
+ expect(manager.hasActiveFilters()).toBe(false);
+ });
+
+ it('should be active when at least one status is selected', () => {
+ getStorageItem.mockReturnValue({
+ baseModel: [],
+ tags: {},
+ loraAvailability: ['ready'],
+ });
+
+ manager = new FilterManager({ page: 'recipes' });
+
+ expect(manager.hasActiveFilters()).toBe(true);
+ });
+ });
+
+ describe('updateActiveFiltersCount', () => {
+ it('should count selected statuses', () => {
+ getStorageItem.mockReturnValue({
+ baseModel: [],
+ tags: {},
+ loraAvailability: ['missing', 'deleted'],
+ });
+
+ manager = new FilterManager({ page: 'recipes' });
+
+ expect(mockActiveFiltersCount.textContent).toBe('2');
+ });
+ });
+
+ describe('chip interaction', () => {
+ it('should select a status when its chip is clicked', async () => {
+ manager = new FilterManager({ page: 'recipes' });
+
+ const readyTag = document.querySelector('[data-availability="ready"]');
+ expect(readyTag.classList.contains('active')).toBe(false);
+
+ readyTag.click();
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(manager.filters.loraAvailability).toEqual(['ready']);
+ expect(readyTag.classList.contains('active')).toBe(true);
+ });
+
+ it('should deselect a selected status when its chip is clicked again', async () => {
+ getStorageItem.mockReturnValue({
+ baseModel: [],
+ tags: {},
+ loraAvailability: ['ready'],
+ });
+
+ manager = new FilterManager({ page: 'recipes' });
+
+ const readyTag = document.querySelector('[data-availability="ready"]');
+ // Restored state should mark the chip active
+ expect(readyTag.classList.contains('active')).toBe(true);
+
+ readyTag.click();
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(manager.filters.loraAvailability).toEqual([]);
+ expect(readyTag.classList.contains('active')).toBe(false);
+ });
+
+ it('should mark all chips active when a stored all-statuses array is restored', () => {
+ // Legacy stored value: all statuses selected. Under positive
+ // selection semantics the backend treats this as show-all.
+ getStorageItem.mockReturnValue({
+ baseModel: [],
+ tags: {},
+ loraAvailability: [...ALL_STATUSES],
+ });
+
+ manager = new FilterManager({ page: 'recipes' });
+
+ expect(manager.filters.loraAvailability).toEqual(ALL_STATUSES);
+ document.querySelectorAll('.lora-availability-tag').forEach(tag => {
+ expect(tag.classList.contains('active')).toBe(true);
+ });
+ });
+ });
+
+ describe('cloneFilters', () => {
+ it('should include loraAvailability in cloned filters', () => {
+ getStorageItem.mockReturnValue({
+ baseModel: [],
+ tags: {},
+ loraAvailability: ['deleted'],
+ });
+
+ manager = new FilterManager({ page: 'recipes' });
+
+ const cloned = manager.cloneFilters();
+
+ expect(cloned.loraAvailability).toEqual(['deleted']);
+ });
+
+ it('should clone an empty selection as an empty array', () => {
+ manager = new FilterManager({ page: 'recipes' });
+
+ const cloned = manager.cloneFilters();
+
+ expect(cloned.loraAvailability).toEqual([]);
+ });
+ });
+
+ describe('clearFilters', () => {
+ it('should reset loraAvailability to no statuses selected', () => {
+ getStorageItem.mockReturnValue({
+ baseModel: [],
+ tags: {},
+ loraAvailability: ['deleted'],
+ });
+
+ manager = new FilterManager({ page: 'recipes' });
+ expect(manager.filters.loraAvailability).toEqual(['deleted']);
+
+ manager.clearFilters();
+
+ expect(manager.filters.loraAvailability).toEqual([]);
+ });
+ });
+});
diff --git a/tests/routes/test_recipe_routes.py b/tests/routes/test_recipe_routes.py
index af632cab..53b54a52 100644
--- a/tests/routes/test_recipe_routes.py
+++ b/tests/routes/test_recipe_routes.py
@@ -632,6 +632,43 @@ async def test_list_recipes_passes_checkpoint_hash_filter(
assert harness.scanner.last_paginated_params["checkpoint_hash"] == "ckpt123"
+async def test_list_recipes_passes_lora_availability_filter(
+ monkeypatch, tmp_path: Path
+) -> None:
+ async with recipe_harness(monkeypatch, tmp_path) as harness:
+ response = await harness.client.get(
+ "/api/lm/recipes?lora_availability=missing,deleted"
+ )
+ payload = await response.json()
+
+ assert response.status == 200
+ assert payload["items"] == []
+ assert harness.scanner.last_paginated_params is not None
+ filters = harness.scanner.last_paginated_params["filters"]
+ assert filters["lora_availability"] == {"missing", "deleted"}
+
+
+async def test_list_recipes_ignores_invalid_lora_availability_values(
+ monkeypatch, tmp_path: Path
+) -> None:
+ async with recipe_harness(monkeypatch, tmp_path) as harness:
+ # Valid values are kept, invalid ones dropped
+ response = await harness.client.get(
+ "/api/lm/recipes?lora_availability=bogus,ready"
+ )
+ assert response.status == 200
+ assert harness.scanner.last_paginated_params is not None
+ filters = harness.scanner.last_paginated_params["filters"]
+ assert filters["lora_availability"] == {"ready"}
+
+ # No valid values at all -> no availability filter
+ response = await harness.client.get("/api/lm/recipes?lora_availability=bogus")
+ assert response.status == 200
+ assert harness.scanner.last_paginated_params is not None
+ filters = harness.scanner.last_paginated_params["filters"]
+ assert "lora_availability" not in filters
+
+
async def test_get_recipes_for_checkpoint(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.scanner.checkpoint_lookup["abc123"] = [
diff --git a/tests/services/test_recipe_lora_availability_filter.py b/tests/services/test_recipe_lora_availability_filter.py
new file mode 100644
index 00000000..46a215e0
--- /dev/null
+++ b/tests/services/test_recipe_lora_availability_filter.py
@@ -0,0 +1,138 @@
+"""Tests for the LoRA availability filter on the recipe listing."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+
+from py.config import config
+from py.services.recipe_scanner import RecipeScanner
+
+
+class StubLoraScanner:
+ """In-memory lora scanner double exposing the hash/version indexes."""
+
+ def __init__(self, hashes=(), version_index=None):
+ self._hashes = {value.lower() for value in hashes}
+ self._cache = SimpleNamespace(raw_data=[], version_index=version_index or {})
+
+ def has_hash(self, hash_value):
+ return hash_value.lower() in self._hashes
+
+ def get_preview_url_by_hash(self, hash_value):
+ return None
+
+ def get_path_by_hash(self, hash_value):
+ return None
+
+ async def get_cached_data(self):
+ return self._cache
+
+ async def refresh_cache(self, force=False):
+ pass
+
+
+@pytest.fixture
+def recipe_scanner(tmp_path, monkeypatch):
+ monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
+ lora_scanner = StubLoraScanner(
+ hashes={"aaa111"},
+ version_index={42: {"file_path": "/loras/from-version.safetensors"}},
+ )
+ scanner = RecipeScanner(lora_scanner=lora_scanner) # pyright: ignore[reportArgumentType]
+
+ recipes = [
+ # All loras in library (hash matching is case-insensitive) -> ready
+ {"id": "r1", "title": "Ready", "loras": [{"hash": "AAA111"}]},
+ # One lora not in library -> missing
+ {
+ "id": "r2",
+ "title": "Missing",
+ "loras": [{"hash": "aaa111"}, {"hash": "bbb222"}],
+ },
+ # Lora deleted on Civitai and not in library -> deleted
+ {
+ "id": "r3",
+ "title": "Deleted",
+ "loras": [{"hash": "ccc333", "isDeleted": True}],
+ },
+ # Missing + deleted -> both statuses
+ {
+ "id": "r4",
+ "title": "Mixed",
+ "loras": [{"hash": "bbb222"}, {"hash": "ccc333", "isDeleted": True}],
+ },
+ # No loras at all -> ready
+ {"id": "r5", "title": "Empty", "loras": []},
+ # Excluded lora is ignored -> ready
+ {
+ "id": "r6",
+ "title": "Excluded",
+ "loras": [{"hash": "bbb222", "exclude": True}],
+ },
+ # modelVersionId resolves via the version index -> ready
+ {"id": "r7", "title": "VersionFallback", "loras": [{"modelVersionId": 42}]},
+ ]
+
+ scanner._cache = SimpleNamespace(
+ raw_data=recipes,
+ sorted_by_date=recipes,
+ sorted_by_name=recipes,
+ )
+ return scanner
+
+
+async def _fetch_ids(scanner, filters=None):
+ result = await scanner.get_paginated_data(page=1, page_size=50, filters=filters)
+ return {item["id"] for item in result["items"]}
+
+
+@pytest.mark.asyncio
+async def test_availability_filter_ready_only(recipe_scanner):
+ ids = await _fetch_ids(recipe_scanner, {"lora_availability": {"ready"}})
+ assert ids == {"r1", "r5", "r6", "r7"}
+
+
+@pytest.mark.asyncio
+async def test_availability_filter_missing_only(recipe_scanner):
+ ids = await _fetch_ids(recipe_scanner, {"lora_availability": {"missing"}})
+ assert ids == {"r2", "r4"}
+
+
+@pytest.mark.asyncio
+async def test_availability_filter_deleted_only(recipe_scanner):
+ ids = await _fetch_ids(recipe_scanner, {"lora_availability": {"deleted"}})
+ assert ids == {"r3", "r4"}
+
+
+@pytest.mark.asyncio
+async def test_availability_filter_missing_and_deleted(recipe_scanner):
+ ids = await _fetch_ids(recipe_scanner, {"lora_availability": {"missing", "deleted"}})
+ assert ids == {"r2", "r3", "r4"}
+
+
+@pytest.mark.asyncio
+async def test_availability_filter_all_statuses_disables_filtering(recipe_scanner):
+ ids = await _fetch_ids(
+ recipe_scanner, {"lora_availability": {"ready", "missing", "deleted"}}
+ )
+ assert ids == {"r1", "r2", "r3", "r4", "r5", "r6", "r7"}
+
+
+@pytest.mark.asyncio
+async def test_availability_filter_absent_or_invalid_disables_filtering(recipe_scanner):
+ assert await _fetch_ids(recipe_scanner) == {"r1", "r2", "r3", "r4", "r5", "r6", "r7"}
+ ids = await _fetch_ids(recipe_scanner, {"lora_availability": {"bogus"}})
+ assert ids == {"r1", "r2", "r3", "r4", "r5", "r6", "r7"}
+
+
+@pytest.mark.asyncio
+async def test_availability_filter_counts_and_pagination(recipe_scanner):
+ # Filtering happens before pagination, so totals reflect the filtered set.
+ result = await recipe_scanner.get_paginated_data(
+ page=1, page_size=1, filters={"lora_availability": {"ready"}}
+ )
+ assert result["total"] == 4
+ assert result["total_pages"] == 4
+ assert len(result["items"]) == 1