From 097be623f497712d1e0583080f2a74c931c3855e Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Mon, 20 Jul 2026 16:16:09 +0200 Subject: [PATCH] feat: preserve CivArchive page URLs separate from HuggingFace mirrors, fix HTML array escaping, and accept provider array payloads Backend: - Keep CivArchive source page URL distinct from HuggingFace download mirrors in download metadata - Handle CivitAI hash lookup HTTP errors and unexpected JSON shapes without caching failures - Allow network JSON requests to return arrays as well as objects Frontend: - Escape each item individually when interpolating arrays into HTML templates - Prefer the selected provider's page URL over cross-provider mirrors in context menus and download targets - Reuse source link resolution for model info display Tests: - Add coverage for CivArchive metadata URL isolation, HTML array escaping, provider array responses, and CivitAI hash lookup caching behavior --- core/downloader.py | 23 +++++ core/network_utils.py | 12 ++- core/sources/civitai.py | 18 +++- tests/test_downloader_metadata.py | 33 +++++++ tests/test_downloads_tab_workflow_route.mjs | 95 ++++++++++++++++++- tests/test_source_internals.py | 49 ++++++++++ tests/test_unified_helpers.py | 26 +++++ .../search/download_target_methods.js | 40 +++++++- web/resolver/utils/html_utils.js | 4 +- web/resolver/views/model_info_methods.js | 30 ++++-- 10 files changed, 311 insertions(+), 19 deletions(-) diff --git a/core/downloader.py b/core/downloader.py index 6c61f1a..016f670 100644 --- a/core/downloader.py +++ b/core/downloader.py @@ -550,6 +550,29 @@ def build_lora_manager_metadata( source.get("platform_url"), source_url, ) + if source_name == "civarchive": + civarchive_page_url = next( + ( + str(value).strip() + for value in ( + source.get("version_url"), + source.get("model_url"), + source.get("page_url"), + source.get("source_url"), + source.get("url"), + ) + if value + and host_matches_domain(urlparse(str(value)).hostname, "civarchive.com") + ), + "", + ) + if not civarchive_page_url and model_id: + civarchive_page_url = f"https://civarchive.com/models/{model_id}" + if version_id: + civarchive_page_url += f"?modelVersionId={version_id}" + if not civarchive_page_url and sha256: + civarchive_page_url = f"https://civarchive.com/sha256/{sha256}" + source_page_url = civarchive_page_url platform_url = _first_present(source.get("platform_url"), source.get("platformUrl")) preview_url = _first_present( source.get("preview_url"), diff --git a/core/network_utils.py b/core/network_utils.py index e72aa4d..ffd8d71 100644 --- a/core/network_utils.py +++ b/core/network_utils.py @@ -250,8 +250,8 @@ def request_source_json( max_attempts: int = 2, log_name: str = "Source API", raise_on_error: bool = False, -) -> Optional[Dict[str, Any]]: - """Perform a GET/POST request and return JSON with retry logic for HTTP 429.""" +) -> Any: + """Perform a GET/POST request and return any parsed JSON payload or ``None``.""" response = request_source_response( url, method=method, @@ -311,10 +311,12 @@ def execute_provider_json_request( timeout: int = 20, max_attempts: int = 2, raise_on_error: bool = False, -) -> Optional[Dict[str, Any]]: +) -> Any: """ - Execute an outbound JSON request to a provider (CivitAI, CivArchive, HF, etc.) - with normalized user-agent, authorization token/session headers, and 429 retry handling. + Execute an outbound JSON request and return any parsed JSON payload. + + Provider APIs may return either objects or arrays. Callers that require a + specific shape must validate the returned value before using it. """ from .type_utils import DEFAULT_BROWSER_USER_AGENT diff --git a/core/sources/civitai.py b/core/sources/civitai.py index fbe3ea6..2038a8d 100644 --- a/core/sources/civitai.py +++ b/core/sources/civitai.py @@ -1390,9 +1390,10 @@ def get_model_info_by_hash( api_url, api_key=api_key, timeout=15, + raise_on_error=True, ) - if data: + if isinstance(data, dict) and data: # Extract useful info from the response model_info = data.get("model", {}) version_info = data @@ -1435,10 +1436,21 @@ def get_model_info_by_hash( log.info(f"Found model by hash {file_hash}: {result.get('model_name')}") return result - else: - log.info(f"Model not found or lookup failed on CivitAI for hash {file_hash}") + log.warning( + f"CivitAI hash lookup returned an unexpected JSON payload for {file_hash}" + ) + return None + + except requests.HTTPError as e: + status_code = getattr(e.response, "status_code", None) + if status_code == 404: + log.info(f"Model not found on CivitAI for hash {file_hash}") _hash_cache[cache_key] = None return None + log.warning( + f"CivitAI hash lookup returned HTTP {status_code or 'error'} for {file_hash}" + ) + return None except Exception as e: log.error(f"Error looking up model by hash {file_hash}: {e}") diff --git a/tests/test_downloader_metadata.py b/tests/test_downloader_metadata.py index 36c8000..498da62 100644 --- a/tests/test_downloader_metadata.py +++ b/tests/test_downloader_metadata.py @@ -16,6 +16,39 @@ from core.downloader import ( class DownloaderMetadataSidecarTests(unittest.TestCase): + def test_civarchive_metadata_keeps_provider_page_separate_from_hf_mirror(self): + with tempfile.TemporaryDirectory() as tmpdir: + model_path = os.path.join(tmpdir, "model.safetensors") + with open(model_path, "wb") as handle: + handle.write(b"abc") + + payload = build_lora_manager_metadata( + model_path, + { + "source": "civarchive", + "details_source": "civarchive", + "model_id": 123, + "version_id": 456, + "version_url": ( + "https://huggingface.co/author/repo/blob/main/model.safetensors" + ), + "url": "https://civarchive.com/models/123?modelVersionId=456", + "download_url": ( + "https://huggingface.co/author/repo/resolve/main/model.safetensors" + ), + "sha256": "a" * 64, + }, + category="checkpoints", + ) + + expected_page = "https://civarchive.com/models/123?modelVersionId=456" + self.assertEqual(expected_page, payload["source_url"]) + self.assertEqual(expected_page, payload["version_url"]) + self.assertEqual( + "https://huggingface.co/author/repo/resolve/main/model.safetensors", + payload["download_url"], + ) + def test_huggingface_metadata_does_not_create_civitai_identity(self): with tempfile.TemporaryDirectory() as tmpdir: model_path = os.path.join(tmpdir, "model.safetensors") diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index df91184..b4c4ea9 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -2,7 +2,8 @@ import fs from 'node:fs'; import path from 'node:path'; import test from 'node:test'; import assert from 'node:assert/strict'; -import { normalizePathIdentity } from '../web/resolver/utils/html_utils.js'; +import { html, normalizePathIdentity } from '../web/resolver/utils/html_utils.js'; +import { getModelCardUrl } from '../web/resolver/utils/url_utils.js'; const projectRoot = path.resolve(import.meta.dirname, '..'); const queueMethodsSource = fs.readFileSync( @@ -38,6 +39,15 @@ const renderFormatMethodsSource = fs.readFileSync( 'utf8' ); +test('html template escapes every item in interpolated arrays', () => { + const rendered = html`
${['