diff --git a/locales/de.json b/locales/de.json index d4d2bdd2..56155d93 100644 --- a/locales/de.json +++ b/locales/de.json @@ -430,6 +430,8 @@ "help": "Wenn aktiviert, überspringt LoRA Manager den Download einer Modellversion, wenn der Download-Verlaufsdienst diese spezifische Version als bereits heruntergeladen erfasst hat. Gilt für alle Download-Abläufe." }, "layoutSettings": { + "groupByModel": "Nach Modell gruppieren", + "groupByModelHelp": "Wenn aktiviert, wird nur die neueste Version jedes Civitai-Modells als einzelne Karte angezeigt. Ältere Versionen werden ausgeblendet.", "displayDensity": "Anzeige-Dichte", "displayDensityOptions": { "default": "Standard", diff --git a/locales/en.json b/locales/en.json index 3162cd73..b55ff9bb 100644 --- a/locales/en.json +++ b/locales/en.json @@ -430,6 +430,8 @@ "help": "When enabled, versions downloaded before will be skipped." }, "layoutSettings": { + "groupByModel": "Group by Model", + "groupByModelHelp": "When enabled, only the latest version of each Civitai model is shown as a single card. Older versions are hidden.", "displayDensity": "Display Density", "displayDensityOptions": { "default": "Default", diff --git a/locales/es.json b/locales/es.json index 63338661..33a96fd2 100644 --- a/locales/es.json +++ b/locales/es.json @@ -430,6 +430,8 @@ "help": "Cuando está habilitado, LoRA Manager omitirá la descarga de una versión de modelo si el servicio de historial de descargas registra esa versión exacta como ya descargada. Aplica a todos los flujos de descarga." }, "layoutSettings": { + "groupByModel": "Agrupar por modelo", + "groupByModelHelp": "Cuando está activado, solo se muestra la versión más reciente de cada modelo de Civitai como una tarjeta única. Las versiones anteriores están ocultas.", "displayDensity": "Densidad de visualización", "displayDensityOptions": { "default": "Predeterminado", diff --git a/locales/fr.json b/locales/fr.json index fcb7ae0c..e5b440c1 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -430,6 +430,8 @@ "help": "Lorsque activé, LoRA Manager ignorera le téléchargement d'une version de modèle si le service d'historique des téléchargements enregistre cette version exacte comme déjà téléchargée. S'applique à tous les flux de téléchargement." }, "layoutSettings": { + "groupByModel": "Grouper par modèle", + "groupByModelHelp": "Lorsque activé, seule la version la plus récente de chaque modèle Civitai s'affiche sous forme de carte unique. Les versions plus anciennes sont masquées.", "displayDensity": "Densité d'affichage", "displayDensityOptions": { "default": "Par défaut", diff --git a/locales/he.json b/locales/he.json index 3005a3a3..e46a7e86 100644 --- a/locales/he.json +++ b/locales/he.json @@ -430,6 +430,8 @@ "help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה." }, "layoutSettings": { + "groupByModel": "קיבוץ לפי דגם", + "groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל דגם Civitai מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.", "displayDensity": "צפיפות תצוגה", "displayDensityOptions": { "default": "ברירת מחדל", diff --git a/locales/ja.json b/locales/ja.json index 57eea30c..dec243cd 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -430,6 +430,8 @@ "help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。" }, "layoutSettings": { + "groupByModel": "モデルでグループ化", + "groupByModelHelp": "有効にすると、各Civitaiモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。", "displayDensity": "表示密度", "displayDensityOptions": { "default": "デフォルト", diff --git a/locales/ko.json b/locales/ko.json index 368df662..d01e5544 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -430,6 +430,8 @@ "help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다." }, "layoutSettings": { + "groupByModel": "모델별 그룹화", + "groupByModelHelp": "활성화하면 각 Civitai 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.", "displayDensity": "표시 밀도", "displayDensityOptions": { "default": "기본", diff --git a/locales/ru.json b/locales/ru.json index eae01a07..6059b2d1 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -430,6 +430,8 @@ "help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки." }, "layoutSettings": { + "groupByModel": "Группировать по модели", + "groupByModelHelp": "При включении отображается только последняя версия каждой модели Civitai в виде одной карточки. Старые версии скрыты.", "displayDensity": "Плотность отображения", "displayDensityOptions": { "default": "По умолчанию", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 582bd726..3b6996b9 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -430,6 +430,8 @@ "help": "启用后,如果下载历史服务记录显示该版本已下载,LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。" }, "layoutSettings": { + "groupByModel": "按模型分组", + "groupByModelHelp": "开启后,每个 Civitai 模型仅显示最新版本的单张卡片,旧版本将被隐藏。", "displayDensity": "显示密度", "displayDensityOptions": { "default": "默认", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index c5fe8eaf..37a5b810 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -430,6 +430,8 @@ "help": "啟用後,如果下載歷史服務記錄顯示該版本已下載,LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。" }, "layoutSettings": { + "groupByModel": "按模型分組", + "groupByModelHelp": "啟用後,每個 Civitai 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。", "displayDensity": "顯示密度", "displayDensityOptions": { "default": "預設", diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index 4812b858..916864b7 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -233,6 +233,8 @@ class ModelListingHandler: start_time = time.perf_counter() try: params = self._parse_common_params(request) + # group_by_model is meaningless for excluded view; strip it + params.pop("group_by_model", None) result = await self._service.get_excluded_paginated_data(**params) format_start = time.perf_counter() @@ -366,6 +368,11 @@ class ModelListingHandler: request.query.get("name_pattern_use_regex", "false").lower() == "true" ) + # Group-by-model flag: deduplicate versions sharing the same civitai modelId + group_by_model = ( + request.query.get("group_by_model", "false").lower() == "true" + ) + return { "page": page, "page_size": page_size, @@ -389,6 +396,7 @@ class ModelListingHandler: "name_pattern_include": name_pattern_include, "name_pattern_exclude": name_pattern_exclude, "name_pattern_use_regex": name_pattern_use_regex, + "group_by_model": group_by_model, **self._parse_specific_params(request), } diff --git a/py/services/base_model_service.py b/py/services/base_model_service.py index 782143af..13f3bfbe 100644 --- a/py/services/base_model_service.py +++ b/py/services/base_model_service.py @@ -104,6 +104,22 @@ class BaseModelService(ABC): fetch_duration = time.perf_counter() - t0 initial_count = len(sorted_data) + # Optionally group by civitai modelId, showing only the latest version per model + dedup_lost = 0 + if kwargs.get("group_by_model"): + dedup_map = {} # modelId -> (item, version_id) + standalone = [] + for item in sorted_data: + mid = self._extract_model_id(item) + if mid is None: + standalone.append(item) + continue + vid = self._extract_version_id(item) or 0 + if mid not in dedup_map or vid > dedup_map[mid][1]: + dedup_map[mid] = (item, vid) + dedup_lost = len(sorted_data) - (len(dedup_map) + len(standalone)) + sorted_data = [entry[0] for entry in dedup_map.values()] + standalone + t1 = time.perf_counter() if hash_filters: filtered_data = await self._apply_hash_filters(sorted_data, hash_filters) @@ -172,7 +188,7 @@ class BaseModelService(ABC): overall_duration = time.perf_counter() - overall_start logger.debug( "%s.get_paginated_data took %.3fs (fetch: %.3fs, filter: %.3fs, update_filter: %.3fs, pagination: %.3fs, annotate: %.3fs). " - "Counts: initial=%d, post_filter=%d, final=%d", + "Counts: initial=%d, dedup=%d, post_filter=%d, final=%d", self.__class__.__name__, overall_duration, fetch_duration, @@ -181,6 +197,7 @@ class BaseModelService(ABC): pagination_duration, annotate_duration, initial_count, + dedup_lost, post_filter_count, final_count, ) diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index 35c887c3..d5d671b5 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -106,6 +106,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = { "backup_auto_enabled": True, "backup_retention_count": 5, "use_new_license_icons": True, + "group_by_model": False, } diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index f89e6eeb..d32bf601 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -1271,6 +1271,11 @@ export class BaseModelApiClient { params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false'); + // Pass group-by-model mode to backend + if (state.global.settings.group_by_model) { + params.append('group_by_model', 'true'); + } + if (!isExcludedView && pageState.filters) { if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) { Object.entries(pageState.filters.tags).forEach(([tag, state]) => { diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index 30763129..e7be6e23 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -905,6 +905,12 @@ export class SettingsManager { showVersionOnCardCheckbox.checked = state.global.settings.show_version_on_card !== false; } + // Set group by model + const groupByModelCheckbox = document.getElementById('groupByModel'); + if (groupByModelCheckbox) { + groupByModelCheckbox.checked = !!state.global.settings.group_by_model; + } + // Set model name display setting const modelNameDisplaySelect = document.getElementById('modelNameDisplay'); if (modelNameDisplaySelect) { @@ -2011,7 +2017,7 @@ export class SettingsManager { } } - if (settingKey === 'show_only_sfw' || settingKey === 'blur_mature_content') { + if (settingKey === 'show_only_sfw' || settingKey === 'blur_mature_content' || settingKey === 'group_by_model') { this.reloadContent(); } @@ -3046,6 +3052,10 @@ export class SettingsManager { const useNewLicenseIcons = state.global.settings.use_new_license_icons !== false; document.body.classList.toggle('use-new-license-icons', useNewLicenseIcons); + // Apply group-by-model mode + const groupByModel = !!state.global.settings.group_by_model; + document.body.classList.toggle('group-by-model', groupByModel); + } } diff --git a/static/js/state/index.js b/static/js/state/index.js index e395bb39..a62f3c26 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -54,6 +54,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({ backup_retention_count: 5, strip_lora_on_copy: false, use_new_license_icons: true, + group_by_model: false, }); export function createDefaultSettings() { diff --git a/templates/components/modals/settings_modal.html b/templates/components/modals/settings_modal.html index 63d52d3b..ee67ca66 100644 --- a/templates/components/modals/settings_modal.html +++ b/templates/components/modals/settings_modal.html @@ -536,6 +536,25 @@ + +
+
+
+ +
+
+ +
+
+
+
diff --git a/tests/services/test_base_model_service.py b/tests/services/test_base_model_service.py index d7a6cb73..761e24c5 100644 --- a/tests/services/test_base_model_service.py +++ b/tests/services/test_base_model_service.py @@ -746,6 +746,63 @@ async def test_get_paginated_data_update_available_only_without_update_service() assert response["total_pages"] == 0 +@pytest.mark.asyncio +async def test_get_paginated_data_group_by_model_dedup(): + """group_by_model deduplicates items sharing the same civitai modelId, + keeping only the item with the highest version (civitai.id).""" + items = [ + # Two versions of the same model (modelId=1) + {"model_name": "SameModel", "folder": "root", "civitai": {"modelId": 1, "id": 100}}, + {"model_name": "SameModel", "folder": "root", "civitai": {"modelId": 1, "id": 200}}, + # Another model with two versions + {"model_name": "AnotherModel", "folder": "root", "civitai": {"modelId": 2, "id": 50}}, + {"model_name": "AnotherModel", "folder": "root", "civitai": {"modelId": 2, "id": 99}}, + # A standalone item with no civitai metadata (no modelId) + {"model_name": "Standalone", "folder": "root"}, + ] + repository = StubRepository(items) + filter_set = PassThroughFilterSet() + search_strategy = NoSearchStrategy() + settings = StubSettings({}) + + service = DummyService( + model_type="stub", + scanner=object(), + metadata_class=BaseModelMetadata, + cache_repository=repository, + filter_set=filter_set, + search_strategy=search_strategy, + settings_provider=settings, + ) + + # With group_by_model=True — modelId=1 keeps id=200, modelId=2 keeps id=99 + response = await service.get_paginated_data( + page=1, + page_size=10, + sort_by="name:asc", + group_by_model=True, + ) + + names = {item["model_name"] for item in response["items"]} + assert names == {"SameModel", "AnotherModel", "Standalone"} + assert response["total"] == 3 + # Verify the kept items have the highest version id + for item in response["items"]: + if item.get("civitai", {}).get("modelId") == 1: + assert item["civitai"]["id"] == 200 + elif item.get("civitai", {}).get("modelId") == 2: + assert item["civitai"]["id"] == 99 + + # With group_by_model=False (default) — all 5 items pass through + response_all = await service.get_paginated_data( + page=1, + page_size=10, + sort_by="name:asc", + ) + + assert response_all["total"] == 5 + + def test_model_filter_set_handles_include_and_exclude_tag_filters(): settings = StubSettings({}) filter_set = ModelFilterSet(settings)