feat(download): per-file download status and multi-file selection (#1058)

This commit is contained in:
Will Miao
2026-08-19 17:51:31 +08:00
parent cef4129fc9
commit e7c26bf722
23 changed files with 22259 additions and 21031 deletions
+5 -1
View File
@@ -1244,11 +1244,13 @@
"downloaded": "Heruntergeladen", "downloaded": "Heruntergeladen",
"downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.", "downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.",
"alreadyInLibrary": "Bereits in Bibliothek", "alreadyInLibrary": "Bereits in Bibliothek",
"partiallyDownloaded": "[TODO: Translate] Partially downloaded",
"autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]", "autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]",
"fileSelection": { "fileSelection": {
"title": "Dateiformat auswählen", "title": "Dateiformat auswählen",
"files": "Dateien", "files": "Dateien",
"select": "Datei auswählen" "select": "Datei auswählen",
"inLibrary": "[TODO: Translate] In Library"
}, },
"errors": { "errors": {
"invalidUrl": "Ungültiges Civitai URL-Format", "invalidUrl": "Ungültiges Civitai URL-Format",
@@ -1594,6 +1596,7 @@
"actions": { "actions": {
"download": "Herunterladen", "download": "Herunterladen",
"downloadTooltip": "Diese Version herunterladen", "downloadTooltip": "Diese Version herunterladen",
"downloadRemainingTooltip": "[TODO: Translate] Download remaining files of this version",
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen", "downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai", "downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar", "downloadNotAllowedTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar",
@@ -1942,6 +1945,7 @@
"downloadPartialSuccess": "{completed} von {total} LoRAs heruntergeladen", "downloadPartialSuccess": "{completed} von {total} LoRAs heruntergeladen",
"downloadPartialWithAccess": "{completed} von {total} LoRAs heruntergeladen. {accessFailures} fehlgeschlagen aufgrund von Zugriffsbeschränkungen. Überprüfen Sie Ihren API-Schlüssel in den Einstellungen oder den Early Access-Status.", "downloadPartialWithAccess": "{completed} von {total} LoRAs heruntergeladen. {accessFailures} fehlgeschlagen aufgrund von Zugriffsbeschränkungen. Überprüfen Sie Ihren API-Schlüssel in den Einstellungen oder den Early Access-Status.",
"pleaseSelectVersion": "Bitte wählen Sie eine Version aus", "pleaseSelectVersion": "Bitte wählen Sie eine Version aus",
"pleaseSelectFile": "[TODO: Translate] Please select at least one file",
"versionExists": "Diese Version existiert bereits in Ihrer Bibliothek", "versionExists": "Diese Version existiert bereits in Ihrer Bibliothek",
"downloadCompleted": "Download erfolgreich abgeschlossen", "downloadCompleted": "Download erfolgreich abgeschlossen",
"downloadSkippedByBaseModel": "Download übersprungen, weil das Basismodell {baseModel} ausgeschlossen ist", "downloadSkippedByBaseModel": "Download übersprungen, weil das Basismodell {baseModel} ausgeschlossen ist",
+5 -1
View File
@@ -1244,11 +1244,13 @@
"downloaded": "Downloaded", "downloaded": "Downloaded",
"downloadedTooltip": "Previously downloaded, but it is not currently in your library.", "downloadedTooltip": "Previously downloaded, but it is not currently in your library.",
"alreadyInLibrary": "Already in Library", "alreadyInLibrary": "Already in Library",
"partiallyDownloaded": "Partially downloaded",
"autoOrganizedPath": "[Auto-organized by path template]", "autoOrganizedPath": "[Auto-organized by path template]",
"fileSelection": { "fileSelection": {
"title": "Select File Format", "title": "Select File Format",
"files": "files", "files": "files",
"select": "Select File" "select": "Select File",
"inLibrary": "In Library"
}, },
"errors": { "errors": {
"invalidUrl": "Invalid Civitai URL format", "invalidUrl": "Invalid Civitai URL format",
@@ -1594,6 +1596,7 @@
"actions": { "actions": {
"download": "Download", "download": "Download",
"downloadTooltip": "Download this version", "downloadTooltip": "Download this version",
"downloadRemainingTooltip": "Download remaining files of this version",
"downloadEarlyAccessTooltip": "Download this early access version from Civitai", "downloadEarlyAccessTooltip": "Download this early access version from Civitai",
"downloadPaidTooltip": "Download this paid version from Civitai", "downloadPaidTooltip": "Download this paid version from Civitai",
"downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai", "downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai",
@@ -1942,6 +1945,7 @@
"downloadPartialSuccess": "Downloaded {completed} of {total} LoRAs", "downloadPartialSuccess": "Downloaded {completed} of {total} LoRAs",
"downloadPartialWithAccess": "Downloaded {completed} of {total} LoRAs. {accessFailures} failed due to access restrictions. Check your API key in settings or early access status.", "downloadPartialWithAccess": "Downloaded {completed} of {total} LoRAs. {accessFailures} failed due to access restrictions. Check your API key in settings or early access status.",
"pleaseSelectVersion": "Please select a version", "pleaseSelectVersion": "Please select a version",
"pleaseSelectFile": "Please select at least one file",
"versionExists": "This version already exists in your library", "versionExists": "This version already exists in your library",
"downloadCompleted": "Download completed successfully", "downloadCompleted": "Download completed successfully",
"downloadSkippedByBaseModel": "Skipped download because base model {baseModel} is excluded", "downloadSkippedByBaseModel": "Skipped download because base model {baseModel} is excluded",
+5 -1
View File
@@ -1244,11 +1244,13 @@
"downloaded": "Descargado", "downloaded": "Descargado",
"downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.", "downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.",
"alreadyInLibrary": "Ya en la biblioteca", "alreadyInLibrary": "Ya en la biblioteca",
"partiallyDownloaded": "[TODO: Translate] Partially downloaded",
"autoOrganizedPath": "[Auto-organizado por plantilla de ruta]", "autoOrganizedPath": "[Auto-organizado por plantilla de ruta]",
"fileSelection": { "fileSelection": {
"title": "Seleccionar formato de archivo", "title": "Seleccionar formato de archivo",
"files": "archivos", "files": "archivos",
"select": "Seleccionar archivo" "select": "Seleccionar archivo",
"inLibrary": "[TODO: Translate] In Library"
}, },
"errors": { "errors": {
"invalidUrl": "Formato de URL de Civitai inválido", "invalidUrl": "Formato de URL de Civitai inválido",
@@ -1594,6 +1596,7 @@
"actions": { "actions": {
"download": "Descargar", "download": "Descargar",
"downloadTooltip": "Descargar esta versión", "downloadTooltip": "Descargar esta versión",
"downloadRemainingTooltip": "[TODO: Translate] Download remaining files of this version",
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai", "downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai", "downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai", "downloadNotAllowedTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai",
@@ -1942,6 +1945,7 @@
"downloadPartialSuccess": "Descargados {completed} de {total} LoRAs", "downloadPartialSuccess": "Descargados {completed} de {total} LoRAs",
"downloadPartialWithAccess": "Descargados {completed} de {total} LoRAs. {accessFailures} fallaron debido a restricciones de acceso. Revisa tu clave API en configuración o estado de acceso temprano.", "downloadPartialWithAccess": "Descargados {completed} de {total} LoRAs. {accessFailures} fallaron debido a restricciones de acceso. Revisa tu clave API en configuración o estado de acceso temprano.",
"pleaseSelectVersion": "Por favor selecciona una versión", "pleaseSelectVersion": "Por favor selecciona una versión",
"pleaseSelectFile": "[TODO: Translate] Please select at least one file",
"versionExists": "Esta versión ya existe en tu biblioteca", "versionExists": "Esta versión ya existe en tu biblioteca",
"downloadCompleted": "Descarga completada exitosamente", "downloadCompleted": "Descarga completada exitosamente",
"downloadSkippedByBaseModel": "Descarga omitida porque el modelo base {baseModel} está excluido", "downloadSkippedByBaseModel": "Descarga omitida porque el modelo base {baseModel} está excluido",
+5 -1
View File
@@ -1244,11 +1244,13 @@
"downloaded": "Téléchargé", "downloaded": "Téléchargé",
"downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.", "downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.",
"alreadyInLibrary": "Déjà dans la bibliothèque", "alreadyInLibrary": "Déjà dans la bibliothèque",
"partiallyDownloaded": "[TODO: Translate] Partially downloaded",
"autoOrganizedPath": "[Auto-organisé par modèle de chemin]", "autoOrganizedPath": "[Auto-organisé par modèle de chemin]",
"fileSelection": { "fileSelection": {
"title": "Choisir le format de fichier", "title": "Choisir le format de fichier",
"files": "fichiers", "files": "fichiers",
"select": "Choisir le fichier" "select": "Choisir le fichier",
"inLibrary": "[TODO: Translate] In Library"
}, },
"errors": { "errors": {
"invalidUrl": "Format d'URL Civitai invalide", "invalidUrl": "Format d'URL Civitai invalide",
@@ -1594,6 +1596,7 @@
"actions": { "actions": {
"download": "Télécharger", "download": "Télécharger",
"downloadTooltip": "Télécharger cette version", "downloadTooltip": "Télécharger cette version",
"downloadRemainingTooltip": "[TODO: Translate] Download remaining files of this version",
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai", "downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai", "downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai", "downloadNotAllowedTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai",
@@ -1942,6 +1945,7 @@
"downloadPartialSuccess": "{completed} sur {total} LoRAs téléchargés", "downloadPartialSuccess": "{completed} sur {total} LoRAs téléchargés",
"downloadPartialWithAccess": "{completed} sur {total} LoRAs téléchargés. {accessFailures} ont échoué en raison de restrictions d'accès. Vérifiez votre clé API dans les paramètres ou le statut d'accès anticipé.", "downloadPartialWithAccess": "{completed} sur {total} LoRAs téléchargés. {accessFailures} ont échoué en raison de restrictions d'accès. Vérifiez votre clé API dans les paramètres ou le statut d'accès anticipé.",
"pleaseSelectVersion": "Veuillez sélectionner une version", "pleaseSelectVersion": "Veuillez sélectionner une version",
"pleaseSelectFile": "[TODO: Translate] Please select at least one file",
"versionExists": "Cette version existe déjà dans votre bibliothèque", "versionExists": "Cette version existe déjà dans votre bibliothèque",
"downloadCompleted": "Téléchargement terminé avec succès", "downloadCompleted": "Téléchargement terminé avec succès",
"downloadSkippedByBaseModel": "Téléchargement ignoré, car le modèle de base {baseModel} est exclu", "downloadSkippedByBaseModel": "Téléchargement ignoré, car le modèle de base {baseModel} est exclu",
+5 -1
View File
@@ -1244,11 +1244,13 @@
"downloaded": "הורד", "downloaded": "הורד",
"downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.", "downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.",
"alreadyInLibrary": "כבר בספרייה", "alreadyInLibrary": "כבר בספרייה",
"partiallyDownloaded": "[TODO: Translate] Partially downloaded",
"autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]", "autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]",
"fileSelection": { "fileSelection": {
"title": "בחר פורמט קובץ", "title": "בחר פורמט קובץ",
"files": "קבצים", "files": "קבצים",
"select": "בחר קובץ" "select": "בחר קובץ",
"inLibrary": "[TODO: Translate] In Library"
}, },
"errors": { "errors": {
"invalidUrl": "פורמט URL של Civitai לא חוקי", "invalidUrl": "פורמט URL של Civitai לא חוקי",
@@ -1594,6 +1596,7 @@
"actions": { "actions": {
"download": "הורדה", "download": "הורדה",
"downloadTooltip": "הורד את הגרסה הזו", "downloadTooltip": "הורד את הגרסה הזו",
"downloadRemainingTooltip": "[TODO: Translate] Download remaining files of this version",
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai", "downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai", "downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai", "downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai",
@@ -1942,6 +1945,7 @@
"downloadPartialSuccess": "הורדו {completed} מתוך {total} LoRAs", "downloadPartialSuccess": "הורדו {completed} מתוך {total} LoRAs",
"downloadPartialWithAccess": "הורדו {completed} מתוך {total} LoRAs. {accessFailures} נכשלו עקב הגבלות גישה. בדוק את מפתח ה-API שלך בהגדרות או את סטטוס הגישה המוקדמת.", "downloadPartialWithAccess": "הורדו {completed} מתוך {total} LoRAs. {accessFailures} נכשלו עקב הגבלות גישה. בדוק את מפתח ה-API שלך בהגדרות או את סטטוס הגישה המוקדמת.",
"pleaseSelectVersion": "אנא בחר גרסה", "pleaseSelectVersion": "אנא בחר גרסה",
"pleaseSelectFile": "[TODO: Translate] Please select at least one file",
"versionExists": "גרסה זו כבר קיימת בספרייה שלך", "versionExists": "גרסה זו כבר קיימת בספרייה שלך",
"downloadCompleted": "ההורדה הושלמה בהצלחה", "downloadCompleted": "ההורדה הושלמה בהצלחה",
"downloadSkippedByBaseModel": "ההורדה דולגה כי מודל הבסיס {baseModel} מוחרג", "downloadSkippedByBaseModel": "ההורדה דולגה כי מודל הבסיס {baseModel} מוחרג",
+5 -1
View File
@@ -1244,11 +1244,13 @@
"downloaded": "ダウンロード済み", "downloaded": "ダウンロード済み",
"downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。", "downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。",
"alreadyInLibrary": "既にライブラリ内", "alreadyInLibrary": "既にライブラリ内",
"partiallyDownloaded": "[TODO: Translate] Partially downloaded",
"autoOrganizedPath": "[パステンプレートによる自動整理]", "autoOrganizedPath": "[パステンプレートによる自動整理]",
"fileSelection": { "fileSelection": {
"title": "ファイル形式を選択", "title": "ファイル形式を選択",
"files": "ファイル", "files": "ファイル",
"select": "ファイルを選択" "select": "ファイルを選択",
"inLibrary": "[TODO: Translate] In Library"
}, },
"errors": { "errors": {
"invalidUrl": "無効なCivitai URL形式", "invalidUrl": "無効なCivitai URL形式",
@@ -1594,6 +1596,7 @@
"actions": { "actions": {
"download": "ダウンロード", "download": "ダウンロード",
"downloadTooltip": "このバージョンをダウンロード", "downloadTooltip": "このバージョンをダウンロード",
"downloadRemainingTooltip": "[TODO: Translate] Download remaining files of this version",
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード", "downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai", "downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません", "downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません",
@@ -1942,6 +1945,7 @@
"downloadPartialSuccess": "{total} LoRAのうち {completed} がダウンロードされました", "downloadPartialSuccess": "{total} LoRAのうち {completed} がダウンロードされました",
"downloadPartialWithAccess": "{total} LoRAのうち {completed} がダウンロードされました。{accessFailures} はアクセス制限により失敗しました。設定でAPIキーまたはアーリーアクセス状況を確認してください。", "downloadPartialWithAccess": "{total} LoRAのうち {completed} がダウンロードされました。{accessFailures} はアクセス制限により失敗しました。設定でAPIキーまたはアーリーアクセス状況を確認してください。",
"pleaseSelectVersion": "バージョンを選択してください", "pleaseSelectVersion": "バージョンを選択してください",
"pleaseSelectFile": "[TODO: Translate] Please select at least one file",
"versionExists": "このバージョンは既にライブラリに存在します", "versionExists": "このバージョンは既にライブラリに存在します",
"downloadCompleted": "ダウンロードが正常に完了しました", "downloadCompleted": "ダウンロードが正常に完了しました",
"downloadSkippedByBaseModel": "ベースモデル {baseModel} が除外されているため、ダウンロードをスキップしました", "downloadSkippedByBaseModel": "ベースモデル {baseModel} が除外されているため、ダウンロードをスキップしました",
+5 -1
View File
@@ -1244,11 +1244,13 @@
"downloaded": "다운로드됨", "downloaded": "다운로드됨",
"downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.", "downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.",
"alreadyInLibrary": "이미 라이브러리에 있음", "alreadyInLibrary": "이미 라이브러리에 있음",
"partiallyDownloaded": "[TODO: Translate] Partially downloaded",
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]", "autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
"fileSelection": { "fileSelection": {
"title": "파일 형식 선택", "title": "파일 형식 선택",
"files": "개 파일", "files": "개 파일",
"select": "파일 선택" "select": "파일 선택",
"inLibrary": "[TODO: Translate] In Library"
}, },
"errors": { "errors": {
"invalidUrl": "잘못된 Civitai URL 형식", "invalidUrl": "잘못된 Civitai URL 형식",
@@ -1594,6 +1596,7 @@
"actions": { "actions": {
"download": "다운로드", "download": "다운로드",
"downloadTooltip": "이 버전 다운로드", "downloadTooltip": "이 버전 다운로드",
"downloadRemainingTooltip": "[TODO: Translate] Download remaining files of this version",
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드", "downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai", "downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다", "downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
@@ -1942,6 +1945,7 @@
"downloadPartialSuccess": "{total}개 중 {completed}개 LoRA가 다운로드되었습니다", "downloadPartialSuccess": "{total}개 중 {completed}개 LoRA가 다운로드되었습니다",
"downloadPartialWithAccess": "{total}개 중 {completed}개 LoRA가 다운로드되었습니다. {accessFailures}개는 액세스 제한으로 실패했습니다. 설정에서 API 키 또는 얼리 액세스 상태를 확인하세요.", "downloadPartialWithAccess": "{total}개 중 {completed}개 LoRA가 다운로드되었습니다. {accessFailures}개는 액세스 제한으로 실패했습니다. 설정에서 API 키 또는 얼리 액세스 상태를 확인하세요.",
"pleaseSelectVersion": "버전을 선택해주세요", "pleaseSelectVersion": "버전을 선택해주세요",
"pleaseSelectFile": "[TODO: Translate] Please select at least one file",
"versionExists": "이 버전은 이미 라이브러리에 있습니다", "versionExists": "이 버전은 이미 라이브러리에 있습니다",
"downloadCompleted": "다운로드가 성공적으로 완료되었습니다", "downloadCompleted": "다운로드가 성공적으로 완료되었습니다",
"downloadSkippedByBaseModel": "기본 모델 {baseModel}이(가) 제외되어 다운로드를 건너뛰었습니다", "downloadSkippedByBaseModel": "기본 모델 {baseModel}이(가) 제외되어 다운로드를 건너뛰었습니다",
+5 -1
View File
@@ -1244,11 +1244,13 @@
"downloaded": "Загружено", "downloaded": "Загружено",
"downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.", "downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.",
"alreadyInLibrary": "Уже в библиотеке", "alreadyInLibrary": "Уже в библиотеке",
"partiallyDownloaded": "[TODO: Translate] Partially downloaded",
"autoOrganizedPath": "[Автоматически организовано по шаблону пути]", "autoOrganizedPath": "[Автоматически организовано по шаблону пути]",
"fileSelection": { "fileSelection": {
"title": "Выбрать формат файла", "title": "Выбрать формат файла",
"files": "файлов", "files": "файлов",
"select": "Выбрать файл" "select": "Выбрать файл",
"inLibrary": "[TODO: Translate] In Library"
}, },
"errors": { "errors": {
"invalidUrl": "Неверный формат URL Civitai", "invalidUrl": "Неверный формат URL Civitai",
@@ -1594,6 +1596,7 @@
"actions": { "actions": {
"download": "Скачать", "download": "Скачать",
"downloadTooltip": "Скачать эту версию", "downloadTooltip": "Скачать эту версию",
"downloadRemainingTooltip": "[TODO: Translate] Download remaining files of this version",
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai", "downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai", "downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai", "downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai",
@@ -1942,6 +1945,7 @@
"downloadPartialSuccess": "Загружено {completed} из {total} LoRAs", "downloadPartialSuccess": "Загружено {completed} из {total} LoRAs",
"downloadPartialWithAccess": "Загружено {completed} из {total} LoRAs. {accessFailures} не удалось из-за ограничений доступа. Проверьте ваш API ключ в настройках или статус раннего доступа.", "downloadPartialWithAccess": "Загружено {completed} из {total} LoRAs. {accessFailures} не удалось из-за ограничений доступа. Проверьте ваш API ключ в настройках или статус раннего доступа.",
"pleaseSelectVersion": "Пожалуйста, выберите версию", "pleaseSelectVersion": "Пожалуйста, выберите версию",
"pleaseSelectFile": "[TODO: Translate] Please select at least one file",
"versionExists": "Эта версия уже существует в вашей библиотеке", "versionExists": "Эта версия уже существует в вашей библиотеке",
"downloadCompleted": "Загрузка успешно завершена", "downloadCompleted": "Загрузка успешно завершена",
"downloadSkippedByBaseModel": "Загрузка пропущена, потому что базовая модель {baseModel} исключена", "downloadSkippedByBaseModel": "Загрузка пропущена, потому что базовая модель {baseModel} исключена",
+5 -1
View File
@@ -1244,11 +1244,13 @@
"downloaded": "已下载", "downloaded": "已下载",
"downloadedTooltip": "之前已下载,但当前不在你的库中。", "downloadedTooltip": "之前已下载,但当前不在你的库中。",
"alreadyInLibrary": "已存在于库中", "alreadyInLibrary": "已存在于库中",
"partiallyDownloaded": "[TODO: Translate] Partially downloaded",
"autoOrganizedPath": "【已按路径模板自动整理】", "autoOrganizedPath": "【已按路径模板自动整理】",
"fileSelection": { "fileSelection": {
"title": "选择文件格式", "title": "选择文件格式",
"files": "个文件", "files": "个文件",
"select": "选择文件" "select": "选择文件",
"inLibrary": "[TODO: Translate] In Library"
}, },
"errors": { "errors": {
"invalidUrl": "无效的 Civitai URL 格式", "invalidUrl": "无效的 Civitai URL 格式",
@@ -1594,6 +1596,7 @@
"actions": { "actions": {
"download": "下载", "download": "下载",
"downloadTooltip": "下载此版本", "downloadTooltip": "下载此版本",
"downloadRemainingTooltip": "[TODO: Translate] Download remaining files of this version",
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本", "downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai", "downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载", "downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载",
@@ -1942,6 +1945,7 @@
"downloadPartialSuccess": "已下载 {completed}/{total} 个 LoRA", "downloadPartialSuccess": "已下载 {completed}/{total} 个 LoRA",
"downloadPartialWithAccess": "已下载 {completed}/{total} 个 LoRA。{accessFailures} 个因访问限制失败。请检查设置中的 API 密钥或早期访问状态。", "downloadPartialWithAccess": "已下载 {completed}/{total} 个 LoRA。{accessFailures} 个因访问限制失败。请检查设置中的 API 密钥或早期访问状态。",
"pleaseSelectVersion": "请选择版本", "pleaseSelectVersion": "请选择版本",
"pleaseSelectFile": "[TODO: Translate] Please select at least one file",
"versionExists": "该版本已存在于你的库中", "versionExists": "该版本已存在于你的库中",
"downloadCompleted": "下载成功完成", "downloadCompleted": "下载成功完成",
"downloadSkippedByBaseModel": "由于基础模型 {baseModel} 已被排除,已跳过下载", "downloadSkippedByBaseModel": "由于基础模型 {baseModel} 已被排除,已跳过下载",
+5 -1
View File
@@ -1244,11 +1244,13 @@
"downloaded": "已下載", "downloaded": "已下載",
"downloadedTooltip": "先前已下載,但目前不在你的庫中。", "downloadedTooltip": "先前已下載,但目前不在你的庫中。",
"alreadyInLibrary": "已在庫存", "alreadyInLibrary": "已在庫存",
"partiallyDownloaded": "[TODO: Translate] Partially downloaded",
"autoOrganizedPath": "[依路徑範本自動整理]", "autoOrganizedPath": "[依路徑範本自動整理]",
"fileSelection": { "fileSelection": {
"title": "選擇檔案格式", "title": "選擇檔案格式",
"files": "個檔案", "files": "個檔案",
"select": "選擇檔案" "select": "選擇檔案",
"inLibrary": "[TODO: Translate] In Library"
}, },
"errors": { "errors": {
"invalidUrl": "Civitai 網址格式無效", "invalidUrl": "Civitai 網址格式無效",
@@ -1594,6 +1596,7 @@
"actions": { "actions": {
"download": "下載", "download": "下載",
"downloadTooltip": "下載此版本", "downloadTooltip": "下載此版本",
"downloadRemainingTooltip": "[TODO: Translate] Download remaining files of this version",
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本", "downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai", "downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載", "downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載",
@@ -1942,6 +1945,7 @@
"downloadPartialSuccess": "已下載 {completed} 個 LoRA,共 {total} 個", "downloadPartialSuccess": "已下載 {completed} 個 LoRA,共 {total} 個",
"downloadPartialWithAccess": "已下載 {completed} 個 LoRA,共 {total} 個。{accessFailures} 個因訪問限制而失敗。請檢查您的 API 密鑰或提前訪問狀態。", "downloadPartialWithAccess": "已下載 {completed} 個 LoRA,共 {total} 個。{accessFailures} 個因訪問限制而失敗。請檢查您的 API 密鑰或提前訪問狀態。",
"pleaseSelectVersion": "請選擇一個版本", "pleaseSelectVersion": "請選擇一個版本",
"pleaseSelectFile": "[TODO: Translate] Please select at least one file",
"versionExists": "此版本已存在於您的庫中", "versionExists": "此版本已存在於您的庫中",
"downloadCompleted": "下載成功完成", "downloadCompleted": "下載成功完成",
"downloadSkippedByBaseModel": "由於基礎模型 {baseModel} 已被排除,已跳過下載", "downloadSkippedByBaseModel": "由於基礎模型 {baseModel} 已被排除,已跳過下載",
+22 -5
View File
@@ -2428,8 +2428,8 @@ class ModelLibraryHandler:
embedding_scanner = await self._service_registry.get_embedding_scanner() embedding_scanner = await self._service_registry.get_embedding_scanner()
found_type = None found_type = None
file_path = None
found_cache = None found_cache = None
entries: list = []
for model_type, scanner in ( for model_type, scanner in (
("lora", lora_scanner), ("lora", lora_scanner),
@@ -2440,27 +2440,43 @@ class ModelLibraryHandler:
if cache and model_version_id in cache.version_index: if cache and model_version_id in cache.version_index:
found_type = model_type found_type = model_type
found_cache = cache found_cache = cache
entry = cache.version_index[model_version_id] # A version can have several local files (#1058); collect
file_path = entry.get("file_path") # them all so the delete below covers every file.
files_getter = getattr(cache, "get_files_by_version_id", None)
if files_getter is not None:
entries = files_getter(model_version_id)
else:
entries = [cache.version_index[model_version_id]]
break break
if not file_path: file_paths = [
entry.get("file_path")
for entry in entries
if isinstance(entry, dict) and entry.get("file_path")
]
if not file_paths:
return web.json_response( return web.json_response(
{"success": False, "error": "Model version not found in any scanner cache"}, {"success": False, "error": "Model version not found in any scanner cache"},
status=404, status=404,
) )
for file_path in file_paths:
target_dir = os.path.dirname(file_path) target_dir = os.path.dirname(file_path)
base_name = os.path.basename(file_path) base_name = os.path.basename(file_path)
file_name, extension = os.path.splitext(base_name) file_name, extension = os.path.splitext(base_name)
await delete_model_artifacts(target_dir, file_name, main_extension=extension) await delete_model_artifacts(target_dir, file_name, main_extension=extension)
if found_cache: if found_cache:
removed_paths = set(file_paths)
found_cache.raw_data = [ found_cache.raw_data = [
item item
for item in found_cache.raw_data for item in found_cache.raw_data
if item.get("file_path") != file_path if item.get("file_path") not in removed_paths
] ]
rebuild = getattr(found_cache, "rebuild_version_index", None)
if rebuild is not None:
rebuild()
await found_cache.resort() await found_cache.resort()
scanner_map = { scanner_map = {
@@ -2483,6 +2499,7 @@ class ModelLibraryHandler:
"success": True, "success": True,
"modelType": found_type, "modelType": found_type,
"modelVersionId": model_version_id, "modelVersionId": model_version_id,
"deletedFiles": len(file_paths),
} }
) )
except Exception as exc: except Exception as exc:
+71
View File
@@ -2189,6 +2189,19 @@ class ModelCivitaiHandler:
else: else:
version.pop("localPath", None) version.pop("localPath", None)
# Per-file downloaded state so multi-file versions can show
# which individual files are already in the library (#1058)
local_entries: List[Any] = []
if version_id is not None and cache:
files_getter = getattr(cache, "get_files_by_version_id", None)
if files_getter is not None:
local_entries = files_getter(version_id)
elif cache_entry is not None:
local_entries = [cache_entry]
version["downloadedFiles"] = self._match_downloaded_files(
version, local_entries
)
model_file = ( model_file = (
self._find_model_file(version.get("files", [])) self._find_model_file(version.get("files", []))
if isinstance(version.get("files"), Iterable) if isinstance(version.get("files"), Iterable)
@@ -2203,6 +2216,64 @@ class ModelCivitaiHandler:
) )
return web.Response(status=500, text=str(exc)) return web.Response(status=500, text=str(exc))
@staticmethod
def _match_downloaded_files(
version: Mapping[str, Any], local_entries: List[Any]
) -> List[Dict[str, Any]]:
"""Map local library entries back to individual files of a version.
Matching follows rule D2 (#1058): SHA256 is authoritative when the
local entry carries one; otherwise fall back to extension-less file
name equality. Returns ``[{fileId, fileName, filePath}]``.
"""
files = version.get("files")
if not isinstance(files, list) or not local_entries:
return []
by_hash: Dict[str, Mapping[str, Any]] = {}
by_name: Dict[str, Mapping[str, Any]] = {}
for file_info in files:
if not isinstance(file_info, Mapping):
continue
sha = str(
(file_info.get("hashes") or {}).get("SHA256") or ""
).strip().lower()
if sha:
by_hash.setdefault(sha, file_info)
name = str(file_info.get("name") or "").strip()
if name:
by_name.setdefault(os.path.splitext(name)[0], file_info)
downloaded: List[Dict[str, Any]] = []
seen_keys: set = set()
for entry in local_entries:
if not isinstance(entry, Mapping):
continue
matched: Optional[Mapping[str, Any]] = None
local_hash = str(entry.get("sha256") or "").strip().lower()
if local_hash:
matched = by_hash.get(local_hash)
if matched is None:
local_name = str(entry.get("file_name") or "").strip()
if local_name:
matched = by_name.get(local_name)
if matched is None:
continue
file_id = matched.get("id")
dedupe_key = file_id if file_id is not None else matched.get("name")
if dedupe_key in seen_keys:
continue
seen_keys.add(dedupe_key)
downloaded.append(
{
"fileId": file_id,
"fileName": matched.get("name"),
"filePath": entry.get("file_path"),
}
)
return downloaded
async def get_civitai_model_by_version(self, request: web.Request) -> web.Response: async def get_civitai_model_by_version(self, request: web.Request) -> web.Response:
try: try:
model_version_id = request.match_info.get("modelVersionId") model_version_id = request.match_info.get("modelVersionId")
+14
View File
@@ -976,6 +976,7 @@ class DownloadManager:
version_info, version_info,
record.get("model_version_id"), record.get("model_version_id"),
record.get("save_path") or record.get("file_path"), record.get("save_path") or record.get("file_path"),
file_info=file_info,
) )
await self._sync_downloaded_version( await self._sync_downloaded_version(
model_type, model_type,
@@ -1860,6 +1861,7 @@ class DownloadManager:
version_info, version_info,
model_version_id, model_version_id,
save_path, save_path,
file_info=file_info,
) )
await self._sync_downloaded_version( await self._sync_downloaded_version(
model_type, model_type,
@@ -1902,6 +1904,7 @@ class DownloadManager:
version_info: Dict[str, Any], version_info: Dict[str, Any],
fallback_version_id=None, fallback_version_id=None,
file_path: str | None = None, file_path: str | None = None,
file_info: Dict[str, Any] | None = None,
) -> None: ) -> None:
try: try:
history_service = await ServiceRegistry.get_downloaded_version_history_service() history_service = await ServiceRegistry.get_downloaded_version_history_service()
@@ -1927,6 +1930,15 @@ class DownloadManager:
if version_id is None: if version_id is None:
version_id = fallback_version_id version_id = fallback_version_id
# Per-file identity for multi-file versions (#1058)
file_id = None
file_name = None
if isinstance(file_info, dict):
file_id = file_info.get("id")
raw_file_name = file_info.get("name")
if isinstance(raw_file_name, str) and raw_file_name.strip():
file_name = raw_file_name.strip()
try: try:
await history_service.mark_downloaded( await history_service.mark_downloaded(
model_type, model_type,
@@ -1934,6 +1946,8 @@ class DownloadManager:
model_id=int(cast(Any, resolved_model_id)) if resolved_model_id is not None else None, model_id=int(cast(Any, resolved_model_id)) if resolved_model_id is not None else None,
source="download", source="download",
file_path=file_path, file_path=file_path,
file_id=file_id,
file_name=file_name,
) )
except (TypeError, ValueError): except (TypeError, ValueError):
logger.debug( logger.debug(
@@ -62,6 +62,14 @@ class DownloadedVersionHistoryService:
); );
CREATE INDEX IF NOT EXISTS idx_downloaded_model_versions_model CREATE INDEX IF NOT EXISTS idx_downloaded_model_versions_model
ON downloaded_model_versions(model_type, model_id); ON downloaded_model_versions(model_type, model_id);
CREATE TABLE IF NOT EXISTS downloaded_version_files (
model_type TEXT NOT NULL,
version_id INTEGER NOT NULL,
file_id INTEGER NOT NULL,
file_name TEXT,
downloaded_at REAL NOT NULL,
PRIMARY KEY (model_type, version_id, file_id)
);
""" """
def __init__(self, db_path: str | None = None, *, settings_manager=None) -> None: def __init__(self, db_path: str | None = None, *, settings_manager=None) -> None:
@@ -131,10 +139,13 @@ class DownloadedVersionHistoryService:
source: str = "manual", source: str = "manual",
file_path: str | None = None, file_path: str | None = None,
library_name: str | None = None, library_name: str | None = None,
file_id: int | None = None,
file_name: str | None = None,
) -> None: ) -> None:
normalized_type = _normalize_model_type(model_type) normalized_type = _normalize_model_type(model_type)
normalized_version_id = _normalize_int(version_id) normalized_version_id = _normalize_int(version_id)
normalized_model_id = _normalize_int(model_id) normalized_model_id = _normalize_int(model_id)
normalized_file_id = _normalize_int(file_id)
if normalized_type is None or normalized_version_id is None: if normalized_type is None or normalized_version_id is None:
return return
@@ -168,6 +179,25 @@ class DownloadedVersionHistoryService:
active_library_name, active_library_name,
), ),
) )
if normalized_file_id is not None:
# Per-file history for multi-file versions (#1058)
conn.execute(
"""
INSERT INTO downloaded_version_files (
model_type, version_id, file_id, file_name, downloaded_at
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(model_type, version_id, file_id) DO UPDATE SET
file_name = COALESCE(excluded.file_name, downloaded_version_files.file_name),
downloaded_at = excluded.downloaded_at
""",
(
normalized_type,
normalized_version_id,
normalized_file_id,
file_name,
timestamp,
),
)
conn.commit() conn.commit()
async def mark_downloaded_bulk( async def mark_downloaded_bulk(
@@ -255,8 +285,63 @@ class DownloadedVersionHistoryService:
self._get_active_library_name(), self._get_active_library_name(),
), ),
) )
# Whole-version deletion also clears the per-file records (#1058)
conn.execute(
"""
DELETE FROM downloaded_version_files
WHERE model_type = ? AND version_id = ?
""",
(normalized_type, normalized_version_id),
)
conn.commit() conn.commit()
async def mark_file_deleted(
self, model_type: str, version_id: int, file_id: int
) -> None:
"""Drop a single file record of a version, keeping siblings (#1058)."""
normalized_type = _normalize_model_type(model_type)
normalized_version_id = _normalize_int(version_id)
normalized_file_id = _normalize_int(file_id)
if (
normalized_type is None
or normalized_version_id is None
or normalized_file_id is None
):
return
async with self._lock:
conn = self._get_conn()
conn.execute(
"""
DELETE FROM downloaded_version_files
WHERE model_type = ? AND version_id = ? AND file_id = ?
""",
(normalized_type, normalized_version_id, normalized_file_id),
)
conn.commit()
async def get_downloaded_file_ids(
self, model_type: str, version_id: int
) -> list[int]:
"""Return the CivitAI file ids recorded as downloaded for a version."""
normalized_type = _normalize_model_type(model_type)
normalized_version_id = _normalize_int(version_id)
if normalized_type is None or normalized_version_id is None:
return []
async with self._lock:
conn = self._get_conn()
rows = conn.execute(
"""
SELECT file_id
FROM downloaded_version_files
WHERE model_type = ? AND version_id = ?
ORDER BY file_id ASC
""",
(normalized_type, normalized_version_id),
).fetchall()
return [int(row["file_id"]) for row in rows]
async def has_been_downloaded(self, model_type: str, version_id: int) -> bool: async def has_been_downloaded(self, model_type: str, version_id: int) -> bool:
normalized_type = _normalize_model_type(model_type) normalized_type = _normalize_model_type(model_type)
normalized_version_id = _normalize_int(version_id) normalized_version_id = _normalize_int(version_id)
+64
View File
@@ -35,6 +35,10 @@ class ModelCache:
folders: List[str] folders: List[str]
version_index: Dict[int, Dict[str, Any]] = field(default_factory=dict) version_index: Dict[int, Dict[str, Any]] = field(default_factory=dict)
model_id_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict) model_id_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict)
# Multi-valued companion to version_index: every local file entry of a
# CivitAI model version, so versions with several downloaded files stay
# consistent (#1058).
version_files_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict)
name_display_mode: str = "model_name" name_display_mode: str = "model_name"
_lock: Any = field(init=False, repr=False, default=None) _lock: Any = field(init=False, repr=False, default=None)
# Cache for last sort: (sort_key, order, seed) -> sorted list # Cache for last sort: (sort_key, order, seed) -> sorted list
@@ -116,6 +120,7 @@ class ModelCache:
self.version_index = {} self.version_index = {}
self.model_id_index = {} self.model_id_index = {}
self.version_files_index = {}
for item in self.raw_data: for item in self.raw_data:
self.add_to_version_index(item) self.add_to_version_index(item)
@@ -132,6 +137,17 @@ class ModelCache:
self.version_index[version_id] = item self.version_index[version_id] = item
# Register in the multi-valued index, deduplicated by file_path (#1058)
files = self.version_files_index.setdefault(version_id, [])
for entry in files:
if entry is item or (
isinstance(entry, dict)
and entry.get('file_path') == item.get('file_path')
):
break
else:
files.append(item)
model_id = self._normalize_version_id(civitai_data.get('modelId')) model_id = self._normalize_version_id(civitai_data.get('modelId'))
if model_id is None: if model_id is None:
return return
@@ -159,11 +175,36 @@ class ModelCache:
if version_id is None: if version_id is None:
return return
# Drop only this file's entry from the multi-valued index (#1058)
files = self.version_files_index.get(version_id)
if files:
remaining = [
entry
for entry in files
if not (
entry is item
or (
isinstance(entry, dict)
and entry.get('file_path') == item.get('file_path')
)
)
]
if remaining:
self.version_files_index[version_id] = remaining
else:
self.version_files_index.pop(version_id, None)
# A surviving sibling file keeps the version present in the indexes
sibling = (self.version_files_index.get(version_id) or [None])[0]
existing = self.version_index.get(version_id) existing = self.version_index.get(version_id)
if existing is item or ( if existing is item or (
isinstance(existing, dict) isinstance(existing, dict)
and existing.get('file_path') == item.get('file_path') and existing.get('file_path') == item.get('file_path')
): ):
if sibling is not None:
self.version_index[version_id] = sibling
else:
self.version_index.pop(version_id, None) self.version_index.pop(version_id, None)
model_id = self._normalize_version_id(civitai_data.get('modelId')) model_id = self._normalize_version_id(civitai_data.get('modelId'))
@@ -174,6 +215,20 @@ class ModelCache:
if not versions: if not versions:
return return
if sibling is not None:
# Update the descriptor to reflect the surviving sibling file
descriptor = self._build_version_descriptor(
sibling,
sibling.get('civitai') if isinstance(sibling, dict) else {},
version_id,
)
for index, existing_desc in enumerate(versions):
if existing_desc.get('versionId') == version_id:
if descriptor is not None:
versions[index] = descriptor
break
return
filtered = [v for v in versions if v.get('versionId') != version_id] filtered = [v for v in versions if v.get('versionId') != version_id]
if filtered: if filtered:
self.model_id_index[model_id] = filtered self.model_id_index[model_id] = filtered
@@ -206,6 +261,15 @@ class ModelCache:
versions = self.model_id_index.get(normalized_id, []) versions = self.model_id_index.get(normalized_id, [])
return [dict(version) for version in versions] return [dict(version) for version in versions]
def get_files_by_version_id(self, version_id: Any) -> List[Dict[str, Any]]:
"""Return every local file entry for a CivitAI model version (#1058)."""
normalized_id = self._normalize_version_id(version_id)
if normalized_id is None:
return []
return list(self.version_files_index.get(normalized_id, []))
async def resort(self): async def resort(self):
"""Resort cached data according to last sort mode if set""" """Resort cached data according to last sort mode if set"""
async with self._lock: async with self._lock:
+33
View File
@@ -2446,6 +2446,39 @@ class ModelScanner:
logger.error(f"Error checking model version existence: {e}") logger.error(f"Error checking model version existence: {e}")
return False return False
async def get_files_for_version(self, model_version_id: int) -> List[Dict[str, Any]]:
"""Get all local file entries for a specific model version (#1058).
A Civitai model version can have several weight files downloaded;
unlike the single-valued version_index this returns every entry.
Args:
model_version_id: Civitai model version ID
Returns:
List[Dict]: Cache entries (may be empty)
"""
try:
normalized_id = int(model_version_id)
except (TypeError, ValueError):
return []
try:
cache = await self.get_cached_data()
if not cache:
return []
getter = getattr(cache, "get_files_by_version_id", None)
if getter is not None:
return getter(normalized_id)
# Fallback for cache implementations without the multi-file index
entry = cache.version_index.get(normalized_id)
return [entry] if entry is not None else []
except Exception as e:
logger.error(f"Error getting files for model version: {e}")
return []
async def get_model_versions_by_id(self, model_id: int) -> List[Dict[str, Any]]: async def get_model_versions_by_id(self, model_id: int) -> List[Dict[str, Any]]:
"""Get all versions of a model by its ID """Get all versions of a model by its ID
@@ -603,6 +603,51 @@
cursor: pointer; cursor: pointer;
} }
.file-option-radio input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--lora-accent);
cursor: pointer;
}
/* Files already in the library are greyed out and not clickable */
.file-option.disabled {
opacity: 0.55;
cursor: not-allowed;
}
.file-option.disabled:hover {
border-color: var(--border-color);
box-shadow: none;
transform: none;
}
.file-option.disabled input[type="checkbox"] {
cursor: not-allowed;
}
/* Options of the other routing group are temporarily disabled once a
selection is made (mixed-type multi-select is not allowed) */
.file-option.group-disabled {
opacity: 0.6;
cursor: not-allowed;
}
.file-option.group-disabled:hover {
border-color: var(--border-color);
box-shadow: none;
transform: none;
}
.file-option.group-disabled input[type="checkbox"] {
cursor: not-allowed;
}
.file-tag.in-library {
background: oklch(var(--lora-accent) / 0.15);
color: var(--lora-accent);
}
.file-option-info { .file-option-info {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -573,7 +573,6 @@ function renderRow(version, options) {
); );
const actions = []; const actions = [];
if (!version.isInLibrary) {
const canDownload = isDownloadAllowed(version); const canDownload = isDownloadAllowed(version);
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : ''; const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
let downloadTitle; let downloadTitle;
@@ -583,6 +582,14 @@ function renderRow(version, options) {
{}, {},
'This version is only available for on-site generation on Civitai' 'This version is only available for on-site generation on Civitai'
); );
} else if (version.isInLibrary) {
// In-library versions may still have undownloaded weight files; the
// download modal's file dialog decides what remains (#1058).
downloadTitle = translate(
'modals.model.versions.actions.downloadRemainingTooltip',
{},
'Download remaining files of this version'
);
} else if (isPaidPermanent(version)) { } else if (isPaidPermanent(version)) {
downloadTitle = translate( downloadTitle = translate(
'modals.model.versions.actions.downloadPaidTooltip', 'modals.model.versions.actions.downloadPaidTooltip',
@@ -612,7 +619,7 @@ function renderRow(version, options) {
disabled: !canDownload, disabled: !canDownload,
} }
)); ));
} else if (version.filePath) { if (version.isInLibrary && version.filePath) {
actions.push(buildActionButton( actions.push(buildActionButton(
deleteLabel, deleteLabel,
'version-action-danger', 'version-action-danger',
@@ -1422,6 +1429,15 @@ export function initVersionsTab({
button.disabled = true; button.disabled = true;
try { try {
// In-library versions may still have undownloaded weight files
// (#1058). The tab payload has no per-file state, so open the
// download modal's file dialog, which refetches the full version
// payload and shows what remains.
if (version.isInLibrary) {
await downloadManager.openFileSelectionForVersion(modelType, modelId, versionId);
return;
}
const pathInfo = await resolveDownloadPathFromCurrentVersion(); const pathInfo = await resolveDownloadPathFromCurrentVersion();
const resolveTemplatePath = shouldResolveTemplatePath(version, pathInfo); const resolveTemplatePath = shouldResolveTemplatePath(version, pathInfo);
const success = await downloadManager.downloadVersionWithDefaults(modelType, modelId, versionId, { const success = await downloadManager.downloadVersionWithDefaults(modelType, modelId, versionId, {
+297 -23
View File
@@ -25,6 +25,12 @@ export class DownloadManager {
this.apiClient = null; this.apiClient = null;
this.useDefaultPath = false; this.useDefaultPath = false;
// Multi-file selection state: selectedFile stays the first selected
// file for backward compatibility with single-file flows (#1058).
this.selectedFile = null;
this.selectedFiles = [];
this._lastDownloadError = null;
// Batch mode state // Batch mode state
this.batchModels = []; this.batchModels = [];
this.isBatchMode = false; this.isBatchMode = false;
@@ -160,6 +166,8 @@ export class DownloadManager {
this.modelVersionId = null; this.modelVersionId = null;
this.source = null; this.source = null;
this.selectedFile = null; this.selectedFile = null;
this.selectedFiles = [];
this._lastDownloadError = null;
this._isDiffusionModel = false; this._isDiffusionModel = false;
this.selectedFolder = ''; this.selectedFolder = '';
@@ -546,6 +554,64 @@ export class DownloadManager {
await this.fetchVersionsForCurrentModel(); await this.fetchVersionsForCurrentModel();
} }
/**
* Open the download modal directly on the file-selection step for a
* specific model version (#1058). Used by entry points (e.g.
* ModelVersionsTab) whose version payloads lack per-file downloaded
* state, so the full versions payload is fetched here first.
*/
async openFileSelectionForVersion(modelType, modelId, versionId, { source = null } = {}) {
try {
this.apiClient = getModelApiClient(modelType);
} catch (error) {
this.apiClient = getModelApiClient();
}
this.showDownloadModal();
this.modelId = modelId ? modelId.toString() : null;
this.modelVersionId = versionId ? versionId.toString() : null;
this.source = source;
if (!this.modelId) {
return;
}
try {
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions'));
await this.retrieveVersionsForModel(this.modelId, this.source);
} catch (error) {
showToast('toast.downloads.loadError', { message: error.message }, 'error');
return;
} finally {
this.loadingManager.hide();
}
const version = this.versions.find(v => v.id.toString() === this.modelVersionId);
if (!version) {
console.warn('[download] openFileSelectionForVersion: version %s not found for model %s',
this.modelVersionId, this.modelId);
this.showVersionStep();
return;
}
const hasRemainingFiles = this._getWeightFiles(version).length > 1
&& this._getRemainingFiles(version).length > 0;
if (hasRemainingFiles) {
this.showFileSelectionStep(version.id);
return;
}
// Nothing left to download for this version (single file or all
// files already in the library) — fall back to the version step.
if (version.existsLocally) {
showToast('toast.loras.versionExists', {}, 'info');
}
this.currentVersion = version;
this.showVersionStep();
}
showVersionStep() { showVersionStep() {
document.getElementById('urlStep').style.display = 'none'; document.getElementById('urlStep').style.display = 'none';
document.getElementById('versionStep').style.display = 'block'; document.getElementById('versionStep').style.display = 'block';
@@ -670,9 +736,14 @@ export class DownloadManager {
const nextButton = document.getElementById('nextFromVersion'); const nextButton = document.getElementById('nextFromVersion');
if (!nextButton) return; if (!nextButton) return;
const existsLocally = this.currentVersion?.existsLocally; const version = this.currentVersion;
const existsLocally = version?.existsLocally;
// A partially downloaded multi-file version still has downloadable
// files, so Next routes into the file dialog instead of blocking (#1058).
const hasRemainingFiles = this._getWeightFiles(version).length > 1
&& this._getRemainingFiles(version).length > 0;
if (existsLocally) { if (existsLocally && !hasRemainingFiles) {
nextButton.disabled = true; nextButton.disabled = true;
nextButton.classList.add('disabled'); nextButton.classList.add('disabled');
nextButton.textContent = translate('modals.download.alreadyInLibrary'); nextButton.textContent = translate('modals.download.alreadyInLibrary');
@@ -683,12 +754,36 @@ export class DownloadManager {
} }
} }
_getWeightFiles(version) {
return (version?.files || []).filter(f => isModelWeightFile(f.type));
}
_getRemainingFiles(version) {
const downloadedIds = new Set(
(version?.downloadedFiles || []).map(f => String(f.fileId))
);
return this._getWeightFiles(version).filter(f => !downloadedIds.has(String(f.id)));
}
// Files of type UNet / Diffusion Model are routed to the diffusion_model
// root while regular files go to the model-type root, so a single
// multi-file selection session must stay within one routing group.
_getFileRoutingGroup(file) {
return (file.type === 'UNet' || file.type === 'Diffusion Model') ? 'diffusion' : 'model';
}
showFileSelectionStep(versionId) { showFileSelectionStep(versionId) {
const version = this.versions.find(v => v.id.toString() === versionId.toString()); const version = this.versions.find(v => v.id.toString() === versionId.toString());
if (!version) return; if (!version) return;
this.currentVersion = version; this.currentVersion = version;
const modelFiles = (version.files || []).filter(f => isModelWeightFile(f.type)); // Start each file-selection session with a clean selection
this.selectedFiles = [];
this.selectedFile = null;
const modelFiles = this._getWeightFiles(version);
const downloadedIds = new Set(
(version.downloadedFiles || []).map(f => String(f.fileId))
);
document.getElementById('versionStep').style.display = 'none'; document.getElementById('versionStep').style.display = 'none';
document.getElementById('fileSelectionStep').style.display = 'block'; document.getElementById('fileSelectionStep').style.display = 'block';
@@ -702,9 +797,12 @@ export class DownloadManager {
container.innerHTML = modelFiles.map(file => { container.innerHTML = modelFiles.map(file => {
const meta = file.metadata || {}; const meta = file.metadata || {};
const sizeGB = file.sizeKB ? (file.sizeKB / (1024 * 1024)).toFixed(2) : '--'; const sizeGB = file.sizeKB ? (file.sizeKB / (1024 * 1024)).toFixed(2) : '--';
const isSelected = this.selectedFile?.id === file.id; const isDownloaded = downloadedIds.has(String(file.id));
const tags = []; const tags = [];
if (isDownloaded) {
tags.push(`<span class="file-tag in-library">${translate('modals.download.fileSelection.inLibrary', {}, 'In Library')}</span>`);
}
if (meta.size) tags.push(`<span class="file-tag size">${meta.size}</span>`); if (meta.size) tags.push(`<span class="file-tag size">${meta.size}</span>`);
if (meta.format) tags.push(`<span class="file-tag format">${meta.format}</span>`); if (meta.format) tags.push(`<span class="file-tag format">${meta.format}</span>`);
if (meta.fp) tags.push(`<span class="file-tag fp">${meta.fp}</span>`); if (meta.fp) tags.push(`<span class="file-tag fp">${meta.fp}</span>`);
@@ -712,9 +810,9 @@ export class DownloadManager {
const fileName = file.name || ''; const fileName = file.name || '';
return ` return `
<div class="file-option ${isSelected ? 'selected' : ''}" data-file-id="${file.id}"> <div class="file-option ${isDownloaded ? 'disabled' : ''}" data-file-id="${file.id}">
<div class="file-option-radio"> <div class="file-option-radio">
<input type="radio" name="fileSelection" value="${file.id}" ${isSelected ? 'checked' : ''}> <input type="checkbox" name="fileSelection" value="${file.id}" ${isDownloaded ? 'disabled' : ''}>
</div> </div>
<div class="file-option-info"> <div class="file-option-info">
<div class="file-option-tags"> <div class="file-option-tags">
@@ -728,33 +826,80 @@ export class DownloadManager {
}).join(''); }).join('');
container.querySelectorAll('.file-option').forEach(el => { container.querySelectorAll('.file-option').forEach(el => {
el.addEventListener('click', () => { el.addEventListener('click', (event) => {
container.querySelectorAll('.file-option').forEach(o => o.classList.remove('selected')); // Already-downloaded files stay disabled regardless
el.classList.add('selected'); if (el.classList.contains('disabled')) {
const radio = el.querySelector('input[type="radio"]'); event.preventDefault();
if (radio) radio.checked = true; return;
}
const checkbox = el.querySelector('input[type="checkbox"]');
if (!checkbox || checkbox.disabled) {
event.preventDefault();
return;
}
// Clicking the checkbox directly toggles natively; clicking
// anywhere else on the option toggles it programmatically.
if (event.target !== checkbox) {
checkbox.checked = !checkbox.checked;
}
this._syncFileSelectionState();
}); });
}); });
} }
// Sync this.selectedFiles with the DOM checkboxes and enforce the
// mixed-type routing guard by disabling the other routing group.
_syncFileSelectionState() {
const container = document.getElementById('fileSelectionList');
if (!container || !this.currentVersion) return;
const checkedValues = new Set(
Array.from(container.querySelectorAll('input[type="checkbox"]:checked'))
.map(cb => cb.value)
);
const modelFiles = this._getWeightFiles(this.currentVersion);
this.selectedFiles = modelFiles.filter(f => checkedValues.has(f.id.toString()));
this.selectedFile = this.selectedFiles[0] || null;
const activeGroup = this.selectedFiles.length > 0
? this._getFileRoutingGroup(this.selectedFiles[0])
: null;
container.querySelectorAll('.file-option').forEach(el => {
const checkbox = el.querySelector('input[type="checkbox"]');
if (!checkbox || el.classList.contains('disabled')) return;
const file = modelFiles.find(f => f.id.toString() === el.dataset.fileId);
const groupBlocked = activeGroup !== null
&& file
&& this._getFileRoutingGroup(file) !== activeGroup
&& !checkbox.checked;
el.classList.toggle('selected', checkbox.checked);
el.classList.toggle('group-disabled', groupBlocked);
checkbox.disabled = groupBlocked;
});
}
confirmFileSelection() { confirmFileSelection() {
const selectedRadio = document.querySelector('#fileSelectionList input[type="radio"]:checked');
if (!selectedRadio) {
console.warn('[download] confirmFileSelection: no radio button checked');
return;
}
const version = this.currentVersion; const version = this.currentVersion;
if (!version) { if (!version) {
console.warn('[download] confirmFileSelection: no currentVersion set'); console.warn('[download] confirmFileSelection: no currentVersion set');
return; return;
} }
const modelFiles = (version.files || []).filter(f => isModelWeightFile(f.type)); // Sync from the DOM first so programmatically checked boxes count too
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value); this._syncFileSelectionState();
console.log('[download] confirmFileSelection: selected file id=%s, name="%s", type="%s", metadata=%o', if (this.selectedFiles.length === 0) {
this.selectedFile?.id, this.selectedFile?.name, this.selectedFile?.type, this.selectedFile?.metadata); console.warn('[download] confirmFileSelection: no file selected');
showToast('toast.loras.pleaseSelectFile', {}, 'error');
return;
}
console.log('[download] confirmFileSelection: %d file(s) selected — %o',
this.selectedFiles.length,
this.selectedFiles.map(f => ({ id: f.id, name: f.name, type: f.type })));
document.getElementById('fileSelectionStep').style.display = 'none'; document.getElementById('fileSelectionStep').style.display = 'none';
document.getElementById('downloadLocationStep').style.display = 'block'; document.getElementById('downloadLocationStep').style.display = 'block';
@@ -785,6 +930,13 @@ export class DownloadManager {
return; return;
} }
if (this.currentVersion.existsLocally) { if (this.currentVersion.existsLocally) {
// Multi-file versions with remaining undownloaded files route
// into the file dialog instead of being blocked outright (#1058).
if (this._getWeightFiles(this.currentVersion).length > 1
&& this._getRemainingFiles(this.currentVersion).length > 0) {
this.showFileSelectionStep(this.currentVersion.id);
return;
}
showToast('toast.loras.versionExists', {}, 'info'); showToast('toast.loras.versionExists', {}, 'info');
return; return;
} }
@@ -919,6 +1071,9 @@ export class DownloadManager {
source = null, source = null,
fileParams = null, fileParams = null,
closeModal = false, closeModal = false,
deferReload = false,
suppressSuccessToast = false,
suppressFailureSummary = false,
}) { }) {
const config = this.apiClient?.apiConfig?.config; const config = this.apiClient?.apiConfig?.config;
@@ -927,7 +1082,8 @@ export class DownloadManager {
} }
const displayName = versionName || `#${versionId}`; const displayName = versionName || `#${versionId}`;
const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, useSaveDirAsRoot, source, fileParams, closeModal: false }; const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, useSaveDirAsRoot, source, fileParams, closeModal: false, deferReload, suppressSuccessToast, suppressFailureSummary };
this._lastDownloadError = null;
let ws = null; let ws = null;
let updateProgress = () => { }; let updateProgress = () => { };
let cancelled = false; let cancelled = false;
@@ -1010,7 +1166,9 @@ export class DownloadManager {
if (response?.skipped) { if (response?.skipped) {
this.loadingManager.setStatus(translate('modals.download.status.finalizing')); this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
updateProgress(100, 0, displayName); updateProgress(100, 0, displayName);
if (!suppressSuccessToast) {
showToast('toast.loras.downloadSkippedByBaseModel', { baseModel: response.base_model || 'Unknown' }, 'warning'); showToast('toast.loras.downloadSkippedByBaseModel', { baseModel: response.base_model || 'Unknown' }, 'warning');
}
if (closeModal) { if (closeModal) {
modalManager.closeModal('downloadModal'); modalManager.closeModal('downloadModal');
} }
@@ -1020,6 +1178,12 @@ export class DownloadManager {
if (!response?.success) { if (!response?.success) {
this.loadingManager.setStatus(translate('modals.download.status.finalizing')); this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
const errorMessage = response?.error || 'Unknown error'; const errorMessage = response?.error || 'Unknown error';
// When the caller aggregates failures itself (multi-file
// loop), just record the error and return (#1058).
if (suppressFailureSummary) {
this._lastDownloadError = errorMessage;
return false;
}
// A file-level "already in library" rejection is an expected // A file-level "already in library" rejection is an expected
// outcome when browsing files of a partially downloaded // outcome when browsing files of a partially downloaded
// version — surface it as a lightweight toast instead of the // version — surface it as a lightweight toast instead of the
@@ -1047,7 +1211,9 @@ export class DownloadManager {
return false; return false;
} }
if (!suppressSuccessToast) {
showToast('toast.loras.downloadCompleted', {}, 'success'); showToast('toast.loras.downloadCompleted', {}, 'success');
}
if (closeModal) { if (closeModal) {
modalManager.closeModal('downloadModal'); modalManager.closeModal('downloadModal');
@@ -1058,6 +1224,7 @@ export class DownloadManager {
ws = null; ws = null;
} }
if (!deferReload) {
const pageState = this.apiClient.getPageState(); const pageState = this.apiClient.getPageState();
if (!useDefaultPaths && targetFolder) { if (!useDefaultPaths && targetFolder) {
@@ -1074,6 +1241,7 @@ export class DownloadManager {
} }
await resetAndReload(true); await resetAndReload(true);
}
return true; return true;
} catch (error) { } catch (error) {
@@ -1081,6 +1249,10 @@ export class DownloadManager {
console.log('Download cancelled by user:', downloadId); console.log('Download cancelled by user:', downloadId);
} else { } else {
console.error('Failed to download model version:', error); console.error('Failed to download model version:', error);
if (suppressFailureSummary) {
this._lastDownloadError = error?.message || 'Unknown error';
return false;
}
showDownloadBatchSummary({ showDownloadBatchSummary({
total: 1, total: 1,
completed: 0, completed: 0,
@@ -1110,6 +1282,89 @@ export class DownloadManager {
} }
} }
/**
* Download multiple selected files of the same version sequentially,
* reusing the location-step choices for every file. Per-file toasts,
* reloads and failure modals are suppressed; a single aggregated result
* is shown at the end (design decision D5, #1058).
*/
async _downloadSelectedFilesSequentially({ modelRoot, targetFolder, useDefaultPaths, useSaveDirAsRoot = false, files = null }) {
const filesToDownload = files || this.selectedFiles;
const totalFiles = filesToDownload.length;
const failedItems = [];
let completedDownloads = 0;
for (const file of filesToDownload) {
const fileParams = {
id: file.id,
name: file.name || null,
type: file.type || 'Model',
format: file.metadata?.format || null,
size: file.metadata?.size || null,
fp: file.metadata?.fp || null,
};
console.log('[download] multi-file loop: downloading file id=%s, name="%s" (%d/%d)',
fileParams.id, fileParams.name, completedDownloads + failedItems.length + 1, totalFiles);
const success = await this.executeDownloadWithProgress({
modelId: this.modelId,
versionId: this.currentVersion.id,
versionName: file.name || `${this.currentVersion.name} #${file.id}`,
modelRoot,
targetFolder,
useDefaultPaths,
useSaveDirAsRoot,
source: this.source,
fileParams,
closeModal: false,
deferReload: true,
suppressSuccessToast: true,
suppressFailureSummary: true,
});
if (success) {
completedDownloads++;
} else {
failedItems.push({
item: {
modelId: this.modelId,
versionId: this.currentVersion.id,
source: this.source,
file,
url: this._buildSingleItemUrl({
modelId: this.modelId,
versionId: this.currentVersion.id,
source: this.source,
}),
},
error: this._lastDownloadError || 'Unknown error',
name: file.name || `#${file.id}`,
});
}
}
if (failedItems.length === 0) {
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
} else {
showDownloadBatchSummary({
total: totalFiles,
completed: completedDownloads,
failedItems,
onRetry: () => this._downloadSelectedFilesSequentially({
modelRoot,
targetFolder,
useDefaultPaths,
useSaveDirAsRoot,
files: failedItems.map(f => f.item.file),
}),
});
}
await resetAndReload(true);
return failedItems.length === 0;
}
async _downloadHfSingle({ modelRoot, targetFolder, useDefaultPaths, files = null }) { async _downloadHfSingle({ modelRoot, targetFolder, useDefaultPaths, files = null }) {
modalManager.closeModal('downloadModal'); modalManager.closeModal('downloadModal');
this.loadingManager.restoreProgressBar(); this.loadingManager.restoreProgressBar();
@@ -1320,6 +1575,14 @@ export class DownloadManager {
? (ver.modelSizeKB / 1024).toFixed(1) ? (ver.modelSizeKB / 1024).toFixed(1)
: (ver?.files?.[0]?.sizeKB ? (ver.files[0].sizeKB / 1024).toFixed(1) : '?'); : (ver?.files?.[0]?.sizeKB ? (ver.files[0].sizeKB / 1024).toFixed(1) : '?');
const existsLocally = ver?.existsLocally; const existsLocally = ver?.existsLocally;
// Multi-file versions that are only partially downloaded get a
// distinct hint instead of the plain in-library badge (#1058).
const isPartiallyDownloaded = existsLocally
&& this._getWeightFiles(ver).length > 1
&& this._getRemainingFiles(ver).length > 0;
const localBadgeLabel = isPartiallyDownloaded
? translate('modals.download.partiallyDownloaded', {}, 'Partially downloaded')
: translate('modals.download.inLibrary');
return ` return `
<div class="batch-preview-item ${existsLocally ? 'batch-preview-local' : ''}" data-index="${index}"> <div class="batch-preview-item ${existsLocally ? 'batch-preview-local' : ''}" data-index="${index}">
<div class="batch-preview-thumbnail"> <div class="batch-preview-thumbnail">
@@ -1330,7 +1593,7 @@ export class DownloadManager {
<div class="batch-preview-meta"> <div class="batch-preview-meta">
${ver?.baseModel ? `<span>${ver.baseModel}</span>` : ''} ${ver?.baseModel ? `<span>${ver.baseModel}</span>` : ''}
<span>${fileSize} MB</span> <span>${fileSize} MB</span>
${existsLocally ? `<span class="batch-preview-local-badge"><i class="fas fa-check"></i> ${translate('modals.download.inLibrary')}</span>` : ''} ${existsLocally ? `<span class="batch-preview-local-badge"><i class="fas fa-check"></i> ${localBadgeLabel}</span>` : ''}
</div> </div>
</div> </div>
${item.versions.length > 1 ? ` ${item.versions.length > 1 ? `
@@ -1621,6 +1884,17 @@ export class DownloadManager {
}); });
} }
// Multi-file selection: download all selected files sequentially,
// reusing the chosen location for every file (#1058).
if (this.selectedFiles.length > 1) {
modalManager.closeModal('downloadModal');
return this._downloadSelectedFilesSequentially({
modelRoot,
targetFolder,
useDefaultPaths,
});
}
const fileParams = this.selectedFile ? { const fileParams = this.selectedFile ? {
id: this.selectedFile.id, id: this.selectedFile.id,
name: this.selectedFile.name || null, name: this.selectedFile.name || null,
@@ -0,0 +1,317 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const {
DOWNLOAD_MANAGER_MODULE,
MODAL_MANAGER_MODULE,
UI_HELPERS_MODULE,
STATE_MODULE,
LOADING_MANAGER_MODULE,
API_FACTORY_MODULE,
STORAGE_HELPERS_MODULE,
FOLDER_TREE_MANAGER_MODULE,
I18N_HELPERS_MODULE,
SUMMARY_MODULE,
mockApiClient,
mockLoadingManager,
showToastMock,
showDownloadBatchSummaryMock,
resetAndReloadMock,
} = vi.hoisted(() => {
// Shared API client returned by the mocked getModelApiClient factory.
const mockApiClient = {
modelType: 'loras',
apiConfig: {
config: {
displayName: 'LoRA',
singularName: 'lora',
},
},
fetchCivitaiVersions: vi.fn(),
fetchModelRoots: vi.fn(async () => ({ roots: ['/models/loras'] })),
fetchUnifiedFolderTree: vi.fn(async () => ({ success: false })),
downloadModel: vi.fn(),
cancelDownload: vi.fn(),
getPageState: vi.fn(() => ({})),
};
const mockLoadingManager = {
showSimpleLoading: vi.fn(),
setStatus: vi.fn(),
hide: vi.fn(),
restoreProgressBar: vi.fn(),
showDownloadProgress: vi.fn(() => vi.fn()),
showCancelButton: vi.fn(),
};
return {
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
LOADING_MANAGER_MODULE: new URL('../../../static/js/managers/LoadingManager.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname,
mockApiClient,
mockLoadingManager,
showToastMock: vi.fn(),
showDownloadBatchSummaryMock: vi.fn(),
resetAndReloadMock: vi.fn(),
};
});
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: {
showModal: vi.fn(),
closeModal: vi.fn(),
},
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: showToastMock,
setupAutoNewlineOnPaste: vi.fn(),
}));
vi.mock(STATE_MODULE, () => ({
state: {
global: {
settings: {},
},
loadingManager: mockLoadingManager,
},
}));
vi.mock(LOADING_MANAGER_MODULE, () => ({
LoadingManager: vi.fn(() => mockLoadingManager),
}));
vi.mock(API_FACTORY_MODULE, () => ({
getModelApiClient: vi.fn(() => mockApiClient),
resetAndReload: resetAndReloadMock,
}));
vi.mock(STORAGE_HELPERS_MODULE, () => ({
getStorageItem: vi.fn((_key, defaultValue) => defaultValue),
setStorageItem: vi.fn(),
}));
vi.mock(FOLDER_TREE_MANAGER_MODULE, () => ({
FolderTreeManager: vi.fn(() => ({
clearSelection: vi.fn(),
init: vi.fn(),
getSelectedPath: vi.fn(() => ''),
})),
}));
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((_, __, fallback) => fallback ?? ''),
}));
vi.mock(SUMMARY_MODULE, () => ({
showDownloadBatchSummary: showDownloadBatchSummaryMock,
}));
/** DOM covering the file-selection, version and location steps. */
function setupDownloadDom() {
document.body.innerHTML = `
<div id="downloadModal">
<div class="download-step" id="urlStep"></div>
<div class="download-step" id="versionStep"></div>
<div class="download-step" id="fileSelectionStep"></div>
<div class="download-step" id="downloadLocationStep"></div>
<div id="fileSelectionList"></div>
<div id="fileSelectionVersionName"></div>
<button id="nextFromVersion"></button>
<div id="downloadModalTitle"></div>
<select id="modelRoot"></select>
<input id="folderPath" />
<div id="targetPathDisplay"></div>
<input id="useDefaultPath" type="checkbox" />
<div id="manualPathSelection"></div>
</div>
`;
}
function makeMultiFileVersion(overrides = {}) {
return {
id: 201,
name: 'Multi-file version',
baseModel: 'SDXL',
images: [],
files: [
{ id: 1001, type: 'Model', sizeKB: 2048, name: 'file-a.safetensors' },
{ id: 1002, type: 'Model', sizeKB: 2048, name: 'file-b.safetensors' },
{ id: 1003, type: 'Model', sizeKB: 2048, name: 'file-c.safetensors' },
],
createdAt: '2026-01-01T00:00:00Z',
existsLocally: true,
...overrides,
};
}
function getFileOption(fileId) {
return document.querySelector(`.file-option[data-file-id="${fileId}"]`);
}
describe('DownloadManager multi-select file dialog (#1058)', () => {
let DownloadManager;
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
setupDownloadDom();
({ DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE));
});
afterEach(() => {
document.body.innerHTML = '';
});
it('renders downloaded files disabled with an In Library tag', () => {
const manager = new DownloadManager();
manager.versions = [makeMultiFileVersion({
downloadedFiles: [
{ fileId: 1001, fileName: 'file-a.safetensors', filePath: '/models/loras/file-a.safetensors' },
],
})];
manager.showFileSelectionStep('201');
const downloadedOption = getFileOption('1001');
expect(downloadedOption.classList.contains('disabled')).toBe(true);
expect(downloadedOption.querySelector('input[type="checkbox"]').disabled).toBe(true);
expect(downloadedOption.querySelector('.file-tag.in-library').textContent).toBe('In Library');
// Remaining files stay selectable
const otherOption = getFileOption('1002');
expect(otherOption.classList.contains('disabled')).toBe(false);
expect(otherOption.querySelector('input[type="checkbox"]').disabled).toBe(false);
});
it('ignores clicks on already-downloaded options', () => {
const manager = new DownloadManager();
manager.versions = [makeMultiFileVersion({
downloadedFiles: [
{ fileId: 1001, fileName: 'file-a.safetensors', filePath: '/models/loras/file-a.safetensors' },
],
})];
manager.showFileSelectionStep('201');
getFileOption('1001').click();
expect(getFileOption('1001').querySelector('input[type="checkbox"]').checked).toBe(false);
expect(manager.selectedFiles).toHaveLength(0);
});
it('confirmFileSelection collects multiple checked files into selectedFiles', () => {
const manager = new DownloadManager();
manager.apiClient = mockApiClient;
manager.versions = [makeMultiFileVersion({ downloadedFiles: [] })];
manager.showFileSelectionStep('201');
getFileOption('1001').click();
getFileOption('1003').click();
manager.confirmFileSelection();
expect(manager.selectedFiles.map(f => f.id)).toEqual([1001, 1003]);
// selectedFile stays the first selected file for single-file flows
expect(manager.selectedFile?.id).toBe(1001);
expect(document.getElementById('fileSelectionStep').style.display).toBe('none');
expect(document.getElementById('downloadLocationStep').style.display).toBe('block');
});
it('confirmFileSelection warns when nothing is selected', () => {
const manager = new DownloadManager();
manager.versions = [makeMultiFileVersion({ downloadedFiles: [] })];
manager.showFileSelectionStep('201');
manager.confirmFileSelection();
expect(showToastMock).toHaveBeenCalledWith('toast.loras.pleaseSelectFile', {}, 'error');
expect(manager.selectedFiles).toHaveLength(0);
expect(document.getElementById('downloadLocationStep').style.display).not.toBe('block');
});
it('disables the other routing group once a file is checked and re-enables when unchecked', () => {
const manager = new DownloadManager();
manager.versions = [makeMultiFileVersion({
downloadedFiles: [],
files: [
{ id: 1001, type: 'UNet', sizeKB: 2048, name: 'unet-a.safetensors' },
{ id: 1002, type: 'Model', sizeKB: 2048, name: 'file-b.safetensors' },
{ id: 1003, type: 'Model', sizeKB: 2048, name: 'file-c.safetensors' },
],
})];
manager.showFileSelectionStep('201');
// Checking a regular Model file disables the UNet option
getFileOption('1002').click();
expect(getFileOption('1001').classList.contains('group-disabled')).toBe(true);
expect(getFileOption('1001').querySelector('input[type="checkbox"]').disabled).toBe(true);
expect(getFileOption('1003').classList.contains('group-disabled')).toBe(false);
// Clicking a group-disabled option does nothing
getFileOption('1001').click();
expect(manager.selectedFiles.map(f => f.id)).toEqual([1002]);
// Unchecking everything re-enables the other group
getFileOption('1002').click();
expect(manager.selectedFiles).toHaveLength(0);
expect(getFileOption('1001').classList.contains('group-disabled')).toBe(false);
expect(getFileOption('1001').querySelector('input[type="checkbox"]').disabled).toBe(false);
});
it('keeps Next enabled for a partially downloaded multi-file version', () => {
const manager = new DownloadManager();
manager.currentVersion = makeMultiFileVersion({
downloadedFiles: [
{ fileId: 1001, fileName: 'file-a.safetensors', filePath: '/models/loras/file-a.safetensors' },
],
});
manager.updateNextButtonState();
const nextButton = document.getElementById('nextFromVersion');
expect(nextButton.disabled).toBe(false);
expect(nextButton.classList.contains('disabled')).toBe(false);
});
it('disables Next when every weight file is already downloaded', () => {
const manager = new DownloadManager();
manager.currentVersion = makeMultiFileVersion({
downloadedFiles: [
{ fileId: 1001, fileName: 'file-a.safetensors', filePath: '/models/loras/file-a.safetensors' },
{ fileId: 1002, fileName: 'file-b.safetensors', filePath: '/models/loras/file-b.safetensors' },
{ fileId: 1003, fileName: 'file-c.safetensors', filePath: '/models/loras/file-c.safetensors' },
],
});
manager.updateNextButtonState();
const nextButton = document.getElementById('nextFromVersion');
expect(nextButton.disabled).toBe(true);
expect(nextButton.classList.contains('disabled')).toBe(true);
});
it('disables Next for an in-library single-file version', () => {
const manager = new DownloadManager();
manager.currentVersion = {
id: 202,
name: 'Single-file version',
files: [{ id: 1004, type: 'Model', sizeKB: 2048, name: 'file-d.safetensors' }],
existsLocally: true,
downloadedFiles: [],
};
manager.updateNextButtonState();
const nextButton = document.getElementById('nextFromVersion');
expect(nextButton.disabled).toBe(true);
});
});
@@ -0,0 +1,77 @@
"""Unit tests for per-file downloaded-state matching (#1058)."""
from py.routes.handlers.model_handlers import ModelCivitaiHandler
VERSION = {
"id": 42,
"files": [
{
"id": 1001,
"name": "file-a.safetensors",
"hashes": {"SHA256": "AAA111"},
},
{
"id": 1002,
"name": "file-b.safetensors",
"hashes": {"SHA256": "BBB222"},
},
],
}
def _entry(file_name: str, sha256: str = "", file_path: str | None = None):
return {
"file_name": file_name,
"file_path": file_path or f"/models/{file_name}.safetensors",
"sha256": sha256,
}
def test_matches_by_sha256():
entries = [_entry("renamed-locally", "bbb222")]
result = ModelCivitaiHandler._match_downloaded_files(VERSION, entries)
assert result == [
{
"fileId": 1002,
"fileName": "file-b.safetensors",
"filePath": "/models/renamed-locally.safetensors",
}
]
def test_falls_back_to_name_when_hash_missing():
entries = [_entry("file-a", "")]
result = ModelCivitaiHandler._match_downloaded_files(VERSION, entries)
assert [r["fileId"] for r in result] == [1001]
def test_hash_takes_precedence_over_name():
# Hash points at file-b while the name points at file-a: hash wins.
entries = [_entry("file-a", "bbb222")]
result = ModelCivitaiHandler._match_downloaded_files(VERSION, entries)
assert [r["fileId"] for r in result] == [1002]
def test_unmatched_entries_are_skipped():
entries = [
_entry("unrelated", "ccc333"),
_entry("file-b", ""), # name match
]
result = ModelCivitaiHandler._match_downloaded_files(VERSION, entries)
assert [r["fileId"] for r in result] == [1002]
def test_multiple_files_of_same_version():
entries = [
_entry("file-a", "aaa111"),
_entry("file-b", "bbb222"),
]
result = ModelCivitaiHandler._match_downloaded_files(VERSION, entries)
assert [r["fileId"] for r in result] == [1001, 1002]
def test_empty_inputs():
assert ModelCivitaiHandler._match_downloaded_files(VERSION, []) == []
assert ModelCivitaiHandler._match_downloaded_files({"id": 1}, [_entry("x")]) == []
assert ModelCivitaiHandler._match_downloaded_files(VERSION, None) == []
@@ -68,3 +68,94 @@ async def test_download_history_bulk_lookup(tmp_path: Path) -> None:
5: {501, 502}, 5: {501, 502},
6: {601}, 6: {601},
} }
@pytest.mark.asyncio
async def test_per_file_history_tracking(tmp_path: Path) -> None:
"""Per-file records coexist with the version-level row (#1058)."""
db_path = tmp_path / "download-history.sqlite"
service = DownloadedVersionHistoryService(
str(db_path),
settings_manager=DummySettings(),
)
await service.mark_downloaded(
"lora", 101, model_id=11, source="download",
file_path="/models/a.safetensors", file_id=1001, file_name="a.safetensors",
)
await service.mark_downloaded(
"lora", 101, model_id=11, source="download",
file_path="/models/b.safetensors", file_id=1002, file_name="b.safetensors",
)
assert await service.get_downloaded_file_ids("lora", 101) == [1001, 1002]
# Version-level tracking remains single-row per version
assert await service.get_downloaded_version_ids("lora", 11) == [101]
# Re-downloading the same file updates in place, no duplicate
await service.mark_downloaded(
"lora", 101, source="download", file_id=1001, file_name="a.safetensors",
)
assert await service.get_downloaded_file_ids("lora", 101) == [1001, 1002]
# Single-file deletion keeps the sibling record
await service.mark_file_deleted("lora", 101, 1001)
assert await service.get_downloaded_file_ids("lora", 101) == [1002]
# Whole-version deletion clears per-file records
await service.mark_as_deleted("lora", 101)
assert await service.get_downloaded_file_ids("lora", 101) == []
assert await service.has_been_downloaded("lora", 101) is False
@pytest.mark.asyncio
async def test_per_file_history_ignores_invalid_ids(tmp_path: Path) -> None:
service = DownloadedVersionHistoryService(
str(tmp_path / "download-history.sqlite"),
settings_manager=DummySettings(),
)
# mark_downloaded without a file id only touches the version-level table
await service.mark_downloaded("lora", 201, model_id=21, source="scan")
assert await service.get_downloaded_file_ids("lora", 201) == []
# Invalid inputs are no-ops
await service.mark_file_deleted("lora", 201, None) # type: ignore[arg-type]
assert await service.get_downloaded_file_ids("unknown-type", 201) == []
@pytest.mark.asyncio
async def test_file_history_table_created_for_legacy_db(tmp_path: Path) -> None:
"""Existing databases gain the per-file table via CREATE IF NOT EXISTS."""
import sqlite3
db_path = tmp_path / "download-history.sqlite"
conn = sqlite3.connect(db_path)
conn.executescript(
"""
CREATE TABLE downloaded_model_versions (
model_type TEXT NOT NULL,
version_id INTEGER NOT NULL,
model_id INTEGER,
first_seen_at REAL NOT NULL,
last_seen_at REAL NOT NULL,
source TEXT NOT NULL,
last_file_path TEXT,
last_library_name TEXT,
is_deleted_override INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (model_type, version_id)
);
"""
)
conn.close()
service = DownloadedVersionHistoryService(
str(db_path),
settings_manager=DummySettings(),
)
await service.mark_downloaded(
"lora", 301, model_id=31, source="download",
file_id=9001, file_name="file.safetensors",
)
assert await service.get_downloaded_file_ids("lora", 301) == [9001]
assert await service.has_been_downloaded("lora", 301) is True
+84
View File
@@ -61,3 +61,87 @@ async def test_model_cache_tracks_versions_by_model_id():
assert cache.get_versions_by_model_id(2) == [ assert cache.get_versions_by_model_id(2) == [
{'versionId': 201, 'name': 'Gamma', 'fileName': 'model-b'}, {'versionId': 201, 'name': 'Gamma', 'fileName': 'model-b'},
] ]
@pytest.mark.asyncio
async def test_version_files_index_tracks_multiple_files_per_version():
"""Two downloaded files of the same version both stay indexed (#1058)."""
item_a = {
'file_path': '/models/v1-a.safetensors',
'file_name': 'model-v1-a',
'folder': '',
'civitai': {'id': 301, 'modelId': 3, 'name': 'Multi'},
}
item_b = {
'file_path': '/models/v1-b.safetensors',
'file_name': 'model-v1-b',
'folder': '',
'civitai': {'id': 301, 'modelId': 3, 'name': 'Multi'},
}
cache = ModelCache(
raw_data=[item_a, item_b],
folders=[],
name_display_mode='model_name',
)
files = cache.get_files_by_version_id(301)
assert {f['file_path'] for f in files} == {
'/models/v1-a.safetensors',
'/models/v1-b.safetensors',
}
# Re-adding an existing entry must not duplicate it
cache.add_to_version_index(item_a)
assert len(cache.get_files_by_version_id(301)) == 2
# Removing the indexed file re-points version_index to the sibling
indexed = cache.version_index[301]
sibling = item_b if indexed is item_a else item_a
cache.remove_from_version_index(indexed)
assert 301 in cache.version_index
assert cache.version_index[301]['file_path'] == sibling['file_path']
assert cache.get_versions_by_model_id(3) == [
{'versionId': 301, 'name': 'Multi', 'fileName': sibling['file_name']},
]
remaining = cache.get_files_by_version_id(301)
assert [f['file_path'] for f in remaining] == [sibling['file_path']]
# Removing the last file drops the version from all indexes
cache.remove_from_version_index(sibling)
assert 301 not in cache.version_index
assert cache.get_files_by_version_id(301) == []
assert cache.get_versions_by_model_id(3) == []
assert 3 not in cache.model_id_index
@pytest.mark.asyncio
async def test_version_files_index_rebuild_from_raw_data():
"""rebuild_version_index reconstructs the multi-valued index (#1058)."""
item_a = {
'file_path': '/models/v1-a.safetensors',
'file_name': 'model-v1-a',
'folder': '',
'civitai': {'id': 401, 'modelId': 4, 'name': 'Multi'},
}
item_b = {
'file_path': '/models/v1-b.safetensors',
'file_name': 'model-v1-b',
'folder': '',
'civitai': {'id': 401, 'modelId': 4, 'name': 'Multi'},
}
cache = ModelCache(
raw_data=[item_a, item_b],
folders=[],
name_display_mode='model_name',
)
cache.version_files_index = {}
cache.rebuild_version_index()
assert len(cache.get_files_by_version_id(401)) == 2
# Invalid ids normalize to empty results
assert cache.get_files_by_version_id('not-an-int') == []
assert cache.get_files_by_version_id(None) == []