feat: add CivitAI link, file matching, and mirror selection to model details
Add a direct "View on CivitAI" action button when a model has CivitAI IDs, auto-detect the matched file via hash/URL/filename, surface the preferred mirror, and render file entries as collapsible details with summary badges. Normalize CSS formatting (rgba spacing, rule separators) for consistency.
This commit is contained in:
@@ -843,6 +843,9 @@ def _normalize_archive_version(
|
||||
"id": version_id,
|
||||
"name": version.get("name") or "",
|
||||
"base_model": version.get("baseModel") or version.get("base_model") or version.get("baseModelType"),
|
||||
"platform_url": version.get("platform_url") or version.get("platformUrl"),
|
||||
"civitai_model_id": version.get("civitai_model_id") or version.get("civitaiModelId"),
|
||||
"civitai_model_version_id": version.get("civitai_model_version_id") or version.get("civitaiModelVersionId"),
|
||||
"published_at": version.get("publishedAt") or version.get("createdAt"),
|
||||
"updated_at": version.get("updatedAt"),
|
||||
"description": version.get("description") or "",
|
||||
@@ -911,6 +914,9 @@ def get_civarchive_model_details(
|
||||
"platform": context.get("platform"),
|
||||
"url": f"{CIVARCHIVE_BASE_URL}/models/{model_id}",
|
||||
"version_url": selected.get("url"),
|
||||
"platform_url": selected.get("platform_url"),
|
||||
"civitai_model_id": selected.get("civitai_model_id"),
|
||||
"civitai_model_version_id": selected.get("civitai_model_version_id"),
|
||||
"versions": [selected],
|
||||
"selected_version": selected,
|
||||
"images": selected.get("images", []),
|
||||
@@ -979,6 +985,9 @@ def get_civarchive_model_details(
|
||||
"platform": context.get("platform") or context.get("platform_name"),
|
||||
"url": f"{CIVARCHIVE_BASE_URL}/models/{model_id}",
|
||||
"version_url": selected.get("url") if selected else f"{CIVARCHIVE_BASE_URL}/models/{model_id}",
|
||||
"platform_url": selected.get("platform_url") if selected else None,
|
||||
"civitai_model_id": selected.get("civitai_model_id") if selected else None,
|
||||
"civitai_model_version_id": selected.get("civitai_model_version_id") if selected else None,
|
||||
"versions": versions,
|
||||
"selected_version": selected,
|
||||
"images": selected.get("images", []) if selected else [],
|
||||
@@ -1057,6 +1066,9 @@ def _build_result_from_normalized_version(
|
||||
"type": model_details.get("type") or file_info.get("type"),
|
||||
"filename": filename,
|
||||
"url": url,
|
||||
"platform_url": version.get("platform_url") or version.get("platformUrl"),
|
||||
"civitai_model_id": version.get("civitai_model_id") or version.get("civitaiModelId"),
|
||||
"civitai_model_version_id": version.get("civitai_model_version_id") or version.get("civitaiModelVersionId"),
|
||||
"download_url": download_urls[0],
|
||||
"download_urls": download_urls,
|
||||
"size": size,
|
||||
@@ -1527,6 +1539,9 @@ def _build_result_from_payload(
|
||||
"type": context.get("type") or selected_file.get("type"),
|
||||
"filename": filename,
|
||||
"url": civarchive_url,
|
||||
"platform_url": version.get("platform_url") or version.get("platformUrl"),
|
||||
"civitai_model_id": version.get("civitai_model_id") or version.get("civitaiModelId"),
|
||||
"civitai_model_version_id": version.get("civitai_model_version_id") or version.get("civitaiModelVersionId"),
|
||||
"download_url": download_urls[0],
|
||||
"download_urls": download_urls,
|
||||
"size": _extract_file_size_bytes(selected_file),
|
||||
|
||||
+1597
-517
File diff suppressed because it is too large
Load Diff
@@ -1513,6 +1513,9 @@ export const modelInfoMethods = {
|
||||
const source = data.source || contextModel.details_source || contextModel.source || '';
|
||||
const creator = data.creator || {};
|
||||
const creatorName = creator.username || creator.name || '';
|
||||
const pageUrl = data.version_url || data.url || '';
|
||||
const civitaiUrl = this.getSourceModelCivitaiUrl(data, selectedVersion, contextModel);
|
||||
const showCivitaiAction = civitaiUrl && civitaiUrl !== pageUrl;
|
||||
|
||||
const statItems = [
|
||||
['Downloads', stats.downloadCount || stats.downloads || versionStats.downloadCount || versionStats.downloads],
|
||||
@@ -1530,7 +1533,8 @@ export const modelInfoMethods = {
|
||||
<span>${this.escapeHtml([source, data.type, creatorName].filter(Boolean).join(' / '))}</span>
|
||||
</div>
|
||||
<div class="mr-model-details-actions">
|
||||
${data.version_url || data.url ? `<a class="mr-model-details-action" href="${this.escapeHtml(data.version_url || data.url)}" target="_blank" rel="noopener noreferrer">${getSvgIcon('externalLink')} Open page</a>` : ''}
|
||||
${showCivitaiAction ? `<a class="mr-model-details-action" href="${this.escapeHtml(civitaiUrl)}" target="_blank" rel="noopener noreferrer">${getSvgIcon('civitai')} View on CivitAI</a>` : ''}
|
||||
${pageUrl ? `<a class="mr-model-details-action" href="${this.escapeHtml(pageUrl)}" target="_blank" rel="noopener noreferrer">${getSvgIcon('externalLink')} Open page</a>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mr-model-details-main">
|
||||
@@ -1589,15 +1593,64 @@ export const modelInfoMethods = {
|
||||
`;
|
||||
},
|
||||
|
||||
getSourceModelCivitaiUrl(data = {}, selectedVersion = {}, contextModel = {}) {
|
||||
const modelId = selectedVersion.civitai_model_id
|
||||
|| selectedVersion.civitaiModelId
|
||||
|| data.civitai_model_id
|
||||
|| data.civitaiModelId
|
||||
|| contextModel.civitai_model_id
|
||||
|| contextModel.civitaiModelId;
|
||||
const versionId = selectedVersion.civitai_model_version_id
|
||||
|| selectedVersion.civitaiModelVersionId
|
||||
|| data.civitai_model_version_id
|
||||
|| data.civitaiModelVersionId
|
||||
|| contextModel.civitai_model_version_id
|
||||
|| contextModel.civitaiModelVersionId;
|
||||
|
||||
if (modelId) {
|
||||
return `https://civitai.com/models/${encodeURIComponent(modelId)}${versionId ? `?modelVersionId=${encodeURIComponent(versionId)}` : ''}`;
|
||||
}
|
||||
|
||||
const explicitUrl = selectedVersion.platform_url
|
||||
|| selectedVersion.platformUrl
|
||||
|| data.platform_url
|
||||
|| data.platformUrl
|
||||
|| contextModel.platform_url
|
||||
|| contextModel.platformUrl;
|
||||
const value = String(explicitUrl || '').trim();
|
||||
if (!/^https?:\/\//i.test(value)) return '';
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
const host = url.hostname.toLowerCase().replace(/^www\./, '');
|
||||
if (host !== 'civitai.com' && host !== 'civitai.red') return '';
|
||||
url.hostname = 'civitai.com';
|
||||
return url.toString();
|
||||
} catch (error) {
|
||||
return /civitai\.(?:com|red)\//i.test(value)
|
||||
? value.replace('civitai.red', 'civitai.com')
|
||||
: '';
|
||||
}
|
||||
},
|
||||
|
||||
renderSourceModelDetailsFiles(version = {}, data = {}, contextModel = {}) {
|
||||
const files = Array.isArray(version.files) ? version.files.filter(file => file?.download_url) : [];
|
||||
if (!files.length) {
|
||||
return '<div class="mr-model-details-empty">No downloadable files available for this version.</div>';
|
||||
}
|
||||
|
||||
return files.map((file, index) => {
|
||||
const targetIndex = files.findIndex(file => this.isSourceModelDetailsTargetFile(file, contextModel));
|
||||
const orderedFiles = targetIndex > 0
|
||||
? [files[targetIndex], ...files.filter((_, index) => index !== targetIndex)]
|
||||
: files;
|
||||
|
||||
return orderedFiles.map((file, index) => {
|
||||
const isTarget = this.isSourceModelDetailsTargetFile(file, contextModel);
|
||||
const fileMeta = this.getSourceModelFileMeta(file);
|
||||
const mirrors = this.getSourceModelMirrors(file);
|
||||
const preferredMirror = this.getSourceModelPreferredMirror(file, contextModel, mirrors);
|
||||
const preferredDownloadUrl = preferredMirror?.url || file.download_url;
|
||||
const preferredFilename = preferredMirror?.filename || file.name || contextModel.filename || data.name || 'model';
|
||||
const payload = {
|
||||
source: data.source || contextModel.details_source || contextModel.source,
|
||||
model_id: data.model_id || contextModel.model_id,
|
||||
@@ -1605,8 +1658,8 @@ export const modelInfoMethods = {
|
||||
name: data.name || contextModel.name,
|
||||
version_name: version.name || '',
|
||||
type: data.type || contextModel.type,
|
||||
filename: file.name || contextModel.filename || data.name || 'model',
|
||||
download_url: file.download_url,
|
||||
filename: preferredFilename,
|
||||
download_url: preferredDownloadUrl,
|
||||
url: version.url || data.version_url || data.url,
|
||||
size: file.size,
|
||||
base_model: version.base_model || contextModel.base_model,
|
||||
@@ -1617,22 +1670,111 @@ export const modelInfoMethods = {
|
||||
const mirrorList = mirrors.length > 1
|
||||
? this.renderSourceModelMirrors(mirrors, payload)
|
||||
: '';
|
||||
const summaryBadges = [
|
||||
isTarget ? 'Matched' : '',
|
||||
file.primary ? 'Primary' : '',
|
||||
mirrors.length ? `${mirrors.length} mirrors` : ''
|
||||
].filter(Boolean);
|
||||
const openAttr = index === 0 ? ' open' : '';
|
||||
return `
|
||||
<div class="mr-model-details-file ${file.primary ? 'is-primary' : ''}">
|
||||
<div>
|
||||
<strong>${this.escapeHtml(file.name || `File ${index + 1}`)}</strong>
|
||||
<span>${this.escapeHtml(fileMeta.summary)}</span>
|
||||
<details class="mr-model-details-file ${file.primary ? 'is-primary' : ''} ${isTarget ? 'is-target' : ''}"${openAttr}>
|
||||
<summary class="mr-model-details-file-summary">
|
||||
<span class="mr-model-details-file-chevron" aria-hidden="true"></span>
|
||||
<span class="mr-model-details-file-heading">
|
||||
<strong>${this.escapeHtml(file.name || `File ${index + 1}`)}</strong>
|
||||
<span>${this.escapeHtml(fileMeta.summary)}</span>
|
||||
</span>
|
||||
${summaryBadges.length ? `<span class="mr-model-details-file-summary-badges">${summaryBadges.map(badge => `<span>${this.escapeHtml(badge)}</span>`).join('')}</span>` : ''}
|
||||
</summary>
|
||||
<div class="mr-model-details-file-body">
|
||||
${fileMeta.badges.length ? `<div class="mr-model-details-file-badges">${fileMeta.badges.map(badge => `<span>${this.escapeHtml(badge)}</span>`).join('')}</div>` : ''}
|
||||
<button type="button" class="mr-btn mr-btn-primary mr-model-details-use" data-selection="${this.escapeHtml(encodeURIComponent(JSON.stringify(payload)))}">
|
||||
${isTarget ? 'Use matched file' : 'Use this file'}
|
||||
</button>
|
||||
${mirrorList}
|
||||
</div>
|
||||
<button type="button" class="mr-btn mr-btn-primary mr-model-details-use" data-selection="${this.escapeHtml(encodeURIComponent(JSON.stringify(payload)))}">
|
||||
Use this version
|
||||
</button>
|
||||
${mirrorList}
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
}).join('');
|
||||
},
|
||||
|
||||
isSourceModelDetailsTargetFile(file = {}, contextModel = {}) {
|
||||
const targetFilename = this.getSourceModelComparableFilename(contextModel.filename || contextModel.original_path || '');
|
||||
const targetUrl = String(contextModel.download_url || contextModel.url || '').trim();
|
||||
const targetHash = String(contextModel.sha256 || contextModel.hash || '').trim().toLowerCase();
|
||||
const fileHash = this.getSourceModelFileHash(file);
|
||||
if (targetHash && fileHash && targetHash === fileHash) return true;
|
||||
|
||||
const urls = this.getSourceModelFileUrls(file);
|
||||
if (targetUrl && urls.some(url => url === targetUrl)) return true;
|
||||
|
||||
if (!targetFilename) return false;
|
||||
return this.getSourceModelFileNames(file).some(name => (
|
||||
this.getSourceModelComparableFilename(name) === targetFilename
|
||||
));
|
||||
},
|
||||
|
||||
getSourceModelPreferredMirror(file = {}, contextModel = {}, mirrors = null) {
|
||||
const candidates = Array.isArray(mirrors) ? mirrors : this.getSourceModelMirrors(file);
|
||||
const targetUrl = String(contextModel.download_url || contextModel.url || '').trim();
|
||||
if (targetUrl) {
|
||||
const byUrl = candidates.find(mirror => String(mirror.url || '') === targetUrl);
|
||||
if (byUrl) return byUrl;
|
||||
}
|
||||
|
||||
const targetFilename = this.getSourceModelComparableFilename(contextModel.filename || contextModel.original_path || '');
|
||||
if (targetFilename) {
|
||||
const byName = candidates.find(mirror => (
|
||||
this.getSourceModelComparableFilename(mirror.filename || mirror.name || '') === targetFilename
|
||||
));
|
||||
if (byName) return byName;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
getSourceModelComparableFilename(value = '') {
|
||||
return String(value || '')
|
||||
.split(/[\\/]+/)
|
||||
.pop()
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
},
|
||||
|
||||
getSourceModelFileHash(file = {}) {
|
||||
const hashes = file.hashes && typeof file.hashes === 'object' ? file.hashes : {};
|
||||
return String(file.sha256 || file.hash || hashes.SHA256 || hashes.sha256 || '').trim().toLowerCase();
|
||||
},
|
||||
|
||||
getSourceModelFileNames(file = {}) {
|
||||
const names = new Set();
|
||||
[file.name, file.filename].forEach(name => {
|
||||
if (name) names.add(String(name));
|
||||
});
|
||||
const mirrors = Array.isArray(file.mirrors) ? file.mirrors : [];
|
||||
mirrors.forEach(mirror => {
|
||||
if (mirror?.filename) names.add(String(mirror.filename));
|
||||
if (mirror?.name) names.add(String(mirror.name));
|
||||
});
|
||||
return Array.from(names);
|
||||
},
|
||||
|
||||
getSourceModelFileUrls(file = {}) {
|
||||
const urls = new Set();
|
||||
[file.download_url, file.downloadUrl, file.url].forEach(url => {
|
||||
if (url) urls.add(String(url));
|
||||
});
|
||||
const downloadUrls = Array.isArray(file.download_urls) ? file.download_urls : [];
|
||||
downloadUrls.forEach(url => {
|
||||
if (url) urls.add(String(url));
|
||||
});
|
||||
const mirrors = Array.isArray(file.mirrors) ? file.mirrors : [];
|
||||
mirrors.forEach(mirror => {
|
||||
if (mirror?.url) urls.add(String(mirror.url));
|
||||
});
|
||||
return Array.from(urls);
|
||||
},
|
||||
|
||||
getSourceModelMirrors(file = {}) {
|
||||
const mirrors = Array.isArray(file.mirrors)
|
||||
? file.mirrors.filter(mirror => mirror?.url)
|
||||
@@ -1766,13 +1908,14 @@ export const modelInfoMethods = {
|
||||
|
||||
getSourceModelMirrorMeta(mirror = {}, isDefault = false) {
|
||||
const host = this.getSourceModelMirrorHost(mirror.url || '');
|
||||
const mirrorHash = this.getSourceModelDisplayHash(mirror);
|
||||
const meta = [
|
||||
isDefault ? 'Default' : '',
|
||||
this.isSourceModelMirrorDead(mirror) ? 'Likely dead' : '',
|
||||
host,
|
||||
this.isSourceModelMirrorGated(mirror) ? 'Gated' : '',
|
||||
mirror.is_paid ? 'Paid' : '',
|
||||
mirror.sha256 ? `SHA256 ${String(mirror.sha256).slice(0, 10)}` : ''
|
||||
mirrorHash ? `SHA256 ${mirrorHash}` : ''
|
||||
].filter(Boolean);
|
||||
return meta.join(' / ');
|
||||
},
|
||||
@@ -1791,14 +1934,20 @@ export const modelInfoMethods = {
|
||||
mirrorCount ? `${mirrorCount} mirrors` : '',
|
||||
file.id ? `ID ${file.id}` : ''
|
||||
].filter(Boolean);
|
||||
const fileHash = this.getSourceModelDisplayHash(file);
|
||||
const summary = [
|
||||
this.formatSearchResultSize(file),
|
||||
file.sha256 ? `SHA256 ${String(file.sha256).slice(0, 10)}` : ''
|
||||
fileHash ? `SHA256 ${fileHash}` : ''
|
||||
].filter(Boolean).join(' / ') || 'Downloadable file';
|
||||
|
||||
return { badges, summary };
|
||||
},
|
||||
|
||||
getSourceModelDisplayHash(value = {}) {
|
||||
const hashes = value.hashes && typeof value.hashes === 'object' ? value.hashes : {};
|
||||
return String(value.sha256 || value.hash || hashes.SHA256 || hashes.sha256 || '').trim();
|
||||
},
|
||||
|
||||
renderSourceModelImageMeta(image = {}) {
|
||||
const stats = image.stats || {};
|
||||
const metaItems = [
|
||||
|
||||
Reference in New Issue
Block a user