From a95c518b3066aa4c385f5254e83ceda7c3e1d941 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Mon, 13 Apr 2026 19:51:04 +0800 Subject: [PATCH] feat(download-history): add downloaded status UX --- locales/de.json | 3 + locales/en.json | 3 + locales/es.json | 3 + locales/fr.json | 3 + locales/he.json | 3 + locales/ja.json | 3 + locales/ko.json | 3 + locales/ru.json | 3 + locales/zh-CN.json | 3 + locales/zh-TW.json | 3 + py/routes/handlers/model_handlers.py | 60 ++++++-- static/css/components/shared.css | 52 ++++++- .../js/components/shared/ModelVersionsTab.js | 8 +- static/js/managers/DownloadManager.js | 18 ++- .../components/modelVersionsTab.media.test.js | 55 +++++++ .../managers/downloadManager.history.test.js | 139 ++++++++++++++++++ tests/routes/test_model_update_handler.py | 71 ++++++++- 17 files changed, 417 insertions(+), 16 deletions(-) create mode 100644 tests/frontend/managers/downloadManager.history.test.js diff --git a/locales/de.json b/locales/de.json index 578124a0..cc2d0fcd 100644 --- a/locales/de.json +++ b/locales/de.json @@ -957,6 +957,8 @@ "earlyAccess": "Early Access", "earlyAccessTooltip": "Early Access erforderlich", "inLibrary": "In Bibliothek", + "downloaded": "Heruntergeladen", + "downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.", "alreadyInLibrary": "Bereits in Bibliothek", "autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]", "errors": { @@ -1228,6 +1230,7 @@ "badges": { "current": "Aktuelle Version", "inLibrary": "In der Bibliothek", + "downloaded": "Heruntergeladen", "newer": "Neuere Version", "earlyAccess": "Früher Zugriff", "ignored": "Ignoriert" diff --git a/locales/en.json b/locales/en.json index 7d0dd984..d87fd958 100644 --- a/locales/en.json +++ b/locales/en.json @@ -957,6 +957,8 @@ "earlyAccess": "Early Access", "earlyAccessTooltip": "Early access required", "inLibrary": "In Library", + "downloaded": "Downloaded", + "downloadedTooltip": "Previously downloaded, but it is not currently in your library.", "alreadyInLibrary": "Already in Library", "autoOrganizedPath": "[Auto-organized by path template]", "errors": { @@ -1228,6 +1230,7 @@ "badges": { "current": "Current Version", "inLibrary": "In Library", + "downloaded": "Downloaded", "newer": "Newer Version", "earlyAccess": "Early Access", "ignored": "Ignored" diff --git a/locales/es.json b/locales/es.json index 9c0e1c3a..1e847042 100644 --- a/locales/es.json +++ b/locales/es.json @@ -957,6 +957,8 @@ "earlyAccess": "Acceso temprano", "earlyAccessTooltip": "Acceso temprano requerido", "inLibrary": "En la biblioteca", + "downloaded": "Descargado", + "downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.", "alreadyInLibrary": "Ya en la biblioteca", "autoOrganizedPath": "[Auto-organizado por plantilla de ruta]", "errors": { @@ -1228,6 +1230,7 @@ "badges": { "current": "Versión actual", "inLibrary": "En la biblioteca", + "downloaded": "Descargado", "newer": "Versión más reciente", "earlyAccess": "Acceso temprano", "ignored": "Ignorada" diff --git a/locales/fr.json b/locales/fr.json index fb52e554..5c8a38cd 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -957,6 +957,8 @@ "earlyAccess": "Accès anticipé", "earlyAccessTooltip": "Accès anticipé requis", "inLibrary": "Dans la bibliothèque", + "downloaded": "Téléchargé", + "downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.", "alreadyInLibrary": "Déjà dans la bibliothèque", "autoOrganizedPath": "[Auto-organisé par modèle de chemin]", "errors": { @@ -1228,6 +1230,7 @@ "badges": { "current": "Version actuelle", "inLibrary": "Dans la bibliothèque", + "downloaded": "Téléchargé", "newer": "Version plus récente", "earlyAccess": "Accès anticipé", "ignored": "Ignorée" diff --git a/locales/he.json b/locales/he.json index 9c72da4d..947c953e 100644 --- a/locales/he.json +++ b/locales/he.json @@ -957,6 +957,8 @@ "earlyAccess": "גישה מוקדמת", "earlyAccessTooltip": "נדרשת גישה מוקדמת", "inLibrary": "בספרייה", + "downloaded": "הורד", + "downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.", "alreadyInLibrary": "כבר בספרייה", "autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]", "errors": { @@ -1228,6 +1230,7 @@ "badges": { "current": "גרסה נוכחית", "inLibrary": "בספרייה", + "downloaded": "הורד", "newer": "גרסה חדשה יותר", "earlyAccess": "גישה מוקדמת", "ignored": "התעלם" diff --git a/locales/ja.json b/locales/ja.json index 0374139c..fff3893a 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -957,6 +957,8 @@ "earlyAccess": "アーリーアクセス", "earlyAccessTooltip": "アーリーアクセスが必要", "inLibrary": "ライブラリ内", + "downloaded": "ダウンロード済み", + "downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。", "alreadyInLibrary": "既にライブラリ内", "autoOrganizedPath": "[パステンプレートによる自動整理]", "errors": { @@ -1228,6 +1230,7 @@ "badges": { "current": "現在のバージョン", "inLibrary": "ライブラリにあります", + "downloaded": "ダウンロード済み", "newer": "新しいバージョン", "earlyAccess": "早期アクセス", "ignored": "無視中" diff --git a/locales/ko.json b/locales/ko.json index 2f58ff7e..88498973 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -957,6 +957,8 @@ "earlyAccess": "얼리 액세스", "earlyAccessTooltip": "얼리 액세스 필요", "inLibrary": "라이브러리에 있음", + "downloaded": "다운로드됨", + "downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.", "alreadyInLibrary": "이미 라이브러리에 있음", "autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]", "errors": { @@ -1228,6 +1230,7 @@ "badges": { "current": "현재 버전", "inLibrary": "라이브러리에 있음", + "downloaded": "다운로드됨", "newer": "최신 버전", "earlyAccess": "얼리 액세스", "ignored": "무시됨" diff --git a/locales/ru.json b/locales/ru.json index a4ad312a..8aaa0dec 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -957,6 +957,8 @@ "earlyAccess": "Ранний доступ", "earlyAccessTooltip": "Требуется ранний доступ", "inLibrary": "В библиотеке", + "downloaded": "Загружено", + "downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.", "alreadyInLibrary": "Уже в библиотеке", "autoOrganizedPath": "[Автоматически организовано по шаблону пути]", "errors": { @@ -1228,6 +1230,7 @@ "badges": { "current": "Текущая версия", "inLibrary": "В библиотеке", + "downloaded": "Загружено", "newer": "Более новая версия", "earlyAccess": "Ранний доступ", "ignored": "Игнорируется" diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 5845550b..dec304cf 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -957,6 +957,8 @@ "earlyAccess": "早期访问", "earlyAccessTooltip": "需要早期访问权限", "inLibrary": "已在库中", + "downloaded": "已下载", + "downloadedTooltip": "之前已下载,但当前不在你的库中。", "alreadyInLibrary": "已存在于库中", "autoOrganizedPath": "【已按路径模板自动整理】", "errors": { @@ -1228,6 +1230,7 @@ "badges": { "current": "当前版本", "inLibrary": "已在库中", + "downloaded": "已下载", "newer": "较新的版本", "earlyAccess": "抢先体验", "ignored": "已忽略" diff --git a/locales/zh-TW.json b/locales/zh-TW.json index bd38f427..8740e625 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -957,6 +957,8 @@ "earlyAccess": "早期存取", "earlyAccessTooltip": "需要早期存取", "inLibrary": "已在庫存", + "downloaded": "已下載", + "downloadedTooltip": "先前已下載,但目前不在你的庫中。", "alreadyInLibrary": "已在庫存", "autoOrganizedPath": "[依路徑範本自動整理]", "errors": { @@ -1228,6 +1230,7 @@ "badges": { "current": "目前版本", "inLibrary": "已在庫中", + "downloaded": "已下載", "newer": "較新版本", "earlyAccess": "搶先體驗", "ignored": "已忽略" diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index 1fc8cf31..268657e8 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -19,6 +19,7 @@ from ...services.download_coordinator import DownloadCoordinator from ...services.metadata_sync_service import MetadataSyncService from ...services.model_file_service import ModelMoveService from ...services.preview_asset_service import PreviewAssetService +from ...services.service_registry import ServiceRegistry from ...services.settings_manager import SettingsManager, get_settings_manager from ...services.tag_update_service import TagUpdateService from ...services.use_cases import ( @@ -1531,6 +1532,13 @@ class ModelCivitaiHandler: cache = await self._service.scanner.get_cached_data() version_index = cache.version_index + history_service = await ServiceRegistry.get_downloaded_version_history_service() + downloaded_version_ids = set( + await history_service.get_downloaded_version_ids( + self._service.model_type, + model_id, + ) + ) for version in versions: version_id = None @@ -1547,6 +1555,9 @@ class ModelCivitaiHandler: else None ) version["existsLocally"] = cache_entry is not None + version["hasBeenDownloaded"] = ( + version_id in downloaded_version_ids if version_id is not None else False + ) if cache_entry and isinstance(cache_entry, Mapping): local_path = cache_entry.get("file_path") if local_path: @@ -2265,7 +2276,7 @@ class ModelUpdateHandler: self, record, *, - version_context: Optional[Dict[int, Dict[str, Optional[str]]]] = None, + version_context: Optional[Dict[int, Dict[str, Any]]] = None, ) -> Dict: context = version_context or {} # Check user setting for hiding early access versions @@ -2294,7 +2305,7 @@ class ModelUpdateHandler: @staticmethod def _serialize_version( - version, context: Optional[Dict[str, Optional[str]]] + version, context: Optional[Dict[str, Any]] ) -> Dict: context = context or {} preview_override = context.get("preview_override") @@ -2328,6 +2339,7 @@ class ModelUpdateHandler: "sizeBytes": version.size_bytes, "previewUrl": preview_url, "isInLibrary": version.is_in_library, + "hasBeenDownloaded": bool(context.get("has_been_downloaded", False)), "shouldIgnore": version.should_ignore, "earlyAccessEndsAt": version.early_access_ends_at, "isEarlyAccess": is_early_access, @@ -2337,8 +2349,31 @@ class ModelUpdateHandler: async def _build_version_context( self, record - ) -> Dict[int, Dict[str, Optional[str]]]: - context: Dict[int, Dict[str, Optional[str]]] = {} + ) -> Dict[int, Dict[str, Any]]: + context: Dict[int, Dict[str, Any]] = {} + downloaded_version_ids: set[int] = set() + try: + history_service = await ServiceRegistry.get_downloaded_version_history_service() + downloaded_version_ids = set( + await history_service.get_downloaded_version_ids( + record.model_type, + record.model_id, + ) + ) + except Exception as exc: # pragma: no cover - defensive logging + self._logger.debug( + "Failed to load download history while building version context: %s", + exc, + ) + + for version in record.versions: + context[version.version_id] = { + "file_path": None, + "file_name": None, + "preview_override": None, + "has_been_downloaded": version.version_id in downloaded_version_ids, + } + try: cache = await self._service.scanner.get_cached_data() except Exception as exc: # pragma: no cover - defensive logging @@ -2357,16 +2392,21 @@ class ModelUpdateHandler: cache_entry = version_index.get(version.version_id) if isinstance(cache_entry, Mapping): preview = cache_entry.get("preview_url") - context_entry: Dict[str, Optional[str]] = { - "file_path": cache_entry.get("file_path"), - "file_name": cache_entry.get("file_name"), - "preview_override": None, - } + context_entry = context.setdefault( + version.version_id, + { + "file_path": None, + "file_name": None, + "preview_override": None, + "has_been_downloaded": version.version_id in downloaded_version_ids, + }, + ) + context_entry["file_path"] = cache_entry.get("file_path") + context_entry["file_name"] = cache_entry.get("file_name") if isinstance(preview, str) and preview: context_entry["preview_override"] = config.get_preview_static_url( preview ) - context[version.version_id] = context_entry return context diff --git a/static/css/components/shared.css b/static/css/components/shared.css index 8d436ec8..0f6f69fa 100644 --- a/static/css/components/shared.css +++ b/static/css/components/shared.css @@ -21,6 +21,56 @@ font-size: 0.9em; } +.downloaded-badge { + display: inline-flex; + align-items: center; + background: color-mix(in oklch, var(--badge-update-bg, #4a90e2) 22%, transparent); + color: var(--badge-update-bg, #4a90e2); + border: 1px solid color-mix(in oklch, var(--badge-update-bg, #4a90e2) 50%, transparent); + padding: 4px 8px; + border-radius: var(--border-radius-xs); + font-size: 0.8em; + font-weight: 500; + white-space: nowrap; + flex-shrink: 0; + position: relative; + transform: translateZ(0); + will-change: transform; +} + +.downloaded-badge i { + margin-right: 4px; + font-size: 0.9em; +} + +.downloaded-info { + display: none; + position: absolute; + top: 100%; + right: 0; + background: var(--card-bg); + border: 1px solid color-mix(in oklch, var(--badge-update-bg, #4a90e2) 50%, transparent); + border-radius: var(--border-radius-xs); + padding: var(--space-1); + margin-top: 4px; + font-size: 0.9em; + color: var(--text-color); + white-space: normal; + word-break: break-word; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + z-index: 100; + min-width: 240px; + max-width: 320px; + transform: translateZ(0); + position: fixed; + pointer-events: none; +} + +.downloaded-badge:hover .downloaded-info { + display: block; + pointer-events: auto; +} + /* Early Access Badge */ .early-access-badge { display: inline-flex; @@ -108,4 +158,4 @@ color: var(--lora-error); font-size: 0.9em; margin-top: 4px; -} \ No newline at end of file +} diff --git a/static/js/components/shared/ModelVersionsTab.js b/static/js/components/shared/ModelVersionsTab.js index a384ee6f..f13ac9d0 100644 --- a/static/js/components/shared/ModelVersionsTab.js +++ b/static/js/components/shared/ModelVersionsTab.js @@ -433,7 +433,13 @@ function renderRow(version, options) { if (version.isInLibrary) { badges.push(buildBadge(translate('modals.model.versions.badges.inLibrary', {}, 'In Library'), 'success')); - } else if (isNewer && !version.shouldIgnore) { + } + + if (!version.isInLibrary && version.hasBeenDownloaded) { + badges.push(buildBadge(translate('modals.model.versions.badges.downloaded', {}, 'Downloaded'), 'info')); + } + + if (!version.isInLibrary && isNewer && !version.shouldIgnore) { badges.push(buildBadge(translate('modals.model.versions.badges.newer', {}, 'Newer Version'), 'info')); } diff --git a/static/js/managers/DownloadManager.js b/static/js/managers/DownloadManager.js index 3ca3d8be..9d442d71 100644 --- a/static/js/managers/DownloadManager.js +++ b/static/js/managers/DownloadManager.js @@ -250,6 +250,7 @@ export class DownloadManager { (version.files[0]?.sizeKB / 1024).toFixed(2); const existsLocally = version.existsLocally; + const hasBeenDownloaded = version.hasBeenDownloaded && !existsLocally; const localPath = version.localPath; const isEarlyAccess = version.availability === 'EarlyAccess'; @@ -262,11 +263,22 @@ export class DownloadManager { `; } - const localStatus = existsLocally ? - `
+ let localStatus = ''; + if (existsLocally) { + localStatus = `
${translate('modals.download.inLibrary')}
${localPath || ''}
-
` : ''; +
`; + } else if (hasBeenDownloaded) { + localStatus = `
+ ${translate('modals.download.downloaded', {}, 'Downloaded')} +
${translate( + 'modals.download.downloadedTooltip', + {}, + 'Previously downloaded, but it is not currently in your library.' + )}
+
`; + } return ` +
+
+ + `; + ({ DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE)); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('shows downloaded badge only for versions missing locally', () => { + const manager = new DownloadManager(); + manager.versions = [ + { + id: 101, + name: 'History only', + images: [], + files: [{ sizeKB: 2048 }], + createdAt: '2026-01-01T00:00:00Z', + existsLocally: false, + hasBeenDownloaded: true, + }, + { + id: 102, + name: 'Still local', + images: [], + files: [{ sizeKB: 2048 }], + createdAt: '2026-01-01T00:00:00Z', + existsLocally: true, + hasBeenDownloaded: true, + localPath: '/models/still-local.safetensors', + }, + ]; + + manager.showVersionStep(); + + const items = document.querySelectorAll('.version-item'); + expect(items).toHaveLength(2); + + expect(items[0].querySelector('.downloaded-badge')?.textContent).toContain('Downloaded'); + expect(items[0].querySelector('.downloaded-info')?.textContent).toContain( + 'Previously downloaded, but it is not currently in your library.' + ); + expect(items[0].querySelector('.local-badge')).toBeNull(); + + expect(items[1].querySelector('.local-badge')).not.toBeNull(); + expect(items[1].querySelector('.local-path')?.textContent).toContain('/models/still-local.safetensors'); + expect(items[1].querySelector('.downloaded-badge')).toBeNull(); + }); +}); diff --git a/tests/routes/test_model_update_handler.py b/tests/routes/test_model_update_handler.py index 42887b2b..4dce7279 100644 --- a/tests/routes/test_model_update_handler.py +++ b/tests/routes/test_model_update_handler.py @@ -7,6 +7,7 @@ import pytest from py.config import config from py.routes.handlers.model_handlers import ModelUpdateHandler +from py.services.service_registry import ServiceRegistry from py.utils.metadata_manager import MetadataManager from py.services.model_update_service import ModelUpdateRecord, ModelVersionRecord @@ -91,7 +92,75 @@ async def test_build_version_context_includes_static_urls(): overrides = await handler._build_version_context(record) expected = config.get_preview_static_url("/tmp/previews/example.png") - assert overrides == {123: {"file_path": None, "file_name": None, "preview_override": expected}} + assert overrides == { + 123: { + "file_path": None, + "file_name": None, + "preview_override": expected, + "has_been_downloaded": False, + } + } + + +@pytest.mark.asyncio +async def test_build_version_context_includes_download_history(monkeypatch): + cache = SimpleNamespace(version_index={}) + service = DummyService(cache) + handler = ModelUpdateHandler( + service=service, + update_service=SimpleNamespace(), + metadata_provider_selector=lambda *_: None, + settings_service=SimpleNamespace(get=lambda *_: False), + logger=logging.getLogger(__name__), + ) + + class DummyHistoryService: + async def get_downloaded_version_ids(self, model_type, model_id): + assert model_type == "lora" + assert model_id == 42 + return [123] + + async def fake_history_service_factory(): + return DummyHistoryService() + + monkeypatch.setattr( + ServiceRegistry, + "get_downloaded_version_history_service", + staticmethod(fake_history_service_factory), + ) + + record = ModelUpdateRecord( + model_type="lora", + model_id=42, + versions=[ + ModelVersionRecord( + version_id=123, + name="Downloaded", + base_model=None, + released_at=None, + size_bytes=None, + preview_url=None, + is_in_library=False, + should_ignore=False, + ), + ModelVersionRecord( + version_id=124, + name="Fresh", + base_model=None, + released_at=None, + size_bytes=None, + preview_url=None, + is_in_library=False, + should_ignore=False, + ), + ], + last_checked_at=None, + should_ignore_model=False, + ) + + overrides = await handler._build_version_context(record) + assert overrides[123]["has_been_downloaded"] is True + assert overrides[124]["has_been_downloaded"] is False @pytest.mark.asyncio