mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-09 15:30:16 -03:00
Compare commits
6 Commits
c1bf9c6221
...
36ef840a22
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36ef840a22 | ||
|
|
09c2445ac9 | ||
|
|
8a6d23f9c7 | ||
|
|
3d207b6744 | ||
|
|
b3edda62ad | ||
|
|
a429e6b1c3 |
@@ -123,24 +123,39 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
if model_hash_from_hashes:
|
if model_hash_from_hashes:
|
||||||
metadata["model_hash"] = model_hash_from_hashes
|
metadata["model_hash"] = model_hash_from_hashes
|
||||||
|
|
||||||
# Extract Lora hashes in alternative format
|
# Extract Lora hashes in alternative format.
|
||||||
|
# Run unconditionally (not just as fallback) so that
|
||||||
|
# non-empty hashes from Lora hashes fill in the gaps left
|
||||||
|
# by empty values in the Hashes JSON dict. Some WebUI
|
||||||
|
# builds write real hash values only to Lora hashes and
|
||||||
|
# leave the Hashes JSON values empty.
|
||||||
lora_hashes_match = re.search(self.LORA_HASHES_REGEX, params_section)
|
lora_hashes_match = re.search(self.LORA_HASHES_REGEX, params_section)
|
||||||
if not hashes_match and lora_hashes_match:
|
if lora_hashes_match:
|
||||||
try:
|
try:
|
||||||
lora_hashes_str = lora_hashes_match.group(1)
|
lora_hashes_str = lora_hashes_match.group(1)
|
||||||
lora_hash_entries = lora_hashes_str.split(', ')
|
lora_hash_entries = lora_hashes_str.split(', ')
|
||||||
|
|
||||||
# Initialize hashes dict if it doesn't exist
|
|
||||||
if "hashes" not in metadata:
|
|
||||||
metadata["hashes"] = {}
|
|
||||||
|
|
||||||
# Parse each lora hash entry (format: "name: hash")
|
# Parse each lora hash entry (format: "name: hash")
|
||||||
for entry in lora_hash_entries:
|
for entry in lora_hash_entries:
|
||||||
if ': ' in entry:
|
if ': ' in entry:
|
||||||
lora_name, lora_hash = entry.split(': ', 1)
|
lora_name, lora_hash = entry.split(': ', 1)
|
||||||
# Add as lora type in the same format as regular hashes
|
lora_hash = lora_hash.strip()
|
||||||
metadata["hashes"][f"lora:{lora_name}"] = lora_hash.strip()
|
if not lora_hash:
|
||||||
|
# Skip entries without a hash value
|
||||||
|
continue
|
||||||
|
# Initialize hashes dict if it doesn't exist
|
||||||
|
if "hashes" not in metadata:
|
||||||
|
metadata["hashes"] = {}
|
||||||
|
# Add as lora type in the same format as
|
||||||
|
# regular hashes. Only override an
|
||||||
|
# existing entry if its value is empty
|
||||||
|
# (Lora hashes is the more reliable
|
||||||
|
# source when Hashes JSON has blanks).
|
||||||
|
key = f"lora:{lora_name}"
|
||||||
|
existing = metadata["hashes"].get(key, "")
|
||||||
|
if not existing:
|
||||||
|
metadata["hashes"][key] = lora_hash
|
||||||
|
|
||||||
# Remove lora hashes from params section
|
# Remove lora hashes from params section
|
||||||
params_section = params_section.replace(lora_hashes_match.group(0), '')
|
params_section = params_section.replace(lora_hashes_match.group(0), '')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -362,6 +377,12 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
# Only process lora or hypernet types
|
# Only process lora or hypernet types
|
||||||
if not hash_key.startswith(("lora:", "hypernet:")):
|
if not hash_key.startswith(("lora:", "hypernet:")):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Skip entries without a hash value — they can't be
|
||||||
|
# resolved via CivitAI and would only produce a
|
||||||
|
# useless "Deleted" entry in the recipe.
|
||||||
|
if not lora_hash:
|
||||||
|
continue
|
||||||
|
|
||||||
lora_type, lora_name = hash_key.split(':', 1)
|
lora_type, lora_name = hash_key.split(':', 1)
|
||||||
|
|
||||||
@@ -387,11 +408,7 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
# Try to get info from Civitai
|
# Try to get info from Civitai
|
||||||
if metadata_provider:
|
if metadata_provider:
|
||||||
try:
|
try:
|
||||||
if lora_hash:
|
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
||||||
# If we have hash, use it for lookup
|
|
||||||
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
|
||||||
else:
|
|
||||||
civitai_info = None
|
|
||||||
|
|
||||||
populated_entry = await self.populate_lora_from_civitai(
|
populated_entry = await self.populate_lora_from_civitai(
|
||||||
lora_entry,
|
lora_entry,
|
||||||
|
|||||||
@@ -724,6 +724,16 @@ class ModelUpdateService:
|
|||||||
"Refreshing update metadata for %d %s models", total_models, model_type
|
"Refreshing update metadata for %d %s models", total_models, model_type
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# When filtering by folder, also collect the cross-folder version set
|
||||||
|
# so that versions already present in other folders are not reported
|
||||||
|
# as available updates. See issue #997.
|
||||||
|
all_local_versions: Optional[Dict[int, List[int]]] = None
|
||||||
|
if folder_path is not None:
|
||||||
|
all_local_versions = await self._collect_local_versions(
|
||||||
|
scanner,
|
||||||
|
target_model_ids=target_filter,
|
||||||
|
)
|
||||||
|
|
||||||
results: Dict[int, ModelUpdateRecord] = {}
|
results: Dict[int, ModelUpdateRecord] = {}
|
||||||
prefetched: Dict[int, Mapping] = {}
|
prefetched: Dict[int, Mapping] = {}
|
||||||
|
|
||||||
@@ -762,6 +772,12 @@ class ModelUpdateService:
|
|||||||
for index, (model_id, version_ids) in enumerate(
|
for index, (model_id, version_ids) in enumerate(
|
||||||
local_versions.items(), start=1
|
local_versions.items(), start=1
|
||||||
):
|
):
|
||||||
|
# Use cross-folder version IDs for is_in_library if available
|
||||||
|
all_vids: Sequence[int] = (
|
||||||
|
all_local_versions.get(model_id, [])
|
||||||
|
if all_local_versions is not None
|
||||||
|
else version_ids
|
||||||
|
)
|
||||||
record = await self._refresh_single_model(
|
record = await self._refresh_single_model(
|
||||||
model_type,
|
model_type,
|
||||||
model_id,
|
model_id,
|
||||||
@@ -769,6 +785,7 @@ class ModelUpdateService:
|
|||||||
metadata_provider,
|
metadata_provider,
|
||||||
force_refresh=force_refresh,
|
force_refresh=force_refresh,
|
||||||
prefetched_response=prefetched.get(model_id),
|
prefetched_response=prefetched.get(model_id),
|
||||||
|
all_local_version_ids=all_vids,
|
||||||
)
|
)
|
||||||
if scanner.is_cancelled():
|
if scanner.is_cancelled():
|
||||||
logger.info(f"{model_type.capitalize()} Update Service: Refresh cancelled by user")
|
logger.info(f"{model_type.capitalize()} Update Service: Refresh cancelled by user")
|
||||||
@@ -964,8 +981,16 @@ class ModelUpdateService:
|
|||||||
*,
|
*,
|
||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
prefetched_response: Optional[Mapping] = None,
|
prefetched_response: Optional[Mapping] = None,
|
||||||
|
all_local_version_ids: Optional[Sequence[int]] = None,
|
||||||
) -> Optional[ModelUpdateRecord]:
|
) -> Optional[ModelUpdateRecord]:
|
||||||
normalized_local = self._normalize_sequence(local_versions)
|
normalized_local = self._normalize_sequence(local_versions)
|
||||||
|
# When folder-filtering, this carries the cross-folder version set
|
||||||
|
# for is_in_library; otherwise it falls back to normalized_local.
|
||||||
|
normalized_all = (
|
||||||
|
self._normalize_sequence(all_local_version_ids)
|
||||||
|
if all_local_version_ids is not None
|
||||||
|
else normalized_local
|
||||||
|
)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
existing = self._get_record(model_type, model_id)
|
existing = self._get_record(model_type, model_id)
|
||||||
@@ -973,6 +998,7 @@ class ModelUpdateService:
|
|||||||
record = self._merge_with_local_versions(
|
record = self._merge_with_local_versions(
|
||||||
existing,
|
existing,
|
||||||
normalized_local,
|
normalized_local,
|
||||||
|
all_local_version_ids=normalized_all,
|
||||||
)
|
)
|
||||||
self._upsert_record(record)
|
self._upsert_record(record)
|
||||||
return record
|
return record
|
||||||
@@ -1048,6 +1074,7 @@ class ModelUpdateService:
|
|||||||
record = self._merge_with_local_versions(
|
record = self._merge_with_local_versions(
|
||||||
existing,
|
existing,
|
||||||
normalized_local,
|
normalized_local,
|
||||||
|
all_local_version_ids=normalized_all,
|
||||||
)
|
)
|
||||||
self._upsert_record(record)
|
self._upsert_record(record)
|
||||||
return record
|
return record
|
||||||
@@ -1059,6 +1086,7 @@ class ModelUpdateService:
|
|||||||
model_type=model_type,
|
model_type=model_type,
|
||||||
model_id=model_id,
|
model_id=model_id,
|
||||||
last_checked_at=now,
|
last_checked_at=now,
|
||||||
|
all_local_version_ids=normalized_all,
|
||||||
)
|
)
|
||||||
record = replace(record, should_ignore_model=True)
|
record = replace(record, should_ignore_model=True)
|
||||||
self._upsert_record(record)
|
self._upsert_record(record)
|
||||||
@@ -1077,6 +1105,7 @@ class ModelUpdateService:
|
|||||||
fetched_versions,
|
fetched_versions,
|
||||||
existing,
|
existing,
|
||||||
now,
|
now,
|
||||||
|
all_local_version_ids=normalized_all,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
record = self._merge_with_local_versions(
|
record = self._merge_with_local_versions(
|
||||||
@@ -1085,6 +1114,7 @@ class ModelUpdateService:
|
|||||||
model_type=model_type,
|
model_type=model_type,
|
||||||
model_id=model_id,
|
model_id=model_id,
|
||||||
last_checked_at=existing.last_checked_at if existing else None,
|
last_checked_at=existing.last_checked_at if existing else None,
|
||||||
|
all_local_version_ids=normalized_all,
|
||||||
)
|
)
|
||||||
self._upsert_record(record)
|
self._upsert_record(record)
|
||||||
return record
|
return record
|
||||||
@@ -1322,12 +1352,20 @@ class ModelUpdateService:
|
|||||||
existing: Optional[ModelUpdateRecord],
|
existing: Optional[ModelUpdateRecord],
|
||||||
normalized_local: Sequence[int],
|
normalized_local: Sequence[int],
|
||||||
*,
|
*,
|
||||||
|
all_local_version_ids: Optional[Sequence[int]] = None,
|
||||||
model_type: Optional[str] = None,
|
model_type: Optional[str] = None,
|
||||||
model_id: Optional[int] = None,
|
model_id: Optional[int] = None,
|
||||||
last_checked_at: Optional[float] = None,
|
last_checked_at: Optional[float] = None,
|
||||||
version_info: Optional[Mapping] = None,
|
version_info: Optional[Mapping] = None,
|
||||||
) -> ModelUpdateRecord:
|
) -> ModelUpdateRecord:
|
||||||
local_set = set(normalized_local)
|
local_set = set(normalized_local)
|
||||||
|
# When folder-filtering, also consider versions in other folders
|
||||||
|
# as in-library so they are not reported as available updates.
|
||||||
|
effective_local_set: set[int] = (
|
||||||
|
local_set | set(all_local_version_ids)
|
||||||
|
if all_local_version_ids is not None
|
||||||
|
else local_set
|
||||||
|
)
|
||||||
versions: List[ModelVersionRecord] = []
|
versions: List[ModelVersionRecord] = []
|
||||||
ignore_map: Dict[int, bool] = {}
|
ignore_map: Dict[int, bool] = {}
|
||||||
if existing:
|
if existing:
|
||||||
@@ -1339,7 +1377,7 @@ class ModelUpdateService:
|
|||||||
versions.append(
|
versions.append(
|
||||||
replace(
|
replace(
|
||||||
version,
|
version,
|
||||||
is_in_library=version.version_id in local_set,
|
is_in_library=version.version_id in effective_local_set,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
elif model_type is None or model_id is None:
|
elif model_type is None or model_id is None:
|
||||||
@@ -1386,8 +1424,17 @@ class ModelUpdateService:
|
|||||||
remote_versions: Sequence[ModelVersionRecord],
|
remote_versions: Sequence[ModelVersionRecord],
|
||||||
existing: Optional[ModelUpdateRecord],
|
existing: Optional[ModelUpdateRecord],
|
||||||
timestamp: float,
|
timestamp: float,
|
||||||
|
*,
|
||||||
|
all_local_version_ids: Optional[Sequence[int]] = None,
|
||||||
) -> ModelUpdateRecord:
|
) -> ModelUpdateRecord:
|
||||||
local_set = set(local_versions)
|
local_set = set(local_versions)
|
||||||
|
# When folder-filtering, also consider versions in other folders
|
||||||
|
# as in-library so they are not reported as available updates.
|
||||||
|
effective_local_set: set[int] = (
|
||||||
|
local_set | set(all_local_version_ids)
|
||||||
|
if all_local_version_ids is not None
|
||||||
|
else local_set
|
||||||
|
)
|
||||||
ignore_map = {version.version_id: version.should_ignore for version in existing.versions} if existing else {}
|
ignore_map = {version.version_id: version.should_ignore for version in existing.versions} if existing else {}
|
||||||
preview_map = {version.version_id: version.preview_url for version in existing.versions} if existing else {}
|
preview_map = {version.version_id: version.preview_url for version in existing.versions} if existing else {}
|
||||||
sort_map = {version.version_id: version.sort_index for version in existing.versions} if existing else {}
|
sort_map = {version.version_id: version.sort_index for version in existing.versions} if existing else {}
|
||||||
@@ -1406,7 +1453,7 @@ class ModelUpdateService:
|
|||||||
released_at=remote_version.released_at,
|
released_at=remote_version.released_at,
|
||||||
size_bytes=remote_version.size_bytes,
|
size_bytes=remote_version.size_bytes,
|
||||||
preview_url=remote_version.preview_url or preview_map.get(version_id),
|
preview_url=remote_version.preview_url or preview_map.get(version_id),
|
||||||
is_in_library=version_id in local_set,
|
is_in_library=version_id in effective_local_set,
|
||||||
should_ignore=ignore_map.get(version_id, remote_version.should_ignore),
|
should_ignore=ignore_map.get(version_id, remote_version.should_ignore),
|
||||||
sort_index=sort_map.get(version_id, index),
|
sort_index=sort_map.get(version_id, index),
|
||||||
early_access_ends_at=remote_version.early_access_ends_at,
|
early_access_ends_at=remote_version.early_access_ends_at,
|
||||||
|
|||||||
@@ -316,7 +316,12 @@ export class PageControls {
|
|||||||
* Load sort preference from storage
|
* Load sort preference from storage
|
||||||
*/
|
*/
|
||||||
loadSortPreference() {
|
loadSortPreference() {
|
||||||
const savedSort = getStorageItem(`${this.pageType}_sort`);
|
// Use separate keys for grouped vs non-grouped sort so each mode
|
||||||
|
// remembers its own preference independently
|
||||||
|
const key = state.global.settings.group_by_model
|
||||||
|
? `${this.pageType}_sort_grouped`
|
||||||
|
: `${this.pageType}_sort`;
|
||||||
|
const savedSort = getStorageItem(key);
|
||||||
if (savedSort) {
|
if (savedSort) {
|
||||||
// Handle legacy format conversion
|
// Handle legacy format conversion
|
||||||
const convertedSort = this.convertLegacySortFormat(savedSort);
|
const convertedSort = this.convertLegacySortFormat(savedSort);
|
||||||
@@ -360,7 +365,11 @@ export class PageControls {
|
|||||||
};
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStorageItem(`${this.pageType}_sort`, sortValue);
|
// Separate storage for grouped vs non-grouped sort
|
||||||
|
const key = state.global.settings.group_by_model
|
||||||
|
? `${this.pageType}_sort_grouped`
|
||||||
|
: `${this.pageType}_sort`;
|
||||||
|
setStorageItem(key, sortValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -555,37 +564,28 @@ export class PageControls {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when group_by_model is toggled.
|
* Called when group_by_model is toggled.
|
||||||
* Saves current sort when entering grouped mode, restores normal sort
|
* Swaps between {pageType}_sort (non-group) and {pageType}_sort_grouped,
|
||||||
* when leaving — prevents "Most versions first" persisting after exit.
|
* so each mode remembers its own sort preference independently.
|
||||||
*/
|
*/
|
||||||
onGroupByModelToggled(isEnabled) {
|
onGroupByModelToggled(isEnabled) {
|
||||||
const normalKey = `${this.pageType}_sort_normal`;
|
|
||||||
const groupedKey = `${this.pageType}_sort_grouped`;
|
const groupedKey = `${this.pageType}_sort_grouped`;
|
||||||
|
|
||||||
if (isEnabled) {
|
if (isEnabled) {
|
||||||
// Entering group mode: save current sort for later restoration
|
// Entering group mode: restore last-used grouped sort, if any
|
||||||
setStorageItem(normalKey, this.pageState.sortBy);
|
|
||||||
// Restore previously saved grouped sort, if any
|
|
||||||
const savedGroupedSort = getStorageItem(groupedKey);
|
const savedGroupedSort = getStorageItem(groupedKey);
|
||||||
if (savedGroupedSort) {
|
if (savedGroupedSort) {
|
||||||
this.pageState.sortBy = savedGroupedSort;
|
this.pageState.sortBy = savedGroupedSort;
|
||||||
this.saveSortPreference(savedGroupedSort);
|
|
||||||
const sortSelect = document.getElementById('sortSelect');
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
if (sortSelect) {
|
if (sortSelect) {
|
||||||
sortSelect.value = savedGroupedSort;
|
sortSelect.value = savedGroupedSort;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Leaving group mode: save current grouped sort aside, restore normal
|
// Leaving group mode: persist current sort for next time, restore non-group sort
|
||||||
const currentSort = this.pageState.sortBy;
|
setStorageItem(groupedKey, this.pageState.sortBy);
|
||||||
if (currentSort && currentSort.startsWith('versions_count')) {
|
const savedNormalSort = getStorageItem(`${this.pageType}_sort`);
|
||||||
setStorageItem(groupedKey, currentSort);
|
|
||||||
}
|
|
||||||
const savedNormalSort = getStorageItem(normalKey);
|
|
||||||
if (savedNormalSort) {
|
if (savedNormalSort) {
|
||||||
removeStorageItem(normalKey);
|
|
||||||
this.pageState.sortBy = savedNormalSort;
|
this.pageState.sortBy = savedNormalSort;
|
||||||
this.saveSortPreference(savedNormalSort);
|
|
||||||
const sortSelect = document.getElementById('sortSelect');
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
if (sortSelect) {
|
if (sortSelect) {
|
||||||
sortSelect.value = savedNormalSort;
|
sortSelect.value = savedNormalSort;
|
||||||
|
|||||||
@@ -230,8 +230,12 @@ export function initSortDropdown(select) {
|
|||||||
// Close dropdown when clicking outside
|
// Close dropdown when clicking outside
|
||||||
document.addEventListener('click', (event) => {
|
document.addEventListener('click', (event) => {
|
||||||
if (!group.contains(event.target)) {
|
if (!group.contains(event.target)) {
|
||||||
|
const wasOpen = group.classList.contains('active');
|
||||||
close();
|
close();
|
||||||
trigger.focus();
|
// Only return focus to the trigger when the dropdown was actually
|
||||||
|
// open — avoids forcing scrollIntoView on every page click (which
|
||||||
|
// causes the scroll container to jump when clicking a model card).
|
||||||
|
if (wasOpen) trigger.focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ImportManager } from './managers/ImportManager.js';
|
|||||||
import { BatchImportManager } from './managers/BatchImportManager.js';
|
import { BatchImportManager } from './managers/BatchImportManager.js';
|
||||||
import { RecipeModal } from './components/RecipeModal.js';
|
import { RecipeModal } from './components/RecipeModal.js';
|
||||||
import { state, getCurrentPageState } from './state/index.js';
|
import { state, getCurrentPageState } from './state/index.js';
|
||||||
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
import { getStorageItem, setStorageItem, getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
||||||
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
||||||
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
||||||
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
||||||
@@ -237,13 +237,18 @@ class RecipeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initEventListeners() {
|
initEventListeners() {
|
||||||
// Sort select
|
// Sort select — load saved preference, persist on change
|
||||||
const sortSelect = document.getElementById('sortSelect');
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
if (sortSelect) {
|
if (sortSelect) {
|
||||||
|
const savedSort = getStorageItem('recipes_sort');
|
||||||
|
if (savedSort) {
|
||||||
|
this.pageState.sortBy = savedSort;
|
||||||
|
}
|
||||||
initSortDropdown(sortSelect);
|
initSortDropdown(sortSelect);
|
||||||
sortSelect.value = this.pageState.sortBy || 'date:desc';
|
sortSelect.value = this.pageState.sortBy || 'date:desc';
|
||||||
sortSelect.addEventListener('change', () => {
|
sortSelect.addEventListener('change', () => {
|
||||||
this.pageState.sortBy = sortSelect.value;
|
this.pageState.sortBy = sortSelect.value;
|
||||||
|
setStorageItem('recipes_sort', sortSelect.value);
|
||||||
refreshVirtualScroll();
|
refreshVirtualScroll();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,74 @@ async def test_parse_metadata_extracts_checkpoint_from_civitai_resources(monkeyp
|
|||||||
assert result["loras"] == []
|
assert result["loras"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_metadata_merges_lora_hashes_over_empty_hashes_json(monkeypatch):
|
||||||
|
"""When Hashes JSON has empty lora hashes but Lora hashes text field has
|
||||||
|
real ones, the real hashes should be used and those LoRAs resolved
|
||||||
|
correctly; entries with empty hashes in both sources should be skipped."""
|
||||||
|
lora_version_info = {
|
||||||
|
"id": 947620,
|
||||||
|
"modelId": 98765,
|
||||||
|
"model": {"name": "cfg_scale_boost", "type": "LORA"},
|
||||||
|
"name": "v1",
|
||||||
|
"images": [{"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/original=true"}],
|
||||||
|
"baseModel": "illustrious",
|
||||||
|
"downloadUrl": "https://civitai.com/api/download/models/947620",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"type": "Model",
|
||||||
|
"primary": True,
|
||||||
|
"sizeKB": 1024,
|
||||||
|
"name": "cfg_scale_boost.safetensors",
|
||||||
|
"hashes": {"SHA256": "4605b2de07"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_metadata_provider():
|
||||||
|
class Provider:
|
||||||
|
async def get_model_by_hash(self, model_hash):
|
||||||
|
assert model_hash == "4605b2de07"
|
||||||
|
return lora_version_info, None
|
||||||
|
|
||||||
|
async def get_model_version_info(self, version_id):
|
||||||
|
raise AssertionError("get_model_version_info should not be called")
|
||||||
|
|
||||||
|
return Provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.parsers.automatic.get_default_metadata_provider",
|
||||||
|
fake_metadata_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
parser = AutomaticMetadataParser()
|
||||||
|
|
||||||
|
metadata_text = (
|
||||||
|
"a cyberpunk portrait <lora:cfg_scale_boost:0.6>\n"
|
||||||
|
"Negative prompt: low quality\n"
|
||||||
|
"Steps: 20, Sampler: Euler a, CFG scale: 7, Seed: 123456, Size: 512x768, "
|
||||||
|
"Model hash: abc123, Model: test.safetensors, "
|
||||||
|
'Lora hashes: "cfg_scale_boost: 4605b2de07, EmptyLora: ", '
|
||||||
|
'Hashes: {"model": "abc123", "lora:cfg_scale_boost": "", "lora:EmptyLora": "", "lora:UnusedLora": ""}'
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await parser.parse_metadata(metadata_text)
|
||||||
|
|
||||||
|
# cfg_scale_boost should be resolved (hash from Lora hashes overrode empty Hashes JSON)
|
||||||
|
loras = result.get("loras", [])
|
||||||
|
assert len(loras) == 1, f"Expected 1 LoRA, got {len(loras)}"
|
||||||
|
lora = loras[0]
|
||||||
|
assert lora["name"] == "cfg_scale_boost", f"Expected cfg_scale_boost, got {lora['name']}"
|
||||||
|
assert lora["hash"] == "4605b2de07", f"Expected hash 4605b2de07, got {lora['hash']}"
|
||||||
|
assert lora.get("isDeleted") in (None, False), f"LoRA should not be deleted"
|
||||||
|
assert lora["weight"] == 0.6, f"Expected weight 0.6, got {lora['weight']}"
|
||||||
|
|
||||||
|
# EmptyLora and UnusedLora should be skipped (no hash in either source)
|
||||||
|
lora_names = [l["name"] for l in loras]
|
||||||
|
assert "EmptyLora" not in lora_names, "EmptyLora should have been skipped"
|
||||||
|
assert "UnusedLora" not in lora_names, "UnusedLora should have been skipped"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_parse_metadata_extracts_checkpoint_from_model_hash(monkeypatch):
|
async def test_parse_metadata_extracts_checkpoint_from_model_hash(monkeypatch):
|
||||||
checkpoint_info = {
|
checkpoint_info = {
|
||||||
|
|||||||
@@ -579,3 +579,45 @@ async def test_update_in_library_versions_populates_metadata(tmp_path):
|
|||||||
assert version.preview_url == "https://example.com/preview.png"
|
assert version.preview_url == "https://example.com/preview.png"
|
||||||
assert version.is_in_library is True
|
assert version.is_in_library is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_folder_filter_considers_cross_folder_versions(tmp_path):
|
||||||
|
"""When refreshing by folder, versions in other folders must still be
|
||||||
|
considered in-library so they aren't reported as available updates."""
|
||||||
|
db_path = tmp_path / "updates.sqlite"
|
||||||
|
service = ModelUpdateService(str(db_path), ttl_seconds=0)
|
||||||
|
# Same model (modelId=1) in two folders with different versions
|
||||||
|
raw_data = [
|
||||||
|
{"civitai": {"modelId": 1, "id": 11}, "folder": "folder_a"},
|
||||||
|
{"civitai": {"modelId": 1, "id": 15}, "folder": "folder_b"},
|
||||||
|
]
|
||||||
|
scanner = DummyScanner(raw_data)
|
||||||
|
# Remote offers: 11 (in folder_a), 15 (in folder_b), 20 (truly new)
|
||||||
|
provider = DummyProvider(
|
||||||
|
{
|
||||||
|
"modelVersions": [
|
||||||
|
{"id": 11, "files": [], "images": []},
|
||||||
|
{"id": 15, "files": [], "images": []},
|
||||||
|
{"id": 20, "files": [], "images": []},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.refresh_for_model_type(
|
||||||
|
"lora", scanner, provider, folder_path="folder_a",
|
||||||
|
)
|
||||||
|
record = await service.get_record("lora", 1)
|
||||||
|
|
||||||
|
assert record is not None
|
||||||
|
|
||||||
|
# Version 15 is in folder_b — must be in_library even when filtering by folder_a
|
||||||
|
v15 = next(v for v in record.versions if v.version_id == 15)
|
||||||
|
assert v15.is_in_library is True
|
||||||
|
|
||||||
|
# Version 20 is truly new — should not be in_library
|
||||||
|
v20 = next(v for v in record.versions if v.version_id == 20)
|
||||||
|
assert v20.is_in_library is False
|
||||||
|
|
||||||
|
# has_update must be True (version 20 > max_in_library=15)
|
||||||
|
assert record.has_update() is True
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user