diff --git a/locales/de.json b/locales/de.json index 7fab221a..bafe9a37 100644 --- a/locales/de.json +++ b/locales/de.json @@ -263,6 +263,7 @@ "videoSettings": "Video-Einstellungen", "layoutSettings": "Layout-Einstellungen", "misc": "Verschiedenes", + "backup": "Backups", "folderSettings": "Standard-Roots", "recipeSettings": "Rezepte", "extraFolderPaths": "Zusätzliche Ordnerpfade", @@ -324,6 +325,31 @@ "saveFailed": "Übersprungene Pfade konnten nicht gespeichert werden: {message}" } }, + "backup": { + "autoEnabled": "Automatische Backups", + "autoEnabledHelp": "Erstellt einmal täglich einen lokalen Schnappschuss und behält die neuesten Schnappschüsse gemäß der Aufbewahrungsrichtlinie.", + "retention": "Aufbewahrungsanzahl", + "retentionHelp": "Wie viele automatische Schnappschüsse behalten werden, bevor ältere entfernt werden.", + "management": "Backup-Verwaltung", + "managementHelp": "Exportiere deinen aktuellen Benutzerstatus oder stelle ihn aus einem Backup-Archiv wieder her.", + "locationSummary": "Aktueller Backup-Speicherort", + "openFolderButton": "Backup-Ordner öffnen", + "openFolderSuccess": "Backup-Ordner geöffnet", + "openFolderFailed": "Backup-Ordner konnte nicht geöffnet werden", + "locationCopied": "Backup-Pfad in die Zwischenablage kopiert: {{path}}", + "locationClipboardFallback": "Backup-Pfad: {{path}}", + "exportButton": "Backup exportieren", + "exportSuccess": "Backup erfolgreich exportiert.", + "exportFailed": "Backup konnte nicht exportiert werden: {message}", + "importButton": "Backup importieren", + "importConfirm": "Dieses Backup importieren und den lokalen Benutzerstatus überschreiben?", + "importSuccess": "Backup erfolgreich importiert.", + "importFailed": "Backup konnte nicht importiert werden: {message}", + "latestSnapshot": "Neuester Schnappschuss", + "latestAutoSnapshot": "Neuester automatischer Schnappschuss", + "snapshotCount": "Gespeicherte Schnappschüsse", + "noneAvailable": "Noch keine Schnappschüsse vorhanden" + }, "downloadSkipBaseModels": { "label": "Downloads für Basismodelle überspringen", "help": "Gilt für alle Download-Abläufe. Hier können nur unterstützte Basismodelle ausgewählt werden.", diff --git a/locales/en.json b/locales/en.json index eba09b4d..0e36b7b0 100644 --- a/locales/en.json +++ b/locales/en.json @@ -263,6 +263,7 @@ "videoSettings": "Video Settings", "layoutSettings": "Layout Settings", "misc": "Miscellaneous", + "backup": "Backups", "folderSettings": "Default Roots", "recipeSettings": "Recipes", "extraFolderPaths": "Extra Folder Paths", @@ -324,6 +325,31 @@ "saveFailed": "Unable to save skip paths: {message}" } }, + "backup": { + "autoEnabled": "Automatic backups", + "autoEnabledHelp": "Create a local snapshot once per day and keep the latest snapshots according to the retention policy.", + "retention": "Retention count", + "retentionHelp": "How many automatic snapshots to keep before older ones are pruned.", + "management": "Backup management", + "managementHelp": "Export your current user state or restore it from a backup archive.", + "locationSummary": "Current backup location", + "openFolderButton": "Open backup folder", + "openFolderSuccess": "Opened backup folder", + "openFolderFailed": "Failed to open backup folder", + "locationCopied": "Backup path copied to clipboard: {{path}}", + "locationClipboardFallback": "Backup path: {{path}}", + "exportButton": "Export backup", + "exportSuccess": "Backup exported successfully.", + "exportFailed": "Failed to export backup: {message}", + "importButton": "Import backup", + "importConfirm": "Import this backup and overwrite local user state?", + "importSuccess": "Backup imported successfully.", + "importFailed": "Failed to import backup: {message}", + "latestSnapshot": "Latest snapshot", + "latestAutoSnapshot": "Latest automatic snapshot", + "snapshotCount": "Saved snapshots", + "noneAvailable": "No snapshots yet" + }, "downloadSkipBaseModels": { "label": "Skip downloads for base models", "help": "When enabled, versions using the selected base models will be skipped.", diff --git a/locales/es.json b/locales/es.json index c775557b..3367c02a 100644 --- a/locales/es.json +++ b/locales/es.json @@ -263,6 +263,7 @@ "videoSettings": "Configuración de video", "layoutSettings": "Configuración de diseño", "misc": "Varios", + "backup": "Copias de seguridad", "folderSettings": "Raíces predeterminadas", "recipeSettings": "Recetas", "extraFolderPaths": "Rutas de carpetas adicionales", @@ -324,6 +325,31 @@ "saveFailed": "No se pudieron guardar las rutas a omitir: {message}" } }, + "backup": { + "autoEnabled": "Copias de seguridad automáticas", + "autoEnabledHelp": "Crea una instantánea local una vez al día y conserva las más recientes según la política de retención.", + "retention": "Cantidad de retención", + "retentionHelp": "Cuántas instantáneas automáticas conservar antes de eliminar las antiguas.", + "management": "Gestión de copias", + "managementHelp": "Exporta tu estado de usuario actual o restáuralo desde un archivo de copia de seguridad.", + "locationSummary": "Ubicación actual de la copia", + "openFolderButton": "Abrir carpeta de copias", + "openFolderSuccess": "Carpeta de copias abierta", + "openFolderFailed": "No se pudo abrir la carpeta de copias", + "locationCopied": "Ruta de la copia copiada al portapapeles: {{path}}", + "locationClipboardFallback": "Ruta de la copia: {{path}}", + "exportButton": "Exportar copia", + "exportSuccess": "Copia exportada correctamente.", + "exportFailed": "No se pudo exportar la copia: {message}", + "importButton": "Importar copia", + "importConfirm": "¿Importar esta copia y sobrescribir el estado local del usuario?", + "importSuccess": "Copia importada correctamente.", + "importFailed": "No se pudo importar la copia: {message}", + "latestSnapshot": "Última instantánea", + "latestAutoSnapshot": "Última instantánea automática", + "snapshotCount": "Instantáneas guardadas", + "noneAvailable": "Aún no hay instantáneas" + }, "downloadSkipBaseModels": { "label": "Omitir descargas para modelos base", "help": "Se aplica a todos los flujos de descarga. Aquí solo se pueden seleccionar modelos base compatibles.", diff --git a/locales/fr.json b/locales/fr.json index 95d9db24..1e2c78a0 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -263,6 +263,7 @@ "videoSettings": "Paramètres vidéo", "layoutSettings": "Paramètres d'affichage", "misc": "Divers", + "backup": "Sauvegardes", "folderSettings": "Racines par défaut", "recipeSettings": "Recipes", "extraFolderPaths": "Chemins de dossiers supplémentaires", @@ -324,6 +325,31 @@ "saveFailed": "Impossible d'enregistrer les chemins à ignorer : {message}" } }, + "backup": { + "autoEnabled": "Sauvegardes automatiques", + "autoEnabledHelp": "Crée un instantané local une fois par jour et conserve les plus récents selon la politique de rétention.", + "retention": "Nombre de rétention", + "retentionHelp": "Combien d'instantanés automatiques conserver avant de supprimer les plus anciens.", + "management": "Gestion des sauvegardes", + "managementHelp": "Exporte l'état actuel de l'utilisateur ou restaure-le depuis une archive de sauvegarde.", + "locationSummary": "Emplacement actuel des sauvegardes", + "openFolderButton": "Ouvrir le dossier de sauvegarde", + "openFolderSuccess": "Dossier de sauvegarde ouvert", + "openFolderFailed": "Impossible d'ouvrir le dossier de sauvegarde", + "locationCopied": "Chemin de sauvegarde copié dans le presse-papiers : {{path}}", + "locationClipboardFallback": "Chemin de sauvegarde : {{path}}", + "exportButton": "Exporter la sauvegarde", + "exportSuccess": "Sauvegarde exportée avec succès.", + "exportFailed": "Échec de l'export de la sauvegarde : {message}", + "importButton": "Importer la sauvegarde", + "importConfirm": "Importer cette sauvegarde et écraser l'état local de l'utilisateur ?", + "importSuccess": "Sauvegarde importée avec succès.", + "importFailed": "Échec de l'import de la sauvegarde : {message}", + "latestSnapshot": "Dernier instantané", + "latestAutoSnapshot": "Dernier instantané automatique", + "snapshotCount": "Instantanés enregistrés", + "noneAvailable": "Aucun instantané pour le moment" + }, "downloadSkipBaseModels": { "label": "Ignorer les téléchargements pour certains modèles de base", "help": "S’applique à tous les flux de téléchargement. Seuls les modèles de base pris en charge peuvent être sélectionnés ici.", diff --git a/locales/he.json b/locales/he.json index fe8013f1..344b2dc9 100644 --- a/locales/he.json +++ b/locales/he.json @@ -263,6 +263,7 @@ "videoSettings": "הגדרות וידאו", "layoutSettings": "הגדרות פריסה", "misc": "שונות", + "backup": "גיבויים", "folderSettings": "תיקיות ברירת מחדל", "recipeSettings": "מתכונים", "extraFolderPaths": "נתיבי תיקיות נוספים", @@ -324,6 +325,31 @@ "saveFailed": "לא ניתן לשמור נתיבי דילוג: {message}" } }, + "backup": { + "autoEnabled": "גיבויים אוטומטיים", + "autoEnabledHelp": "יוצר צילום מצב מקומי פעם ביום ושומר את הצילומים האחרונים לפי מדיניות השמירה.", + "retention": "כמות שמירה", + "retentionHelp": "כמה צילומי מצב אוטומטיים לשמור לפני שמסירים ישנים.", + "management": "ניהול גיבויים", + "managementHelp": "ייצא את מצב המשתמש הנוכחי או שחזר אותו מארכיון גיבוי.", + "locationSummary": "מיקום הגיבוי הנוכחי", + "openFolderButton": "פתח את תיקיית הגיבויים", + "openFolderSuccess": "תיקיית הגיבויים נפתחה", + "openFolderFailed": "לא ניתן היה לפתוח את תיקיית הגיבויים", + "locationCopied": "נתיב הגיבוי הועתק ללוח: {{path}}", + "locationClipboardFallback": "נתיב הגיבוי: {{path}}", + "exportButton": "ייצא גיבוי", + "exportSuccess": "הגיבוי יוצא בהצלחה.", + "exportFailed": "נכשל ייצוא הגיבוי: {message}", + "importButton": "ייבא גיבוי", + "importConfirm": "לייבא את הגיבוי הזה ולדרוס את מצב המשתמש המקומי?", + "importSuccess": "הגיבוי יובא בהצלחה.", + "importFailed": "נכשל ייבוא הגיבוי: {message}", + "latestSnapshot": "צילום המצב האחרון", + "latestAutoSnapshot": "צילום המצב האוטומטי האחרון", + "snapshotCount": "צילומי מצב שמורים", + "noneAvailable": "עדיין אין צילומי מצב" + }, "downloadSkipBaseModels": { "label": "דלג על הורדות עבור מודלי בסיס", "help": "חל על כל תהליכי ההורדה. ניתן לבחור כאן רק מודלי בסיס נתמכים.", diff --git a/locales/ja.json b/locales/ja.json index 6647122d..81c463cc 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -263,6 +263,7 @@ "videoSettings": "動画設定", "layoutSettings": "レイアウト設定", "misc": "その他", + "backup": "バックアップ", "folderSettings": "デフォルトルート", "recipeSettings": "レシピ", "extraFolderPaths": "追加フォルダーパス", @@ -324,6 +325,31 @@ "saveFailed": "スキップパスの保存に失敗しました:{message}" } }, + "backup": { + "autoEnabled": "自動バックアップ", + "autoEnabledHelp": "1日1回ローカルのスナップショットを作成し、保持ポリシーに従って最新のものを残します。", + "retention": "保持数", + "retentionHelp": "古いものを削除する前に、何件の自動スナップショットを保持するかを指定します。", + "management": "バックアップ管理", + "managementHelp": "現在のユーザー状態をエクスポートするか、バックアップアーカイブから復元します。", + "locationSummary": "現在のバックアップ場所", + "openFolderButton": "バックアップフォルダを開く", + "openFolderSuccess": "バックアップフォルダを開きました", + "openFolderFailed": "バックアップフォルダを開けませんでした", + "locationCopied": "バックアップパスをクリップボードにコピーしました: {{path}}", + "locationClipboardFallback": "バックアップパス: {{path}}", + "exportButton": "バックアップをエクスポート", + "exportSuccess": "バックアップを正常にエクスポートしました。", + "exportFailed": "バックアップのエクスポートに失敗しました: {message}", + "importButton": "バックアップをインポート", + "importConfirm": "このバックアップをインポートして、ローカルのユーザー状態を上書きしますか?", + "importSuccess": "バックアップを正常にインポートしました。", + "importFailed": "バックアップのインポートに失敗しました: {message}", + "latestSnapshot": "最新のスナップショット", + "latestAutoSnapshot": "最新の自動スナップショット", + "snapshotCount": "保存済みスナップショット", + "noneAvailable": "まだスナップショットはありません" + }, "downloadSkipBaseModels": { "label": "ベースモデルのダウンロードをスキップ", "help": "すべてのダウンロードフローに適用されます。ここでは対応しているベースモデルのみ選択できます。", diff --git a/locales/ko.json b/locales/ko.json index acc40700..3ed3ea61 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -263,6 +263,7 @@ "videoSettings": "비디오 설정", "layoutSettings": "레이아웃 설정", "misc": "기타", + "backup": "백업", "folderSettings": "기본 루트", "recipeSettings": "레시피", "extraFolderPaths": "추가 폴다 경로", @@ -324,6 +325,31 @@ "saveFailed": "건너뛰기 경로를 저장할 수 없습니다: {message}" } }, + "backup": { + "autoEnabled": "자동 백업", + "autoEnabledHelp": "하루에 한 번 로컬 스냅샷을 만들고 보존 정책에 따라 최신 스냅샷을 유지합니다.", + "retention": "보존 개수", + "retentionHelp": "오래된 자동 스냅샷을 삭제하기 전에 몇 개를 유지할지 지정합니다.", + "management": "백업 관리", + "managementHelp": "현재 사용자 상태를 내보내거나 백업 아카이브에서 복원합니다.", + "locationSummary": "현재 백업 위치", + "openFolderButton": "백업 폴더 열기", + "openFolderSuccess": "백업 폴더를 열었습니다", + "openFolderFailed": "백업 폴더를 열지 못했습니다", + "locationCopied": "백업 경로를 클립보드에 복사했습니다: {{path}}", + "locationClipboardFallback": "백업 경로: {{path}}", + "exportButton": "백업 내보내기", + "exportSuccess": "백업을 성공적으로 내보냈습니다.", + "exportFailed": "백업 내보내기에 실패했습니다: {message}", + "importButton": "백업 가져오기", + "importConfirm": "이 백업을 가져와서 로컬 사용자 상태를 덮어쓰시겠습니까?", + "importSuccess": "백업을 성공적으로 가져왔습니다.", + "importFailed": "백업 가져오기에 실패했습니다: {message}", + "latestSnapshot": "최근 스냅샷", + "latestAutoSnapshot": "최근 자동 스냅샷", + "snapshotCount": "저장된 스냅샷", + "noneAvailable": "아직 스냅샷이 없습니다" + }, "downloadSkipBaseModels": { "label": "기본 모델 다운로드 건너뛰기", "help": "모든 다운로드 흐름에 적용됩니다. 여기서는 지원되는 기본 모델만 선택할 수 있습니다.", diff --git a/locales/ru.json b/locales/ru.json index 8b9e56f6..1f5f8e83 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -263,6 +263,7 @@ "videoSettings": "Настройки видео", "layoutSettings": "Настройки макета", "misc": "Разное", + "backup": "Резервные копии", "folderSettings": "Корневые папки", "recipeSettings": "Рецепты", "extraFolderPaths": "Дополнительные пути к папкам", @@ -324,6 +325,31 @@ "saveFailed": "Не удалось сохранить пути для пропуска: {message}" } }, + "backup": { + "autoEnabled": "Автоматические резервные копии", + "autoEnabledHelp": "Создаёт локальный снимок раз в день и хранит последние снимки согласно политике хранения.", + "retention": "Количество хранения", + "retentionHelp": "Сколько автоматических снимков сохранять перед удалением старых.", + "management": "Управление резервными копиями", + "managementHelp": "Экспортируйте текущее состояние пользователя или восстановите его из архива резервной копии.", + "locationSummary": "Текущее расположение резервных копий", + "openFolderButton": "Открыть папку резервных копий", + "openFolderSuccess": "Папка резервных копий открыта", + "openFolderFailed": "Не удалось открыть папку резервных копий", + "locationCopied": "Путь к резервной копии скопирован в буфер обмена: {{path}}", + "locationClipboardFallback": "Путь к резервной копии: {{path}}", + "exportButton": "Экспортировать резервную копию", + "exportSuccess": "Резервная копия успешно экспортирована.", + "exportFailed": "Не удалось экспортировать резервную копию: {message}", + "importButton": "Импортировать резервную копию", + "importConfirm": "Импортировать эту резервную копию и перезаписать локальное состояние пользователя?", + "importSuccess": "Резервная копия успешно импортирована.", + "importFailed": "Не удалось импортировать резервную копию: {message}", + "latestSnapshot": "Последний снимок", + "latestAutoSnapshot": "Последний автоматический снимок", + "snapshotCount": "Сохранённые снимки", + "noneAvailable": "Снимков пока нет" + }, "downloadSkipBaseModels": { "label": "Пропускать загрузки для базовых моделей", "help": "Применяется ко всем сценариям загрузки. Здесь можно выбрать только поддерживаемые базовые модели.", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index d7b33721..205cbd90 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -263,6 +263,7 @@ "videoSettings": "视频设置", "layoutSettings": "布局设置", "misc": "其他", + "backup": "备份", "folderSettings": "默认根目录", "recipeSettings": "配方", "extraFolderPaths": "额外文件夹路径", @@ -324,6 +325,31 @@ "saveFailed": "无法保存跳过路径:{message}" } }, + "backup": { + "autoEnabled": "自动备份", + "autoEnabledHelp": "每天创建一次本地快照,并按保留策略保留最新快照。", + "retention": "保留数量", + "retentionHelp": "在删除旧快照之前,要保留多少个自动快照。", + "management": "备份管理", + "managementHelp": "导出当前用户状态,或从备份归档中恢复。", + "locationSummary": "当前备份位置", + "openFolderButton": "打开备份文件夹", + "openFolderSuccess": "已打开备份文件夹", + "openFolderFailed": "无法打开备份文件夹", + "locationCopied": "备份路径已复制到剪贴板:{{path}}", + "locationClipboardFallback": "备份路径:{{path}}", + "exportButton": "导出备份", + "exportSuccess": "备份导出成功。", + "exportFailed": "备份导出失败:{message}", + "importButton": "导入备份", + "importConfirm": "导入此备份并覆盖本地用户状态吗?", + "importSuccess": "备份导入成功。", + "importFailed": "备份导入失败:{message}", + "latestSnapshot": "最近快照", + "latestAutoSnapshot": "最近自动快照", + "snapshotCount": "已保存快照", + "noneAvailable": "还没有快照" + }, "downloadSkipBaseModels": { "label": "跳过这些基础模型的下载", "help": "适用于所有下载流程。这里只能选择受支持的基础模型。", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 592b7962..b63335d5 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -263,6 +263,7 @@ "videoSettings": "影片設定", "layoutSettings": "版面設定", "misc": "其他", + "backup": "備份", "folderSettings": "預設根目錄", "recipeSettings": "配方", "extraFolderPaths": "額外資料夾路徑", @@ -324,6 +325,31 @@ "saveFailed": "無法儲存跳過路徑:{message}" } }, + "backup": { + "autoEnabled": "自動備份", + "autoEnabledHelp": "每天建立一次本地快照,並依保留政策保留最新快照。", + "retention": "保留數量", + "retentionHelp": "在刪除舊快照之前,要保留多少自動快照。", + "management": "備份管理", + "managementHelp": "匯出目前的使用者狀態,或從備份封存中還原。", + "locationSummary": "目前備份位置", + "openFolderButton": "開啟備份資料夾", + "openFolderSuccess": "已開啟備份資料夾", + "openFolderFailed": "無法開啟備份資料夾", + "locationCopied": "備份路徑已複製到剪貼簿:{{path}}", + "locationClipboardFallback": "備份路徑:{{path}}", + "exportButton": "匯出備份", + "exportSuccess": "備份匯出成功。", + "exportFailed": "備份匯出失敗:{message}", + "importButton": "匯入備份", + "importConfirm": "要匯入此備份並覆寫本機使用者狀態嗎?", + "importSuccess": "備份匯入成功。", + "importFailed": "備份匯入失敗:{message}", + "latestSnapshot": "最近快照", + "latestAutoSnapshot": "最近自動快照", + "snapshotCount": "已儲存快照", + "noneAvailable": "目前還沒有快照" + }, "downloadSkipBaseModels": { "label": "跳過這些基礎模型的下載", "help": "適用於所有下載流程。這裡只能選擇受支援的基礎模型。", diff --git a/py/lora_manager.py b/py/lora_manager.py index f2038040..6e7758fd 100644 --- a/py/lora_manager.py +++ b/py/lora_manager.py @@ -222,6 +222,7 @@ class LoraManager: # Register DownloadManager with ServiceRegistry await ServiceRegistry.get_download_manager() + await ServiceRegistry.get_backup_service() from .services.metadata_service import initialize_metadata_providers diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index 0dfaac19..7aedd959 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -9,11 +9,14 @@ objects that can be composed by the route controller. from __future__ import annotations import asyncio +import contextlib import json import logging import os import subprocess import sys +import tempfile +import zipfile from dataclasses import dataclass from typing import Awaitable, Callable, Dict, Mapping, Protocol @@ -130,6 +133,22 @@ class MetadataArchiveManagerProtocol(Protocol): ... +class BackupServiceProtocol(Protocol): + async def create_snapshot( + self, *, snapshot_type: str = "manual", persist: bool = False + ) -> dict: # pragma: no cover - protocol + ... + + async def restore_snapshot(self, archive_path: str) -> dict: # pragma: no cover - protocol + ... + + def get_status(self) -> dict: # pragma: no cover - protocol + ... + + def get_available_snapshots(self) -> list[dict]: # pragma: no cover - protocol + ... + + class NodeRegistry: """Thread-safe registry for tracking LoRA nodes in active workflows.""" @@ -746,12 +765,17 @@ class ModelExampleFilesHandler: return web.json_response({"success": False, "error": str(exc)}, status=500) +async def _noop_backup_service() -> None: + return None + + @dataclass class ServiceRegistryAdapter: get_lora_scanner: Callable[[], Awaitable] get_checkpoint_scanner: Callable[[], Awaitable] get_embedding_scanner: Callable[[], Awaitable] get_downloaded_version_history_service: Callable[[], Awaitable] + get_backup_service: Callable[[], Awaitable] = _noop_backup_service class ModelLibraryHandler: @@ -1418,10 +1442,150 @@ class MetadataArchiveHandler: return web.json_response({"success": False, "error": str(exc)}, status=500) +class BackupHandler: + """Handler for user-state backup export/import.""" + + def __init__( + self, + *, + backup_service_factory: Callable[[], Awaitable[BackupServiceProtocol]] = ServiceRegistry.get_backup_service, + ) -> None: + self._backup_service_factory = backup_service_factory + + async def get_backup_status(self, request: web.Request) -> web.Response: + try: + service = await self._backup_service_factory() + return web.json_response( + { + "success": True, + "status": service.get_status(), + "snapshots": service.get_available_snapshots(), + } + ) + except Exception as exc: # pragma: no cover - defensive logging + logger.error("Error getting backup status: %s", exc, exc_info=True) + return web.json_response({"success": False, "error": str(exc)}, status=500) + + async def export_backup(self, request: web.Request) -> web.Response: + try: + service = await self._backup_service_factory() + result = await service.create_snapshot(snapshot_type="manual", persist=False) + headers = { + "Content-Type": "application/zip", + "Content-Disposition": f'attachment; filename="{result["archive_name"]}"', + } + return web.Response(body=result["archive_bytes"], headers=headers) + except Exception as exc: # pragma: no cover - defensive logging + logger.error("Error exporting backup: %s", exc, exc_info=True) + return web.json_response({"success": False, "error": str(exc)}, status=500) + + async def import_backup(self, request: web.Request) -> web.Response: + temp_path: str | None = None + try: + fd, temp_path = tempfile.mkstemp( + suffix=".zip", prefix="lora-manager-backup-" + ) + os.close(fd) + + if request.content_type.startswith("multipart/"): + reader = await request.multipart() + field = await reader.next() + uploaded = False + while field is not None: + if getattr(field, "filename", None): + with open(temp_path, "wb") as handle: + while True: + chunk = await field.read_chunk() + if not chunk: + break + handle.write(chunk) + uploaded = True + break + field = await reader.next() + if not uploaded: + return web.json_response( + {"success": False, "error": "Missing backup archive"}, + status=400, + ) + else: + body = await request.read() + if not body: + return web.json_response( + {"success": False, "error": "Missing backup archive"}, + status=400, + ) + with open(temp_path, "wb") as handle: + handle.write(body) + + if not temp_path or not os.path.exists(temp_path) or os.path.getsize(temp_path) == 0: + return web.json_response( + {"success": False, "error": "Missing backup archive"}, + status=400, + ) + + service = await self._backup_service_factory() + result = await service.restore_snapshot(temp_path) + return web.json_response({"success": True, **result}) + except (ValueError, zipfile.BadZipFile) as exc: + logger.error("Error importing backup: %s", exc, exc_info=True) + return web.json_response({"success": False, "error": str(exc)}, status=400) + except Exception as exc: # pragma: no cover - defensive logging + logger.error("Error importing backup: %s", exc, exc_info=True) + return web.json_response({"success": False, "error": str(exc)}, status=500) + finally: + if temp_path and os.path.exists(temp_path): + with contextlib.suppress(OSError): + os.remove(temp_path) + + class FileSystemHandler: def __init__(self, settings_service=None) -> None: self._settings = settings_service or get_settings_manager() + async def _open_path(self, path: str) -> web.Response: + path = os.path.abspath(path) + if not os.path.isdir(path): + return web.json_response( + {"success": False, "error": "Folder does not exist"}, + status=404, + ) + + if os.name == "nt": + subprocess.Popen(["explorer", path]) + elif os.name == "posix": + if _is_docker(): + return web.json_response( + { + "success": True, + "message": "Running in Docker: Path available for copying", + "path": path, + "mode": "clipboard", + } + ) + if _is_wsl(): + windows_path = _wsl_to_windows_path(path) + if windows_path: + subprocess.Popen(["explorer.exe", windows_path]) + else: + logger.error( + "Failed to convert WSL path to Windows path: %s", path + ) + return web.json_response( + { + "success": False, + "error": "Failed to open folder location: path conversion error", + }, + status=500, + ) + elif sys.platform == "darwin": + subprocess.Popen(["open", path]) + else: + subprocess.Popen(["xdg-open", path]) + + return web.json_response( + {"success": True, "message": f"Opened folder: {path}", "path": path} + ) + async def open_file_location(self, request: web.Request) -> web.Response: try: data = await request.json() @@ -1536,6 +1700,20 @@ class FileSystemHandler: logger.error("Failed to open settings location: %s", exc, exc_info=True) return web.json_response({"success": False, "error": str(exc)}, status=500) + async def open_backup_location(self, request: web.Request) -> web.Response: + try: + settings_file = getattr(self._settings, "settings_file", None) + if not settings_file: + return web.json_response( + {"success": False, "error": "Settings file not found"}, status=404 + ) + + backup_dir = os.path.join(os.path.dirname(os.path.abspath(settings_file)), "backups") + return await self._open_path(backup_dir) + except Exception as exc: # pragma: no cover - defensive logging + logger.error("Failed to open backup location: %s", exc, exc_info=True) + return web.json_response({"success": False, "error": str(exc)}, status=500) + class CustomWordsHandler: """Handler for autocomplete via TagFTSIndex.""" @@ -1840,6 +2018,7 @@ class MiscHandlerSet: node_registry: NodeRegistryHandler, model_library: ModelLibraryHandler, metadata_archive: MetadataArchiveHandler, + backup: BackupHandler, filesystem: FileSystemHandler, custom_words: CustomWordsHandler, supporters: SupportersHandler, @@ -1855,6 +2034,7 @@ class MiscHandlerSet: self.node_registry = node_registry self.model_library = model_library self.metadata_archive = metadata_archive + self.backup = backup self.filesystem = filesystem self.custom_words = custom_words self.supporters = supporters @@ -1886,9 +2066,13 @@ class MiscHandlerSet: "download_metadata_archive": self.metadata_archive.download_metadata_archive, "remove_metadata_archive": self.metadata_archive.remove_metadata_archive, "get_metadata_archive_status": self.metadata_archive.get_metadata_archive_status, + "get_backup_status": self.backup.get_backup_status, + "export_backup": self.backup.export_backup, + "import_backup": self.backup.import_backup, "get_model_versions_status": self.model_library.get_model_versions_status, "open_file_location": self.filesystem.open_file_location, "open_settings_location": self.filesystem.open_settings_location, + "open_backup_location": self.filesystem.open_backup_location, "search_custom_words": self.custom_words.search_custom_words, "get_supporters": self.supporters.get_supporters, "get_example_workflows": self.example_workflows.get_example_workflows, @@ -1907,4 +2091,5 @@ def build_service_registry_adapter() -> ServiceRegistryAdapter: get_checkpoint_scanner=ServiceRegistry.get_checkpoint_scanner, get_embedding_scanner=ServiceRegistry.get_embedding_scanner, get_downloaded_version_history_service=ServiceRegistry.get_downloaded_version_history_service, + get_backup_service=ServiceRegistry.get_backup_service, ) diff --git a/py/routes/misc_route_registrar.py b/py/routes/misc_route_registrar.py index 9351d35a..9c7f6245 100644 --- a/py/routes/misc_route_registrar.py +++ b/py/routes/misc_route_registrar.py @@ -62,6 +62,10 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition( "GET", "/api/lm/metadata-archive-status", "get_metadata_archive_status" ), + RouteDefinition("GET", "/api/lm/backup/status", "get_backup_status"), + RouteDefinition("POST", "/api/lm/backup/export", "export_backup"), + RouteDefinition("POST", "/api/lm/backup/import", "import_backup"), + RouteDefinition("POST", "/api/lm/backup/open-location", "open_backup_location"), RouteDefinition( "GET", "/api/lm/model-versions-status", "get_model_versions_status" ), diff --git a/py/routes/misc_routes.py b/py/routes/misc_routes.py index a800bc86..d12a463e 100644 --- a/py/routes/misc_routes.py +++ b/py/routes/misc_routes.py @@ -23,6 +23,7 @@ from .handlers.misc_handlers import ( FileSystemHandler, HealthCheckHandler, LoraCodeHandler, + BackupHandler, MetadataArchiveHandler, MiscHandlerSet, ModelExampleFilesHandler, @@ -116,6 +117,7 @@ class MiscRoutes: settings_service=self._settings, metadata_provider_updater=self._metadata_provider_updater, ) + backup = BackupHandler() filesystem = FileSystemHandler(settings_service=self._settings) node_registry_handler = NodeRegistryHandler( node_registry=self._node_registry, @@ -141,6 +143,7 @@ class MiscRoutes: node_registry=node_registry_handler, model_library=model_library, metadata_archive=metadata_archive, + backup=backup, filesystem=filesystem, custom_words=custom_words, supporters=supporters, diff --git a/py/services/backup_service.py b/py/services/backup_service.py new file mode 100644 index 00000000..6864ff8f --- /dev/null +++ b/py/services/backup_service.py @@ -0,0 +1,411 @@ +from __future__ import annotations + +import asyncio +import contextlib +import hashlib +import json +import logging +import os +import shutil +import tempfile +import time +import zipfile +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable, Optional + +from ..utils.cache_paths import CacheType, get_cache_base_dir, get_cache_file_path +from ..utils.settings_paths import get_settings_dir +from .settings_manager import get_settings_manager + +logger = logging.getLogger(__name__) + + +BACKUP_MANIFEST_VERSION = 1 +DEFAULT_BACKUP_RETENTION_COUNT = 5 +DEFAULT_BACKUP_INTERVAL_SECONDS = 24 * 60 * 60 + + +@dataclass(frozen=True) +class BackupEntry: + kind: str + archive_path: str + target_path: str + sha256: str + size: int + mtime: float + + +class BackupService: + """Create and restore user-state backup archives.""" + + _instance: "BackupService | None" = None + _instance_lock = asyncio.Lock() + + def __init__(self, *, settings_manager=None, backup_dir: str | None = None) -> None: + self._settings = settings_manager or get_settings_manager() + self._backup_dir = Path(backup_dir or self._resolve_backup_dir()) + self._backup_dir.mkdir(parents=True, exist_ok=True) + self._lock = asyncio.Lock() + self._auto_task: asyncio.Task[None] | None = None + + @classmethod + async def get_instance(cls) -> "BackupService": + async with cls._instance_lock: + if cls._instance is None: + cls._instance = cls() + cls._instance._ensure_auto_snapshot_task() + return cls._instance + + @staticmethod + def _resolve_backup_dir() -> str: + return os.path.join(get_settings_dir(create=True), "backups") + + def get_backup_dir(self) -> str: + return str(self._backup_dir) + + def _ensure_auto_snapshot_task(self) -> None: + if self._auto_task is not None and not self._auto_task.done(): + return + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + + self._auto_task = loop.create_task(self._auto_backup_loop()) + + def _get_setting_bool(self, key: str, default: bool) -> bool: + try: + return bool(self._settings.get(key, default)) + except Exception: + return default + + def _get_setting_int(self, key: str, default: int) -> int: + try: + value = self._settings.get(key, default) + return max(1, int(value)) + except Exception: + return default + + def _settings_file_path(self) -> str: + settings_file = getattr(self._settings, "settings_file", None) + if settings_file: + return str(settings_file) + return os.path.join(get_settings_dir(create=True), "settings.json") + + def _download_history_path(self) -> str: + base_dir = get_cache_base_dir(create=True) + history_dir = os.path.join(base_dir, "download_history") + os.makedirs(history_dir, exist_ok=True) + return os.path.join(history_dir, "downloaded_versions.sqlite") + + def _model_update_dir(self) -> str: + return str(Path(get_cache_file_path(CacheType.MODEL_UPDATE, create_dir=True)).parent) + + def _model_update_targets(self) -> list[tuple[str, str, str]]: + """Return (kind, archive_path, target_path) tuples for backup.""" + + targets: list[tuple[str, str, str]] = [] + + settings_path = self._settings_file_path() + targets.append(("settings", "settings/settings.json", settings_path)) + + history_path = self._download_history_path() + targets.append( + ( + "download_history", + "cache/download_history/downloaded_versions.sqlite", + history_path, + ) + ) + + symlink_path = get_cache_file_path(CacheType.SYMLINK, create_dir=True) + targets.append( + ( + "symlink_map", + "cache/symlink/symlink_map.json", + symlink_path, + ) + ) + + model_update_dir = Path(self._model_update_dir()) + if model_update_dir.exists(): + for sqlite_file in sorted(model_update_dir.glob("*.sqlite")): + targets.append( + ( + "model_update", + f"cache/model_update/{sqlite_file.name}", + str(sqlite_file), + ) + ) + + return targets + + @staticmethod + def _hash_file(path: str) -> tuple[str, int, float]: + digest = hashlib.sha256() + total = 0 + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + total += len(chunk) + digest.update(chunk) + mtime = os.path.getmtime(path) + return digest.hexdigest(), total, mtime + + def _build_manifest(self, entries: Iterable[BackupEntry], *, snapshot_type: str) -> dict[str, Any]: + created_at = datetime.now(timezone.utc).isoformat() + active_library = None + try: + active_library = self._settings.get_active_library_name() + except Exception: + active_library = None + + return { + "manifest_version": BACKUP_MANIFEST_VERSION, + "created_at": created_at, + "snapshot_type": snapshot_type, + "active_library": active_library, + "files": [ + { + "kind": entry.kind, + "archive_path": entry.archive_path, + "target_path": entry.target_path, + "sha256": entry.sha256, + "size": entry.size, + "mtime": entry.mtime, + } + for entry in entries + ], + } + + def _write_archive(self, archive_path: str, entries: list[BackupEntry], manifest: dict[str, Any]) -> None: + with zipfile.ZipFile( + archive_path, + mode="w", + compression=zipfile.ZIP_DEFLATED, + compresslevel=6, + ) as zf: + zf.writestr( + "manifest.json", + json.dumps(manifest, indent=2, ensure_ascii=False).encode("utf-8"), + ) + for entry in entries: + zf.write(entry.target_path, arcname=entry.archive_path) + + async def create_snapshot(self, *, snapshot_type: str = "manual", persist: bool = False) -> dict[str, Any]: + """Create a backup archive. + + If ``persist`` is true, the archive is stored in the backup directory + and retained according to the configured retention policy. + """ + + async with self._lock: + raw_targets = self._model_update_targets() + entries: list[BackupEntry] = [] + for kind, archive_path, target_path in raw_targets: + if not os.path.exists(target_path): + continue + sha256, size, mtime = self._hash_file(target_path) + entries.append( + BackupEntry( + kind=kind, + archive_path=archive_path, + target_path=target_path, + sha256=sha256, + size=size, + mtime=mtime, + ) + ) + + if not entries: + raise FileNotFoundError("No backupable files were found") + + manifest = self._build_manifest(entries, snapshot_type=snapshot_type) + archive_name = self._build_archive_name(snapshot_type=snapshot_type) + fd, temp_path = tempfile.mkstemp(suffix=".zip", dir=str(self._backup_dir)) + os.close(fd) + + try: + self._write_archive(temp_path, entries, manifest) + if persist: + final_path = self._backup_dir / archive_name + os.replace(temp_path, final_path) + self._prune_snapshots() + return { + "archive_path": str(final_path), + "archive_name": final_path.name, + "manifest": manifest, + } + + with open(temp_path, "rb") as handle: + data = handle.read() + return { + "archive_name": archive_name, + "archive_bytes": data, + "manifest": manifest, + } + finally: + with contextlib.suppress(FileNotFoundError): + os.remove(temp_path) + + def _build_archive_name(self, *, snapshot_type: str) -> str: + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + return f"lora-manager-backup-{timestamp}-{snapshot_type}.zip" + + def _prune_snapshots(self) -> None: + retention = self._get_setting_int( + "backup_retention_count", DEFAULT_BACKUP_RETENTION_COUNT + ) + archives = sorted( + self._backup_dir.glob("lora-manager-backup-*-auto.zip"), + key=lambda path: path.stat().st_mtime, + reverse=True, + ) + for path in archives[retention:]: + with contextlib.suppress(OSError): + path.unlink() + + async def restore_snapshot(self, archive_path: str) -> dict[str, Any]: + """Restore backup contents from a ZIP archive.""" + + async with self._lock: + try: + zf = zipfile.ZipFile(archive_path, mode="r") + except zipfile.BadZipFile as exc: + raise ValueError("Backup archive is not a valid ZIP file") from exc + + with zf: + try: + manifest = json.loads(zf.read("manifest.json").decode("utf-8")) + except KeyError as exc: + raise ValueError("Backup archive is missing manifest.json") from exc + + if not isinstance(manifest, dict): + raise ValueError("Backup manifest is invalid") + if manifest.get("manifest_version") != BACKUP_MANIFEST_VERSION: + raise ValueError("Backup manifest version is not supported") + + files = manifest.get("files", []) + if not isinstance(files, list): + raise ValueError("Backup manifest file list is invalid") + + extracted_paths: list[tuple[str, str]] = [] + temp_dir = Path(tempfile.mkdtemp(prefix="lora-manager-restore-")) + try: + for item in files: + if not isinstance(item, dict): + continue + archive_member = item.get("archive_path") + if not isinstance(archive_member, str) or not archive_member: + continue + archive_member_path = Path(archive_member) + if archive_member_path.is_absolute() or ".." in archive_member_path.parts: + raise ValueError(f"Invalid archive member path: {archive_member}") + + kind = item.get("kind") + target_path = self._resolve_restore_target(kind, archive_member) + if target_path is None: + continue + + extracted_path = temp_dir / archive_member_path + extracted_path.parent.mkdir(parents=True, exist_ok=True) + with zf.open(archive_member) as source, open( + extracted_path, "wb" + ) as destination: + shutil.copyfileobj(source, destination) + + expected_hash = item.get("sha256") + if isinstance(expected_hash, str) and expected_hash: + actual_hash, _, _ = self._hash_file(str(extracted_path)) + if actual_hash != expected_hash: + raise ValueError( + f"Checksum mismatch for {archive_member}" + ) + + extracted_paths.append((str(extracted_path), target_path)) + + for extracted_path, target_path in extracted_paths: + os.makedirs(os.path.dirname(target_path), exist_ok=True) + os.replace(extracted_path, target_path) + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + return { + "success": True, + "restored_files": len(extracted_paths), + "snapshot_type": manifest.get("snapshot_type"), + } + + def _resolve_restore_target(self, kind: Any, archive_member: str) -> str | None: + if kind == "settings": + return self._settings_file_path() + if kind == "download_history": + return self._download_history_path() + if kind == "symlink_map": + return get_cache_file_path(CacheType.SYMLINK, create_dir=True) + if kind == "model_update": + filename = os.path.basename(archive_member) + return str(Path(get_cache_file_path(CacheType.MODEL_UPDATE, create_dir=True)).parent / filename) + return None + + async def create_auto_snapshot_if_due(self) -> Optional[dict[str, Any]]: + if not self._get_setting_bool("backup_auto_enabled", True): + return None + + latest = self.get_latest_auto_snapshot() + now = time.time() + if latest and now - latest["mtime"] < DEFAULT_BACKUP_INTERVAL_SECONDS: + return None + + return await self.create_snapshot(snapshot_type="auto", persist=True) + + async def _auto_backup_loop(self) -> None: + while True: + try: + await self.create_auto_snapshot_if_due() + await asyncio.sleep(DEFAULT_BACKUP_INTERVAL_SECONDS) + except asyncio.CancelledError: + raise + except Exception as exc: # pragma: no cover - defensive guard + logger.warning("Automatic backup snapshot failed: %s", exc, exc_info=True) + await asyncio.sleep(60) + + def get_available_snapshots(self) -> list[dict[str, Any]]: + snapshots: list[dict[str, Any]] = [] + for path in sorted(self._backup_dir.glob("lora-manager-backup-*.zip")): + try: + stat = path.stat() + except OSError: + continue + snapshots.append( + { + "name": path.name, + "path": str(path), + "size": stat.st_size, + "mtime": stat.st_mtime, + "is_auto": path.name.endswith("-auto.zip"), + } + ) + snapshots.sort(key=lambda item: item["mtime"], reverse=True) + return snapshots + + def get_latest_auto_snapshot(self) -> Optional[dict[str, Any]]: + autos = [snapshot for snapshot in self.get_available_snapshots() if snapshot["is_auto"]] + if not autos: + return None + return autos[0] + + def get_status(self) -> dict[str, Any]: + snapshots = self.get_available_snapshots() + return { + "backupDir": self.get_backup_dir(), + "enabled": self._get_setting_bool("backup_auto_enabled", True), + "retentionCount": self._get_setting_int( + "backup_retention_count", DEFAULT_BACKUP_RETENTION_COUNT + ), + "snapshotCount": len(snapshots), + "latestSnapshot": snapshots[0] if snapshots else None, + "latestAutoSnapshot": self.get_latest_auto_snapshot(), + } diff --git a/py/services/model_update_service.py b/py/services/model_update_service.py index d550537b..a69c4286 100644 --- a/py/services/model_update_service.py +++ b/py/services/model_update_service.py @@ -12,6 +12,7 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence from .errors import RateLimitError, ResourceNotFoundError from .settings_manager import get_settings_manager +from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration from ..utils.civitai_utils import rewrite_preview_url from ..utils.preview_selection import resolve_mature_threshold, select_preview_media @@ -234,12 +235,52 @@ class ModelUpdateService: ON model_update_versions(model_id); """ - def __init__(self, db_path: str, *, ttl_seconds: int = 24 * 60 * 60, settings_manager=None) -> None: - self._db_path = db_path + def __init__( + self, + db_path: str | None = None, + *, + ttl_seconds: int = 24 * 60 * 60, + settings_manager=None, + ) -> None: + self._settings = settings_manager or get_settings_manager() + self._library_name = self._get_active_library_name() + self._db_path = db_path or self._resolve_default_path(self._library_name) self._ttl_seconds = ttl_seconds self._lock = asyncio.Lock() self._schema_initialized = False - self._settings = settings_manager or get_settings_manager() + self._custom_db_path = db_path is not None + self._ensure_directory() + self._initialize_schema() + + def _get_active_library_name(self) -> str: + try: + value = self._settings.get_active_library_name() + except Exception: + value = None + return value or "default" + + def _resolve_default_path(self, library_name: str) -> str: + env_override = os.environ.get("LORA_MANAGER_MODEL_UPDATE_DB") + return resolve_cache_path_with_migration( + CacheType.MODEL_UPDATE, + library_name=library_name, + env_override=env_override, + ) + + def on_library_changed(self) -> None: + """Switch to the database for the active library.""" + + if self._custom_db_path: + return + + library_name = self._get_active_library_name() + new_path = self._resolve_default_path(library_name) + if new_path == self._db_path: + return + + self._library_name = library_name + self._db_path = new_path + self._schema_initialized = False self._ensure_directory() self._initialize_schema() @@ -262,11 +303,114 @@ class ModelUpdateService: conn.execute("PRAGMA foreign_keys = ON") conn.executescript(self._SCHEMA) self._apply_migrations(conn) + self._migrate_from_legacy_snapshot(conn) self._schema_initialized = True except Exception as exc: # pragma: no cover - defensive guard logger.error("Failed to initialize update schema: %s", exc, exc_info=True) raise + def _migrate_from_legacy_snapshot(self, conn: sqlite3.Connection) -> None: + """Copy update tracking data out of the legacy model snapshot database.""" + + if self._custom_db_path: + return + + try: + from .persistent_model_cache import get_persistent_cache + + legacy_path = get_persistent_cache(self._library_name).get_database_path() + except Exception: + return + + if not legacy_path or os.path.abspath(legacy_path) == os.path.abspath(self._db_path): + return + if not os.path.exists(legacy_path): + return + + try: + existing_row = conn.execute( + "SELECT 1 FROM model_update_status LIMIT 1" + ).fetchone() + if existing_row: + return + except Exception: + return + + try: + with sqlite3.connect(legacy_path, check_same_thread=False) as legacy_conn: + legacy_conn.row_factory = sqlite3.Row + status_rows = legacy_conn.execute( + """ + SELECT model_id, model_type, last_checked_at, should_ignore_model + FROM model_update_status + """ + ).fetchall() + if not status_rows: + return + + version_rows = legacy_conn.execute( + """ + SELECT model_id, version_id, sort_index, name, base_model, released_at, + size_bytes, preview_url, is_in_library, should_ignore, + early_access_ends_at, is_early_access + FROM model_update_versions + ORDER BY model_id ASC, sort_index ASC, version_id ASC + """ + ).fetchall() + + conn.execute("BEGIN") + conn.executemany( + """ + INSERT OR REPLACE INTO model_update_status ( + model_id, model_type, last_checked_at, should_ignore_model + ) VALUES (?, ?, ?, ?) + """, + [ + ( + int(row["model_id"]), + row["model_type"], + row["last_checked_at"], + int(row["should_ignore_model"] or 0), + ) + for row in status_rows + ], + ) + conn.executemany( + """ + INSERT OR REPLACE INTO model_update_versions ( + model_id, version_id, sort_index, name, base_model, released_at, + size_bytes, preview_url, is_in_library, should_ignore, + early_access_ends_at, is_early_access + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + [ + ( + int(row["model_id"]), + int(row["version_id"]), + int(row["sort_index"] or 0), + row["name"], + row["base_model"], + row["released_at"], + row["size_bytes"], + row["preview_url"], + int(row["is_in_library"] or 0), + int(row["should_ignore"] or 0), + row["early_access_ends_at"], + int(row["is_early_access"] or 0), + ) + for row in version_rows + ], + ) + conn.commit() + logger.info( + "Migrated model update tracking data from legacy snapshot DB for %s", + self._library_name, + ) + except sqlite3.OperationalError as exc: + logger.debug("Legacy model update migration skipped: %s", exc) + except Exception as exc: # pragma: no cover - defensive guard + logger.warning("Failed to migrate model update data: %s", exc, exc_info=True) + def _apply_migrations(self, conn: sqlite3.Connection) -> None: """Ensure legacy databases match the current schema without dropping data.""" diff --git a/py/services/service_registry.py b/py/services/service_registry.py index 8d9319de..7d16c905 100644 --- a/py/services/service_registry.py +++ b/py/services/service_registry.py @@ -159,10 +159,9 @@ class ServiceRegistry: return cls._services[service_name] from .model_update_service import ModelUpdateService - from .persistent_model_cache import get_persistent_cache + from .settings_manager import get_settings_manager - cache = get_persistent_cache() - service = ModelUpdateService(cache.get_database_path()) + service = ModelUpdateService(settings_manager=get_settings_manager()) cls._services[service_name] = service logger.debug(f"Created and registered {service_name}") return service @@ -189,6 +188,26 @@ class ServiceRegistry: logger.debug(f"Created and registered {service_name}") return service + @classmethod + async def get_backup_service(cls): + """Get or create the backup service.""" + + service_name = "backup_service" + + if service_name in cls._services: + return cls._services[service_name] + + async with cls._get_lock(service_name): + if service_name in cls._services: + return cls._services[service_name] + + from .backup_service import BackupService + + service = await BackupService.get_instance() + cls._services[service_name] = service + logger.debug(f"Created and registered {service_name}") + return service + @classmethod async def get_civarchive_client(cls): """Get or create CivArchive client instance""" diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index bfcf04ab..00d1319e 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -95,6 +95,8 @@ DEFAULT_SETTINGS: Dict[str, Any] = { "metadata_refresh_skip_paths": [], "skip_previously_downloaded_model_versions": False, "download_skip_base_models": [], + "backup_auto_enabled": True, + "backup_retention_count": 5, } @@ -1983,6 +1985,7 @@ class SettingsManager: "checkpoint_scanner", "embedding_scanner", "recipe_scanner", + "model_update_service", ): service = ServiceRegistry.get_service_sync(service_name) if service and hasattr(service, "on_library_changed"): diff --git a/py/utils/cache_paths.py b/py/utils/cache_paths.py index c7658a44..6ade6871 100644 --- a/py/utils/cache_paths.py +++ b/py/utils/cache_paths.py @@ -11,6 +11,8 @@ Target structure: │ └── symlink_map.json ├── model/ │ └── {library_name}.sqlite + ├── model_update/ + │ └── {library_name}.sqlite ├── recipe/ │ └── {library_name}.sqlite └── fts/ @@ -36,6 +38,7 @@ class CacheType(Enum): """Types of cache files managed by the cache path resolver.""" MODEL = "model" + MODEL_UPDATE = "model_update" RECIPE = "recipe" RECIPE_FTS = "recipe_fts" TAG_FTS = "tag_fts" @@ -45,6 +48,7 @@ class CacheType(Enum): # Subdirectory structure for each cache type _CACHE_SUBDIRS = { CacheType.MODEL: "model", + CacheType.MODEL_UPDATE: "model_update", CacheType.RECIPE: "recipe", CacheType.RECIPE_FTS: "fts", CacheType.TAG_FTS: "fts", @@ -54,6 +58,7 @@ _CACHE_SUBDIRS = { # Filename patterns for each cache type _CACHE_FILENAMES = { CacheType.MODEL: "{library_name}.sqlite", + CacheType.MODEL_UPDATE: "{library_name}.sqlite", CacheType.RECIPE: "{library_name}.sqlite", CacheType.RECIPE_FTS: "recipe_fts.sqlite", CacheType.TAG_FTS: "tag_fts.sqlite", diff --git a/static/css/components/modal/_base.css b/static/css/components/modal/_base.css index 18efbca0..be4f21b0 100644 --- a/static/css/components/modal/_base.css +++ b/static/css/components/modal/_base.css @@ -311,6 +311,161 @@ button:disabled, color: var(--lora-error, #ef4444); } +.backup-status { + background: rgba(0, 0, 0, 0.03); + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: var(--border-radius-sm); + padding: var(--space-3); +} + +[data-theme="dark"] .backup-status { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--lora-border); +} + +.backup-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.backup-summary-card { + background: rgba(255, 255, 255, 0.5); + border: 1px solid rgba(0, 0, 0, 0.06); + border-radius: var(--border-radius-sm); + padding: var(--space-2); +} + +[data-theme="dark"] .backup-summary-card { + background: rgba(255, 255, 255, 0.02); + border-color: rgba(255, 255, 255, 0.05); +} + +.backup-summary-label { + color: var(--text-color); + font-size: 0.85rem; + opacity: 0.7; + margin-bottom: 6px; +} + +.backup-summary-value { + color: var(--text-color); + font-size: 1.1rem; + font-weight: 600; + line-height: 1.3; + word-break: break-word; +} + +.backup-summary-value.status-enabled { + color: var(--lora-success, #10b981); +} + +.backup-summary-value.status-disabled { + color: var(--lora-error, #ef4444); +} + +.backup-status-list { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.backup-status-row { + display: grid; + grid-template-columns: minmax(140px, 180px) 1fr; + gap: var(--space-2); + align-items: start; +} + +.backup-status-label { + color: var(--text-color); + font-weight: 500; + opacity: 0.8; +} + +.backup-status-content { + min-width: 0; +} + +.backup-status-primary { + color: var(--text-color); + font-weight: 600; + line-height: 1.4; +} + +.backup-status-secondary { + color: var(--text-color); + opacity: 0.72; + font-size: 0.88rem; + line-height: 1.4; + word-break: break-word; + margin-top: 2px; +} + +.backup-location-details { + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: var(--border-radius-sm); + background: rgba(0, 0, 0, 0.02); +} + +[data-theme="dark"] .backup-location-details { + border-color: var(--lora-border); + background: rgba(255, 255, 255, 0.02); +} + +.backup-location-details summary { + cursor: pointer; + padding: var(--space-2) var(--space-3); + color: var(--text-color); + font-weight: 500; +} + +.backup-location-panel { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--space-2); + align-items: center; + width: 100%; + max-width: 100%; + box-sizing: border-box; + padding: 0 var(--space-3) var(--space-3); +} + +.backup-location-panel .text-btn { + justify-self: end; +} + +.backup-location-path { + display: block; + min-width: 0; + max-width: 100%; + padding: 6px 8px; + border-radius: var(--border-radius-sm); + background: rgba(0, 0, 0, 0.05); + color: var(--text-color); + overflow-wrap: anywhere; + word-break: break-word; +} + +[data-theme="dark"] .backup-location-path { + background: rgba(255, 255, 255, 0.05); +} + +@media (max-width: 768px) { + .backup-status-row { + grid-template-columns: 1fr; + } + + .backup-location-panel { + grid-template-columns: 1fr; + } + + .backup-location-panel .text-btn { + justify-self: start; + } +} + /* Add styles for delete preview image */ .delete-preview { max-width: 150px; diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index 9af3d6c5..df441970 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -361,6 +361,13 @@ export class SettingsManager { }); } + const openBackupLocationButton = document.getElementById('backupOpenLocationBtn'); + if (openBackupLocationButton) { + openBackupLocationButton.addEventListener('click', () => { + this.openBackupLocation(); + }); + } + ['lora', 'checkpoint', 'embedding'].forEach(modelType => { const customInput = document.getElementById(`${modelType}CustomTemplate`); if (customInput) { @@ -742,6 +749,35 @@ export class SettingsManager { } } + async openBackupLocation() { + try { + const response = await fetch('/api/lm/backup/open-location', { + method: 'POST' + }); + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + const data = await response.json(); + + if (data.mode === 'clipboard' && data.path) { + try { + await navigator.clipboard.writeText(data.path); + showToast('settings.backup.locationCopied', { path: data.path }, 'success'); + } catch (clipboardErr) { + console.warn('Clipboard API not available:', clipboardErr); + showToast('settings.backup.locationClipboardFallback', { path: data.path }, 'info'); + } + } else { + showToast('settings.backup.openFolderSuccess', {}, 'success'); + } + } catch (error) { + console.error('Failed to open backup folder:', error); + showToast('settings.backup.openFolderFailed', {}, 'error'); + } + } + async loadSettingsToUI() { // Set frontend settings from state const blurMatureContentCheckbox = document.getElementById('blurMatureContent'); @@ -878,6 +914,9 @@ export class SettingsManager { // Load metadata archive settings await this.loadMetadataArchiveSettings(); + // Load backup settings + await this.loadBackupSettings(); + // Load base model path mappings this.loadBaseModelMappings(); @@ -1857,6 +1896,10 @@ export class SettingsManager { await this.updateMetadataArchiveStatus(); } + if (settingKey === 'backup_auto_enabled') { + await this.updateBackupStatus(); + } + showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success'); // Apply frontend settings immediately @@ -1945,6 +1988,163 @@ export class SettingsManager { } } + async loadBackupSettings() { + const backupAutoEnabledCheckbox = document.getElementById('backupAutoEnabled'); + if (backupAutoEnabledCheckbox) { + backupAutoEnabledCheckbox.checked = state.global.settings.backup_auto_enabled ?? true; + } + + const backupRetentionCountInput = document.getElementById('backupRetentionCount'); + if (backupRetentionCountInput) { + backupRetentionCountInput.value = state.global.settings.backup_retention_count ?? 5; + } + + await this.updateBackupStatus(); + } + + async updateBackupStatus() { + try { + const response = await fetch('/api/lm/backup/status'); + const data = await response.json(); + + const statusContainer = document.getElementById('backupStatus'); + if (!statusContainer || !data.success) { + return; + } + + const status = data.status || {}; + const latestAutoSnapshot = status.latestAutoSnapshot; + const retentionCount = status.retentionCount ?? state.global.settings.backup_retention_count ?? 5; + const enabled = status.enabled ?? state.global.settings.backup_auto_enabled ?? true; + const backupDir = status.backupDir || ''; + const backupLocationPath = document.getElementById('backupLocationPath'); + if (backupLocationPath) { + backupLocationPath.textContent = backupDir; + backupLocationPath.title = backupDir; + } + + const formatTimestamp = (timestamp) => { + if (!timestamp) { + return translate('common.status.unknown', {}, 'Unknown'); + } + return new Date(timestamp * 1000).toLocaleString(); + }; + + const renderSnapshotDetail = (snapshot) => { + if (!snapshot) { + return translate('settings.backup.noneAvailable', {}, 'No snapshots yet'); + } + + const size = typeof snapshot.size === 'number' ? ` (${this.formatFileSize(snapshot.size)})` : ''; + return `${snapshot.name}${size}`; + }; + + statusContainer.innerHTML = ` +
+
+