diff --git a/py/services/base_model_service.py b/py/services/base_model_service.py index 5ecc0d7c..9b91a714 100644 --- a/py/services/base_model_service.py +++ b/py/services/base_model_service.py @@ -152,18 +152,51 @@ class BaseModelService(ABC): dedup_lost = len(sorted_data) - (len(dedup_map) + len(standalone)) sorted_data = [entry[0] for entry in dedup_map.values()] + standalone - # Re-sort by version_count after dedup (only makes sense in group_by_model mode) - is_group_by_active = kwargs.get("group_by_model") and civitai_model_id is None - if sort_params.key == "versions_count" and is_group_by_active: + # Re-sort by version_count (grouped: after dedup; non-grouped: group internally, sort, expand) + if sort_params.key == "versions_count" and civitai_model_id is None: reverse = sort_params.order == "desc" - sorted_data.sort( - key=lambda x: ( - x.get("version_count", 0), - (x.get("model_name") or x.get("file_name") or "").lower(), - x.get("file_path", "").lower(), - ), - reverse=reverse, - ) + if kwargs.get("group_by_model"): + # Grouped mode: items are already dedup'd with version_count attached + sorted_data.sort( + key=lambda x: ( + x.get("version_count", 0), + (x.get("model_name") or x.get("file_name") or "").lower(), + x.get("file_path", "").lower(), + ), + reverse=reverse, + ) + else: + # Non-grouped mode: group internally, sort groups by count, expand + # Respect the version_grouping setting (same logic as grouped dedup) + ufs = self.settings.get("version_grouping", "same_base") + group_by_base = ufs == "same_base" + + model_groups: Dict[Any, List[Dict]] = {} + ungrouped_standalone: List[Dict] = [] + for item in sorted_data: + mid = self._extract_model_id(item) + if mid is None: + ungrouped_standalone.append(item) + continue + key = (mid, item.get("base_model") or "") if group_by_base else mid + model_groups.setdefault(key, []).append(item) + # Sort versions within each group by version id descending + for items in model_groups.values(): + items.sort( + key=lambda x: self._extract_version_id(x) or 0, + reverse=True, + ) + # Sort groups by version count + sorted_groups = sorted( + model_groups.values(), + key=lambda items: len(items), + reverse=reverse, + ) + # Flatten: grouped items first, standalone items last + sorted_data = [] + for items in sorted_groups: + sorted_data.extend(items) + sorted_data.extend(ungrouped_standalone) t1 = time.perf_counter() if hash_filters: diff --git a/static/css/style.css b/static/css/style.css index dfc00782..b8460bf1 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -60,7 +60,4 @@ margin-bottom: var(--space-2); } -/* Hide versions_count sort option when group-by-model is off */ -body:not(.group-by-model) .sort-option-versions-count { - display: none; -} +/* ---------- reused from shared styles ---------- */ diff --git a/tests/services/test_base_model_service.py b/tests/services/test_base_model_service.py index 876941dd..a9e2a6b3 100644 --- a/tests/services/test_base_model_service.py +++ b/tests/services/test_base_model_service.py @@ -809,6 +809,136 @@ async def test_get_paginated_data_group_by_model_dedup(): assert response_all["total"] == 5 +@pytest.mark.asyncio +async def test_get_paginated_data_versions_count_non_grouped_desc(): + """Non-grouped, versions_count:desc — groups by model, sorts by count desc, + within-group by version id desc, then flattens.""" + items = [ + # modelId=1 has 3 versions + {"model_name": "ModelA", "folder": "root", "civitai": {"modelId": 1, "id": 300}}, + {"model_name": "ModelA", "folder": "root", "civitai": {"modelId": 1, "id": 200}}, + {"model_name": "ModelA", "folder": "root", "civitai": {"modelId": 1, "id": 100}}, + # modelId=2 has 2 versions + {"model_name": "ModelB", "folder": "root", "civitai": {"modelId": 2, "id": 99}}, + {"model_name": "ModelB", "folder": "root", "civitai": {"modelId": 2, "id": 50}}, + # modelId=3 has 1 version + {"model_name": "ModelC", "folder": "root", "civitai": {"modelId": 3, "id": 1}}, + # standalone (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, + ) + + response = await service.get_paginated_data( + page=1, page_size=10, sort_by="versions_count:desc", + ) + + ids = [item["civitai"]["id"] for item in response["items"] if "civitai" in item and "id" in item["civitai"]] + # modelId=1 (3 versions): id descending → 300, 200, 100 + # modelId=2 (2 versions): id descending → 99, 50 + # modelId=3 (1 version) → 1 + assert ids == [300, 200, 100, 99, 50, 1], f"Unexpected order: {ids}" + assert response["total"] == 7 + + +@pytest.mark.asyncio +async def test_get_paginated_data_versions_count_non_grouped_asc(): + """Non-grouped, versions_count:asc — groups by model, sorts by count asc, + then flattens.""" + items = [ + # modelId=1 has 3 versions + {"model_name": "ModelA", "folder": "root", "civitai": {"modelId": 1, "id": 300}}, + {"model_name": "ModelA", "folder": "root", "civitai": {"modelId": 1, "id": 200}}, + {"model_name": "ModelA", "folder": "root", "civitai": {"modelId": 1, "id": 100}}, + # modelId=2 has 2 versions + {"model_name": "ModelB", "folder": "root", "civitai": {"modelId": 2, "id": 99}}, + {"model_name": "ModelB", "folder": "root", "civitai": {"modelId": 2, "id": 50}}, + # modelId=3 has 1 version + {"model_name": "ModelC", "folder": "root", "civitai": {"modelId": 3, "id": 1}}, + # standalone (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, + ) + + response = await service.get_paginated_data( + page=1, page_size=10, sort_by="versions_count:asc", + ) + + ids = [item["civitai"]["id"] for item in response["items"] if "civitai" in item and "id" in item["civitai"]] + # modelId=3 (1 version) → 1 + # modelId=2 (2 versions): id descending → 99, 50 + # modelId=1 (3 versions): id descending → 300, 200, 100 + assert ids == [1, 99, 50, 300, 200, 100], f"Unexpected order: {ids}" + assert response["total"] == 7 + + +@pytest.mark.asyncio +async def test_get_paginated_data_versions_count_non_grouped_same_base(): + """Non-grouped, versions_count with version_grouping=same_base — + models with same modelId but different base_model are separate groups.""" + items = [ + # modelId=1, base_model="sd15" — 2 versions + {"model_name": "ModelA", "folder": "root", "base_model": "sd15", "civitai": {"modelId": 1, "id": 200}}, + {"model_name": "ModelA", "folder": "root", "base_model": "sd15", "civitai": {"modelId": 1, "id": 100}}, + # modelId=1, base_model="sdxl" — 3 versions + {"model_name": "ModelA", "folder": "root", "base_model": "sdxl", "civitai": {"modelId": 1, "id": 30}}, + {"model_name": "ModelA", "folder": "root", "base_model": "sdxl", "civitai": {"modelId": 1, "id": 20}}, + {"model_name": "ModelA", "folder": "root", "base_model": "sdxl", "civitai": {"modelId": 1, "id": 10}}, + # modelId=2, base_model="sd15" — 1 version + {"model_name": "ModelB", "folder": "root", "base_model": "sd15", "civitai": {"modelId": 2, "id": 1}}, + ] + repository = StubRepository(items) + filter_set = PassThroughFilterSet() + search_strategy = NoSearchStrategy() + settings = StubSettings({"version_grouping": "same_base"}) + + service = DummyService( + model_type="stub", + scanner=object(), + metadata_class=BaseModelMetadata, + cache_repository=repository, + filter_set=filter_set, + search_strategy=search_strategy, + settings_provider=settings, + ) + + response = await service.get_paginated_data( + page=1, page_size=10, sort_by="versions_count:desc", + ) + + ids = [item["civitai"]["id"] for item in response["items"] if "civitai" in item and "id" in item["civitai"]] + # (1, "sdxl") — 3 versions: 30, 20, 10 + # (1, "sd15") — 2 versions: 200, 100 + # (2, "sd15") — 1 version: 1 + assert ids == [30, 20, 10, 200, 100, 1], f"Unexpected order: {ids}" + assert response["total"] == 6 + + async def test_get_paginated_data_filters_by_civitai_model_id(): """civitai_model_id filter returns only items matching the given modelId, and bypasses group_by_model dedup so all versions appear."""