mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-20 04:21:27 -03:00
feat(download): per-file download status and multi-file selection (#1058)
This commit is contained in:
+5
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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} 已被排除,已跳過下載",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) == []
|
||||||
|
|||||||
Reference in New Issue
Block a user