diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index c5c81d3..33e1b93 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -3950,7 +3950,56 @@ test('search suggestion metadata prefers exact matching base model over weaker a assert.equal(merged.filename, 'snofs_krea_v1.safetensors'); }); +test('download path metadata preserves source precedence and workflow fallbacks', () => { + const getDownloadSourceContext = eval(`(${extractMethod(downloadTargetMethodsSource, 'getDownloadSourceContext')})`); + const getDownloadPathMetadata = eval(`(${extractMethod(downloadTargetMethodsSource, 'getDownloadPathMetadata')})`); + const dialog = { + getDownloadSourceContext, + getCachedSearchSuggestionData() { + return { + model_name: 'Cached model', + tags: ['style'], + repo_id: 'cached/repo', + category: 'loras' + }; + }, + getCompatibleCivitaiSearchResult() { + return { + base_model: 'SDXL', + creator_username: 'workflow-author' + }; + }, + getFilenameFromPath(value = '') { + return String(value).split(/[\\/]/).at(-1) || ''; + } + }; + + const metadata = getDownloadPathMetadata.call(dialog, { + original_path: 'workflow/model.safetensors', + category: 'checkpoints', + civitai_info: { model_name: 'Workflow model' }, + download_source: { model_name: 'Selected model' } + }, { + filename: 'explicit.safetensors', + model_name: 'Explicit model', + tags: ['explicit'], + repo_id: 'owner/repo', + author: 'explicit-author', + category: 'text_encoders' + }); + + assert.equal(metadata.filename, 'explicit.safetensors'); + assert.equal(metadata.name, 'Explicit model'); + assert.equal(metadata.base_model, 'SDXL'); + assert.deepEqual(metadata.tags, ['explicit']); + assert.equal(metadata.creator.username, 'workflow-author'); + assert.equal(metadata.author, 'explicit-author'); + assert.equal(metadata.repo_id, 'owner/repo'); + assert.equal(metadata.category, 'text_encoders'); +}); + test('manual URL download metadata never inherits provider identity or hash from search results', () => { + const getDownloadSourceContext = eval(`(${extractMethod(downloadTargetMethodsSource, 'getDownloadSourceContext')})`); const getDownloadMetadata = eval(`(${extractMethod(downloadTargetMethodsSource, 'getDownloadMetadata')})`); const staleHash = 'a'.repeat(64); const exactHash = 'b'.repeat(64); @@ -3987,6 +4036,7 @@ test('manual URL download metadata never inherits provider identity or hash from hashes: { SHA256: exactHash } }; const dialog = { + getDownloadSourceContext, getCachedSearchSuggestionData() { throw new Error('manual URL must not read cached search metadata'); }, @@ -4021,6 +4071,7 @@ test('manual URL download metadata never inherits provider identity or hash from }); test('selected provider file hash overrides stale workflow hash', () => { + const getDownloadSourceContext = eval(`(${extractMethod(downloadTargetMethodsSource, 'getDownloadSourceContext')})`); const getDownloadMetadata = eval(`(${extractMethod(downloadTargetMethodsSource, 'getDownloadMetadata')})`); const staleHash = '1'.repeat(64); const selectedHash = 'f'.repeat(64); @@ -4044,6 +4095,7 @@ test('selected provider file hash overrides stale workflow hash', () => { } }; const dialog = { + getDownloadSourceContext, getCachedSearchSuggestionData() { throw new Error('selected provider metadata must not read cached search metadata'); }, @@ -4068,6 +4120,7 @@ test('selected provider file hash overrides stale workflow hash', () => { }); test('selected provider without a file hash does not inherit workflow hash', () => { + const getDownloadSourceContext = eval(`(${extractMethod(downloadTargetMethodsSource, 'getDownloadSourceContext')})`); const getDownloadMetadata = eval(`(${extractMethod(downloadTargetMethodsSource, 'getDownloadMetadata')})`); const staleHash = '1'.repeat(64); const missing = { @@ -4085,6 +4138,7 @@ test('selected provider without a file hash does not inherit workflow hash', () selected_file: { name: 'another-variant.safetensors' } }; const dialog = { + getDownloadSourceContext, getCachedSearchSuggestionData() { throw new Error('selected provider metadata must not read cached search metadata'); }, @@ -4107,6 +4161,7 @@ test('selected provider without a file hash does not inherit workflow hash', () }); test('CivArchive metadata keeps its page URL when the download mirror is HuggingFace', () => { + const getDownloadSourceContext = eval(`(${extractMethod(downloadTargetMethodsSource, 'getDownloadSourceContext')})`); const getDownloadMetadata = eval(`(${extractMethod(downloadTargetMethodsSource, 'getDownloadMetadata')})`); const civarchivePage = 'https://civarchive.com/models/123?modelVersionId=456'; const huggingFaceMirror = 'https://huggingface.co/author/repo/resolve/main/model.safetensors'; @@ -4121,6 +4176,7 @@ test('CivArchive metadata keeps its page URL when the download mirror is Hugging download_url: huggingFaceMirror }; const dialog = { + getDownloadSourceContext, getCachedSearchSuggestionData() { return { source: 'huggingface', diff --git a/web/resolver/search/download_target_methods.js b/web/resolver/search/download_target_methods.js index 102ff4b..e1b86ff 100644 --- a/web/resolver/search/download_target_methods.js +++ b/web/resolver/search/download_target_methods.js @@ -1224,17 +1224,50 @@ export const downloadTargetMethods = { return list[0]; }, - getDownloadPathMetadata(missing = {}, source = {}) { + getDownloadSourceContext(missing = {}, source = {}, { respectProvenanceBoundary = true } = {}) { const sourceData = source && typeof source === 'object' ? source : {}; - const searchSuggestion = this.getCachedSearchSuggestionData(missing); - const compatibleCivitaiSearch = this.getCompatibleCivitaiSearchResult?.(missing) || {}; + const isProvidedUrl = Boolean( + sourceData.custom_url + || sourceData.url_source === 'custom' + || sourceData.match_type === 'custom_url' + || sourceData.provided_url + ); + const sourceIdentity = String( + sourceData.details_source || sourceData.source || sourceData.sourceKey || '' + ).trim().toLowerCase().replace(/[-\s]+/g, '_'); + const isProviderSelection = sourceData.match_type === 'selected' + || ['civitai', 'civarchive', 'huggingface', 'lora_manager_archive'].includes(sourceIdentity); + const useSourceProvenanceBoundary = Boolean( + respectProvenanceBoundary && (isProvidedUrl || isProviderSelection) + ); + const searchSuggestion = useSourceProvenanceBoundary + ? null + : this.getCachedSearchSuggestionData(missing); + const compatibleCivitaiSearch = useSourceProvenanceBoundary + ? {} + : (this.getCompatibleCivitaiSearchResult?.(missing) || {}); + const inheritedDownloadSource = useSourceProvenanceBoundary ? {} : (missing?.download_source || {}); + const inheritedCivitaiInfo = useSourceProvenanceBoundary ? {} : (missing?.civitai_info || {}); const merged = { - ...(missing?.civitai_info || {}), + ...inheritedCivitaiInfo, ...compatibleCivitaiSearch, - ...(missing?.download_source || {}), + ...inheritedDownloadSource, ...(searchSuggestion || {}), ...sourceData }; + return { + sourceData, + merged, + inheritedDownloadSource, + isProvidedUrl, + useSourceProvenanceBoundary + }; + }, + + getDownloadPathMetadata(missing = {}, source = {}) { + const { merged } = this.getDownloadSourceContext(missing, source, { + respectProvenanceBoundary: false + }); const repoId = merged.repo_id || merged.repo || ''; const filename = merged.downloadFilename || merged.filename @@ -1264,36 +1297,17 @@ export const downloadTargetMethods = { }, getDownloadMetadata(missing = {}, source = {}, options = {}) { - const sourceData = source && typeof source === 'object' ? source : {}; - const isProvidedUrl = Boolean( - sourceData.custom_url - || sourceData.url_source === 'custom' - || sourceData.match_type === 'custom_url' - || sourceData.provided_url - ); - const sourceIdentity = String( - sourceData.details_source || sourceData.source || sourceData.sourceKey || '' - ).trim().toLowerCase().replace(/[-\s]+/g, '_'); - const isProviderSelection = sourceData.match_type === 'selected' - || ['civitai', 'civarchive', 'huggingface', 'lora_manager_archive'].includes(sourceIdentity); - const useSourceProvenanceBoundary = isProvidedUrl || isProviderSelection; // A URL explicitly selected by the user is an authoritative provenance // boundary. Never fill its hash, provider IDs, or file details from an // earlier fuzzy search result belonging to another provider. The same // rule applies to a selected/provider result, including LoRA Archive. - const searchSuggestion = useSourceProvenanceBoundary ? null : this.getCachedSearchSuggestionData(missing); - const compatibleCivitaiSearch = useSourceProvenanceBoundary - ? {} - : (this.getCompatibleCivitaiSearchResult?.(missing) || {}); - const inheritedDownloadSource = useSourceProvenanceBoundary ? {} : (missing?.download_source || {}); - const inheritedCivitaiInfo = useSourceProvenanceBoundary ? {} : (missing?.civitai_info || {}); - const merged = { - ...inheritedCivitaiInfo, - ...compatibleCivitaiSearch, - ...inheritedDownloadSource, - ...(searchSuggestion || {}), - ...sourceData - }; + const { + sourceData, + merged, + inheritedDownloadSource, + isProvidedUrl, + useSourceProvenanceBoundary + } = this.getDownloadSourceContext(missing, source); const sourceHasIdentity = Boolean( sourceData.model_id || sourceData.modelId