feat(sort): enable versions_count sort in non-grouped mode

Sort by Most/Fewest versions first now works when Group by model is off.

- Backend: group items by modelId (respecting version_grouping setting),
  count versions per group, sort groups by count, expand groups with
  versions sorted by version id descending
- CSS: remove rule that hid the sort option in non-grouped mode
- Tests: add 3 tests covering desc, asc, and same_base variants
This commit is contained in:
Will Miao
2026-06-24 17:14:39 +08:00
parent 7a71b34b54
commit 609dc5d783
3 changed files with 175 additions and 15 deletions

View File

@@ -152,18 +152,51 @@ class BaseModelService(ABC):
dedup_lost = len(sorted_data) - (len(dedup_map) + len(standalone)) dedup_lost = len(sorted_data) - (len(dedup_map) + len(standalone))
sorted_data = [entry[0] for entry in dedup_map.values()] + 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) # Re-sort by version_count (grouped: after dedup; non-grouped: group internally, sort, expand)
is_group_by_active = kwargs.get("group_by_model") and civitai_model_id is None if sort_params.key == "versions_count" and civitai_model_id is None:
if sort_params.key == "versions_count" and is_group_by_active:
reverse = sort_params.order == "desc" reverse = sort_params.order == "desc"
sorted_data.sort( if kwargs.get("group_by_model"):
key=lambda x: ( # Grouped mode: items are already dedup'd with version_count attached
x.get("version_count", 0), sorted_data.sort(
(x.get("model_name") or x.get("file_name") or "").lower(), key=lambda x: (
x.get("file_path", "").lower(), x.get("version_count", 0),
), (x.get("model_name") or x.get("file_name") or "").lower(),
reverse=reverse, 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() t1 = time.perf_counter()
if hash_filters: if hash_filters:

View File

@@ -60,7 +60,4 @@
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
} }
/* Hide versions_count sort option when group-by-model is off */ /* ---------- reused from shared styles ---------- */
body:not(.group-by-model) .sort-option-versions-count {
display: none;
}

View File

@@ -809,6 +809,136 @@ async def test_get_paginated_data_group_by_model_dedup():
assert response_all["total"] == 5 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(): async def test_get_paginated_data_filters_by_civitai_model_id():
"""civitai_model_id filter returns only items matching the given modelId, """civitai_model_id filter returns only items matching the given modelId,
and bypasses group_by_model dedup so all versions appear.""" and bypasses group_by_model dedup so all versions appear."""