mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
d572292142
A ModelScope or Hugging Face download landed as a bare filename, hash and
source link; the model card stayed empty until the user ran "Enrich
Metadata with AI" by hand. But everything that makes a CivitAI download
useful — the display name, the description, the tags, the trigger words,
the example images, the preview — is already published by those sites'
public APIs, so asking for it at download time is deterministic work, not
model work.
Add `py/services/model_sources/hydration.py`, called by
`_save_source_metadata()` once the sidecar exists and the file is in the
scanner cache. It fetches the model card plus the site's card extras and
hands them to the same `PostProcessor` the AI skill uses, with an empty
`llm_output`, so the two paths cannot drift apart. What lands:
* `model_name` from the site's own display name (ModelScope's `Name`), so
the card stops showing the local filename — written only while the value
still equals the file stem, since once a user renames a model that
choice is theirs to keep
* `civitai.name` from the matched version's label (`showName`), which the
card renders as the version chip
* `civitai.description` / `modelDescription` from the author summary plus
the README as HTML
* `civitai.images` / `preview_url` from the per-file example images
* `civitai.trainedWords` from the per-file trigger words
* `base_model`, `tags` and `usage_tips` as before
Provenance stays honest: the pass records
`metadata_source = "source:<platform>"` rather than the skill's
`agent:enrich_hf_metadata`, and — because no provider ran — it no longer
stamps `llm_enriched_at`; that stamp is now conditional on the LLM
actually answering, which is what the field means. The five hand-rolled
`civitai` dict merges in the post-processor collapse into one
`_merge_civitai()` helper.
Two guards keep it safe. Only a model whose stored
`source_platform`/`source_url` match the repository being downloaded is
updated, so a local file that merely shares a name never receives another
model's card; and a file already on disk is topped up too, which
back-fills models downloaded before this existed. READMEs and detail
payloads describe the repository rather than the file, so a short-lived
process-wide `ModelSourceCache` (300 s, 32 entries) keeps a batch over one
repository to two HTTP requests. Every failure is logged and swallowed:
hydration can never fail a download.
Fix the hash policy while here. `_save_source_metadata()` went straight to
`MetadataManager.create_default_metadata()`, bypassing the per-type
factory on the owning scanner, so a checkpoint paid a full SHA256 inside
the download request — `CheckpointScanner`/`OtherScanner` deliberately
record `hash_status="pending"` with an empty `sha256` for their multi-GB
files. Metadata is now created through `scanner._create_default_metadata()`.
Hydration copes with the empty hash: `_matching_versions()` falls back to
the repository basename, which is exactly what the download just wrote.
Report both post-transfer stages, which advance no byte counter and so
read as a stall: the bar sat at 100% showing `0 B/s` for the seconds spent
hashing and fetching. `_report_phase()` broadcasts
`{"status": "metadata", "stage": "indexing" | "source", "platform": ...}`,
and `LoadingManager` names the stage in the status line (keeping the batch
position), retitles the item line, replaces the dead speed figure and runs
a sheen over the bar. `stage`/`platform` are machine-readable; the wording
is localised in the frontend.
Finally, `modelscope.ai` is its own catalogue rather than an alias of
`modelscope.cn` — `referall13/EM1` exists only on `.ai` and
`jj3550945163/Krea-2-LORA` only on `.cn` — so its URLs were rejected with
"Invalid model URL format". Register it as `ModelScopeIntlSource`
(`platform="modelscope-ai"`, `msai:` group prefix, its own default
download directory) and derive every URL either deployment builds from a
per-class `base_url`. `modelscope.com` stays an alias of `.cn`, which is
what it redirects to. The frontend source table, the link dialog hints and
the docs mirror the split.
Verified against the live APIs: both reported `.ai` repositories list
their files, read their READMEs and yield name / version / base model /
trigger words / example images. Backend 3092 passed; frontend 1259 JS +
91 Vue passed. The nine locales carry the new progress copy in the next
commit.
229 lines
7.1 KiB
Python
229 lines
7.1 KiB
Python
"""Registry and metadata helpers for external model sources.
|
|
|
|
The registry is the single place the rest of the codebase asks "which site
|
|
is this URL from?", "what is this model's source?", and "can we enrich it?".
|
|
Import from :mod:`py.services.model_sources` rather than this module
|
|
directly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any, Dict, Mapping, Optional
|
|
|
|
from .base import GROUP_PREFIXES, ModelSource, SourceRef, clean_source_url
|
|
from .huggingface import HuggingFaceSource
|
|
from .modelscope import ModelScopeIntlSource, ModelScopeSource
|
|
from .tensorart import TensorArtSource
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
#: Order matters only for disambiguation; the URL patterns are disjoint.
|
|
#: ``modelscope.ai`` is a separate catalogue from ``modelscope.cn`` rather than
|
|
#: an alias, which is why it gets its own entry (see ``modelscope.py``).
|
|
_SOURCES: tuple[ModelSource, ...] = (
|
|
HuggingFaceSource(),
|
|
ModelScopeSource(),
|
|
ModelScopeIntlSource(),
|
|
TensorArtSource(),
|
|
)
|
|
|
|
_BY_PLATFORM: Dict[str, ModelSource] = {s.platform: s for s in _SOURCES}
|
|
|
|
#: Metadata keys that carry the canonical external-source identity.
|
|
SOURCE_PLATFORM_FIELD = "source_platform"
|
|
SOURCE_URL_FIELD = "source_url"
|
|
#: Legacy field kept as a read/write alias for Hugging Face models so that
|
|
#: older sidecars, cached rows, and third-party consumers keep working.
|
|
LEGACY_HF_URL_FIELD = "hf_url"
|
|
|
|
|
|
def list_sources() -> list[ModelSource]:
|
|
"""Return every known model source."""
|
|
|
|
return list(_SOURCES)
|
|
|
|
|
|
def get_source(platform: Optional[str]) -> Optional[ModelSource]:
|
|
"""Return the source registered for *platform*, or ``None``."""
|
|
|
|
if not platform or not isinstance(platform, str):
|
|
return None
|
|
return _BY_PLATFORM.get(platform.strip().lower())
|
|
|
|
|
|
def source_label(platform: Optional[str], default: str = "") -> str:
|
|
"""Return the human-readable label for *platform*."""
|
|
|
|
source = get_source(platform)
|
|
return source.label if source else default
|
|
|
|
|
|
def downloadable_sources() -> list[ModelSource]:
|
|
"""Return the sources whose repositories can be downloaded directly."""
|
|
|
|
return [source for source in _SOURCES if source.supports_download]
|
|
|
|
|
|
def get_download_source(platform: Optional[str]) -> Optional[ModelSource]:
|
|
"""Return the source for *platform*, but only when it supports downloads."""
|
|
|
|
source = get_source(platform)
|
|
if source is None or not source.supports_download:
|
|
return None
|
|
return source
|
|
|
|
|
|
def detect_source(url: Optional[str], *, strict: bool = False) -> Optional[SourceRef]:
|
|
"""Return the :class:`SourceRef` for *url*, or ``None`` if unsupported."""
|
|
|
|
if not url or not isinstance(url, str):
|
|
return None
|
|
for source in _SOURCES:
|
|
ref = source.ref(url, strict=strict)
|
|
if ref is not None:
|
|
return ref
|
|
return None
|
|
|
|
|
|
def resolve_source_ref(metadata: Mapping[str, Any]) -> Optional[SourceRef]:
|
|
"""Return the source reference described by a model's metadata.
|
|
|
|
Handles all three storage states found in the wild:
|
|
|
|
1. ``source_url`` + ``source_platform`` (current format)
|
|
2. ``hf_url`` only (legacy Hugging Face storage)
|
|
3. ``hf_url`` plus a newer ``source_url`` (both written by older builds)
|
|
"""
|
|
|
|
if not isinstance(metadata, Mapping):
|
|
return None
|
|
|
|
platform = clean_source_url(metadata.get(SOURCE_PLATFORM_FIELD)).lower()
|
|
url = clean_source_url(metadata.get(SOURCE_URL_FIELD))
|
|
legacy = clean_source_url(metadata.get(LEGACY_HF_URL_FIELD))
|
|
|
|
source = get_source(platform)
|
|
if url:
|
|
if source is not None:
|
|
ref = source.ref(url)
|
|
if ref is not None:
|
|
return ref
|
|
ref = detect_source(url)
|
|
if ref is not None:
|
|
return ref
|
|
# Unknown platform but a URL is present: keep it addressable.
|
|
return SourceRef(platform=platform or "unknown", source_id="", url=url)
|
|
|
|
if legacy:
|
|
return detect_source(legacy)
|
|
return None
|
|
|
|
|
|
def normalize_metadata_source(metadata: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Normalise the external-source fields on *metadata* in place.
|
|
|
|
Guarantees that ``source_url``/``source_platform`` are present and
|
|
consistent, and that ``hf_url`` mirrors ``source_url`` for Hugging Face
|
|
models (never for other platforms, so a stale alias can't make a
|
|
ModelScope model look like a Hugging Face one).
|
|
|
|
Returns the same dict for convenient chaining.
|
|
"""
|
|
|
|
if not isinstance(metadata, dict):
|
|
return metadata
|
|
|
|
platform = clean_source_url(metadata.get(SOURCE_PLATFORM_FIELD)).lower()
|
|
url = clean_source_url(metadata.get(SOURCE_URL_FIELD))
|
|
legacy = clean_source_url(metadata.get(LEGACY_HF_URL_FIELD))
|
|
|
|
source = get_source(platform)
|
|
ref: Optional[SourceRef] = None
|
|
|
|
if url:
|
|
ref = source.ref(url) if source is not None else None
|
|
if ref is None:
|
|
ref = detect_source(url)
|
|
elif legacy:
|
|
ref = detect_source(legacy)
|
|
|
|
if ref is not None and ref.source_id:
|
|
platform = ref.platform
|
|
url = ref.url or url
|
|
|
|
if platform:
|
|
metadata[SOURCE_PLATFORM_FIELD] = platform
|
|
else:
|
|
metadata.setdefault(SOURCE_PLATFORM_FIELD, "")
|
|
|
|
metadata[SOURCE_URL_FIELD] = url
|
|
|
|
# Keep the legacy alias in sync, but only for Hugging Face.
|
|
if url and platform == "huggingface":
|
|
metadata[LEGACY_HF_URL_FIELD] = url
|
|
elif LEGACY_HF_URL_FIELD in metadata and platform and platform != "huggingface":
|
|
metadata[LEGACY_HF_URL_FIELD] = ""
|
|
elif legacy and not url:
|
|
metadata[LEGACY_HF_URL_FIELD] = legacy
|
|
|
|
return metadata
|
|
|
|
|
|
def has_external_source(item: Mapping[str, Any]) -> bool:
|
|
"""Return ``True`` when *item* is linked to any external model site."""
|
|
|
|
if not isinstance(item, Mapping):
|
|
return False
|
|
return bool(
|
|
clean_source_url(item.get(SOURCE_URL_FIELD))
|
|
or clean_source_url(item.get(LEGACY_HF_URL_FIELD))
|
|
)
|
|
|
|
|
|
def get_source_platform(item: Mapping[str, Any]) -> str:
|
|
"""Return the platform id stored on *item* (may be empty)."""
|
|
|
|
if not isinstance(item, Mapping):
|
|
return ""
|
|
platform = clean_source_url(item.get(SOURCE_PLATFORM_FIELD)).lower()
|
|
if platform:
|
|
return platform
|
|
ref = resolve_source_ref(item)
|
|
return ref.platform if ref else ""
|
|
|
|
|
|
def source_group_key(item: Mapping[str, Any]) -> Optional[str]:
|
|
"""Return the version-group key for *item*, or ``None``.
|
|
|
|
Hugging Face keeps the historical ``hf:{owner}/{repo}`` shape; other
|
|
platforms use their own short prefix (see :data:`GROUP_PREFIXES`).
|
|
"""
|
|
|
|
ref = resolve_source_ref(item)
|
|
if ref is None or not ref.source_id:
|
|
return None
|
|
source = get_source(ref.platform)
|
|
if source is None:
|
|
return None
|
|
return source.group_key(ref.source_id)
|
|
|
|
|
|
__all__ = [
|
|
"GROUP_PREFIXES",
|
|
"LEGACY_HF_URL_FIELD",
|
|
"SOURCE_PLATFORM_FIELD",
|
|
"SOURCE_URL_FIELD",
|
|
"detect_source",
|
|
"downloadable_sources",
|
|
"get_download_source",
|
|
"get_source",
|
|
"get_source_platform",
|
|
"has_external_source",
|
|
"list_sources",
|
|
"normalize_metadata_source",
|
|
"resolve_source_ref",
|
|
"source_group_key",
|
|
"source_label",
|
|
]
|