diff --git a/py/services/civitai_client.py b/py/services/civitai_client.py index ed8a3c30..e64c4eeb 100644 --- a/py/services/civitai_client.py +++ b/py/services/civitai_client.py @@ -329,8 +329,19 @@ class CivitaiClient: "name": result.get("name", ""), } message = self._extract_error_message(result) - if message and "not found" in message.lower(): - raise ResourceNotFoundError(f"Resource not found for model {model_id}") + if message and ( + "not found" in message.lower() + or message.lower().startswith("no model with id ") + ): + raise ResourceNotFoundError( + f"Civitai model {model_id} was not found. Check the original " + "Civitai link: use the model ID from /models/, not a " + "version ID from ?modelVersionId= or " + "/api/download/models/. If the model ID is correct, " + "the model may have been removed or made inaccessible. " + "If the page opens only when logged in, check the Civitai " + "API key in LoRA Manager settings." + ) if is_expected_offline_error(message): logger.info("Civitai request skipped: %s", OFFLINE_FRIENDLY_MESSAGE) return None @@ -347,7 +358,7 @@ class CivitaiClient: except RateLimitError: raise except ResourceNotFoundError as exc: - logger.info("Model %s is no longer available on Civitai: %s", model_id, exc) + logger.info("Civitai model lookup failed: %s", exc) raise except Exception as e: logger.error("Error fetching model versions: %s", e, exc_info=True) diff --git a/py/utils/civitai_utils.py b/py/utils/civitai_utils.py index ceacdc77..3a232ded 100644 --- a/py/utils/civitai_utils.py +++ b/py/utils/civitai_utils.py @@ -148,7 +148,8 @@ def extract_civitai_model_url_parts( if parsed is None: return None, None - path_match = re.search(r"/models/(\d+)", parsed.path) + # Download URLs contain version IDs, not model IDs. + path_match = re.match(r"^/models/(\d+)(?:/|$)", parsed.path) if not path_match: return None, None diff --git a/static/js/utils/civitaiUtils.js b/static/js/utils/civitaiUtils.js index 2f7f30fd..7f5bb2a4 100644 --- a/static/js/utils/civitaiUtils.js +++ b/static/js/utils/civitaiUtils.js @@ -228,12 +228,19 @@ export function extractCivitaiModelUrlParts(url) { try { const parsedUrl = new URL(url); + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + return { modelId: null, modelVersionId: null }; + } if (!isSupportedCivitaiPageHost(parsedUrl.hostname)) { return { modelId: null, modelVersionId: null }; } - const pathMatch = parsedUrl.pathname.match(/\/models\/(\d+)/); - const modelId = pathMatch ? pathMatch[1] : null; + // Download URLs contain version IDs, not model IDs. + const pathMatch = parsedUrl.pathname.match(/^\/models\/(\d+)(?:\/|$)/); + if (!pathMatch) { + return { modelId: null, modelVersionId: null }; + } + const modelId = pathMatch[1]; const modelVersionId = parsedUrl.searchParams.get('modelVersionId'); return { modelId, modelVersionId }; @@ -269,8 +276,11 @@ export function classifyModelRelinkUrl(url) { } const hostname = parsedUrl.hostname.toLowerCase().replace(/^www\./, ''); - const pathMatch = parsedUrl.pathname.match(/\/models\/(\d+)/); - const modelId = pathMatch ? pathMatch[1] : null; + const pathMatch = parsedUrl.pathname.match(/^\/models\/(\d+)(?:\/|$)/); + if (!pathMatch) { + return { source: null, modelId: null, modelVersionId: null }; + } + const modelId = pathMatch[1]; const modelVersionId = parsedUrl.searchParams.get('modelVersionId'); if (SUPPORTED_CIVITAI_PAGE_HOSTS.has(hostname)) { diff --git a/tests/frontend/utils/civitaiUtils.test.js b/tests/frontend/utils/civitaiUtils.test.js index 04ae081e..36de32cd 100644 --- a/tests/frontend/utils/civitaiUtils.test.js +++ b/tests/frontend/utils/civitaiUtils.test.js @@ -352,6 +352,12 @@ describe('civitaiUtils', () => { }); describe('extractCivitaiModelUrlParts', () => { + it.each(['civitai.com', 'civitai.red'])('rejects download and API URLs on %s', (host) => { + for (const path of ['/api/download/models/3390681', '/api/v1/models/3390681', '/models/3390681invalid']) { + expect(extractCivitaiModelUrlParts(`https://${host}${path}?modelVersionId=3390681`)) + .toEqual({ modelId: null, modelVersionId: null }); + } + }); it('extracts model and version ids from civitai.red model URLs', () => { expect( extractCivitaiModelUrlParts('https://civitai.red/models/65423/name?modelVersionId=98765') @@ -376,6 +382,12 @@ describe('civitaiUtils', () => { }); describe('classifyModelRelinkUrl', () => { + it.each(['civitai.com', 'civitai.red', 'civarchive.com'])('rejects non-model pages on %s', (host) => { + for (const path of ['/api/download/models/3390681', '/api/v1/models/3390681', '/models/3390681invalid']) { + expect(classifyModelRelinkUrl(`https://${host}${path}`)) + .toEqual({ source: null, modelId: null, modelVersionId: null }); + } + }); it('classifies civitai.com model URLs', () => { expect( classifyModelRelinkUrl('https://civitai.com/models/649516/name?modelVersionId=726676') diff --git a/tests/services/test_civitai_client.py b/tests/services/test_civitai_client.py index a023ee23..8689a60c 100644 --- a/tests/services/test_civitai_client.py +++ b/tests/services/test_civitai_client.py @@ -199,6 +199,26 @@ async def test_get_model_versions_raises_on_not_found(monkeypatch, downloader): await client.get_model_versions("missing") +@pytest.mark.parametrize("payload", ["Resource not found", {"error": "No model with id 3390681"}]) +async def test_get_model_versions_not_found_explains_id_and_access(payload, downloader, caplog): + downloader.make_request = AsyncMock(return_value=(False, payload)) + client = await CivitaiClient.get_instance() + + with caplog.at_level("INFO"), pytest.raises(ResourceNotFoundError) as exc_info: + await client.get_model_versions("3390681") + + message = str(exc_info.value) + assert "3390681" in message + assert "/models/" in message + assert "modelVersionId" in message + assert "/api/download/models/" in message + assert "API key" in message + assert "no longer available" not in caplog.text + downloader.make_request.assert_awaited_once_with( + "GET", f"{client.base_url}/models/3390681", use_auth=True + ) + + async def test_get_model_versions_raises_on_nested_not_found(monkeypatch, downloader): async def fake_make_request(method, url, use_auth=True, **kwargs): return False, {"error": {"message": "Resource not found"}} diff --git a/tests/utils/test_civitai_utils.py b/tests/utils/test_civitai_utils.py index 6ef5362a..6b3bbc05 100644 --- a/tests/utils/test_civitai_utils.py +++ b/tests/utils/test_civitai_utils.py @@ -1,3 +1,5 @@ +import pytest + from py.utils.civitai_utils import ( build_license_flags, extract_civitai_image_id, @@ -8,6 +10,13 @@ from py.utils.civitai_utils import ( resolve_license_payload, ) +@pytest.mark.parametrize("host", ["civitai.com", "civitai.red"]) +@pytest.mark.parametrize("path", ["/api/download/models/3390681", "/api/v1/models/3390681", "/models/3390681invalid"]) +def test_extract_civitai_model_url_parts_rejects_non_model_pages(host, path): + assert extract_civitai_model_url_parts( + f"https://{host}{path}?modelVersionId=3390681" + ) == (None, None) + def test_resolve_license_payload_defaults(): payload, flags = resolve_license_info({})