diff --git a/locales/de.json b/locales/de.json index 2d825a56..e96423a3 100644 --- a/locales/de.json +++ b/locales/de.json @@ -622,6 +622,10 @@ "label": "Früher Zugriff Updates ausblenden", "help": "Nur Early-Access-Updates" }, + "hidePaidUpdates": { + "label": "[TODO: Translate] Hide Paid Updates", + "help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge" + }, "licenseIcons": { "useNewStyle": "Aktualisierte Lizenzsymbole verwenden", "useNewStyleHelp": "Lizenzberechtigungen mit farbigen Indikatoren (neuer Stil) oder nur Einschränkungssymbolen (klassischer Stil) anzeigen. Orientiert sich am aktuellen CivitAI-Design." @@ -1550,6 +1554,8 @@ "newerTooltip": "Diese Version ist neuer als Ihre neueste lokale Version", "earlyAccess": "Früher Zugriff", "earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich", + "paid": "[TODO: Translate] Paid", + "paidTooltip": "[TODO: Translate] This version requires payment to download", "ignored": "Ignoriert", "ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert", "onSiteOnly": "Nur On-Site", @@ -1559,6 +1565,7 @@ "download": "Herunterladen", "downloadTooltip": "Diese Version herunterladen", "downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen", + "downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai", "downloadNotAllowedTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar", "delete": "Löschen", "deleteTooltip": "Diese lokale Version löschen", diff --git a/locales/en.json b/locales/en.json index b3f0a056..2312d47a 100644 --- a/locales/en.json +++ b/locales/en.json @@ -622,6 +622,10 @@ "label": "Hide Early Access Updates", "help": "When enabled, models with only early access updates will not show 'Update available' badge" }, + "hidePaidUpdates": { + "label": "Hide Paid Updates", + "help": "When enabled, models with only paid updates will not show 'Update available' badge" + }, "licenseIcons": { "useNewStyle": "Use updated license icons", "useNewStyleHelp": "Display license permissions with colored indicators (new style) or restriction-only icons (classic style). Mirroring the current CivitAI design." @@ -1550,6 +1554,8 @@ "newerTooltip": "This version is newer than your latest local version", "earlyAccess": "Early Access", "earlyAccessTooltip": "This version currently requires Civitai early access", + "paid": "Paid", + "paidTooltip": "This version requires payment to download", "ignored": "Ignored", "ignoredTooltip": "Update notifications are disabled for this version", "onSiteOnly": "On-Site Only", @@ -1559,6 +1565,7 @@ "download": "Download", "downloadTooltip": "Download this version", "downloadEarlyAccessTooltip": "Download this early access version from Civitai", + "downloadPaidTooltip": "Download this paid version from Civitai", "downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai", "delete": "Delete", "deleteTooltip": "Delete this local version", diff --git a/locales/es.json b/locales/es.json index 338cb331..7e16662f 100644 --- a/locales/es.json +++ b/locales/es.json @@ -622,6 +622,10 @@ "label": "Ocultar actualizaciones de acceso temprano", "help": "Solo actualizaciones de acceso temprano" }, + "hidePaidUpdates": { + "label": "[TODO: Translate] Hide Paid Updates", + "help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge" + }, "licenseIcons": { "useNewStyle": "Usar iconos de licencia actualizados", "useNewStyleHelp": "Mostrar permisos de licencia con indicadores de color (nuevo estilo) o solo iconos de restricción (estilo clásico). Refleja el diseño actual de CivitAI." @@ -1550,6 +1554,8 @@ "newerTooltip": "Esta versión es más reciente que tu última versión local", "earlyAccess": "Acceso temprano", "earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai", + "paid": "[TODO: Translate] Paid", + "paidTooltip": "[TODO: Translate] This version requires payment to download", "ignored": "Ignorada", "ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión", "onSiteOnly": "Solo en Sitio", @@ -1559,6 +1565,7 @@ "download": "Descargar", "downloadTooltip": "Descargar esta versión", "downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde 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", "delete": "Eliminar", "deleteTooltip": "Eliminar esta versión local", diff --git a/locales/fr.json b/locales/fr.json index 91a58c0e..1a96b4cf 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -622,6 +622,10 @@ "label": "Masquer les mises à jour en accès anticipé", "help": "Seulement les mises à jour en accès anticipé" }, + "hidePaidUpdates": { + "label": "[TODO: Translate] Hide Paid Updates", + "help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge" + }, "licenseIcons": { "useNewStyle": "Utiliser les icônes de licence mises à jour", "useNewStyleHelp": "Afficher les permissions de licence avec des indicateurs colorés (nouveau style) ou des icônes de restriction uniquement (style classique). Reprend le design actuel de CivitAI." @@ -1550,6 +1554,8 @@ "newerTooltip": "Cette version est plus récente que votre dernière version locale", "earlyAccess": "Accès anticipé", "earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai", + "paid": "[TODO: Translate] Paid", + "paidTooltip": "[TODO: Translate] This version requires payment to download", "ignored": "Ignorée", "ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version", "onSiteOnly": "Uniquement sur Site", @@ -1559,6 +1565,7 @@ "download": "Télécharger", "downloadTooltip": "Télécharger cette version", "downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis 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", "delete": "Supprimer", "deleteTooltip": "Supprimer cette version locale", diff --git a/locales/he.json b/locales/he.json index b5288e40..1dd3a234 100644 --- a/locales/he.json +++ b/locales/he.json @@ -622,6 +622,10 @@ "label": "הסתר עדכוני גישה מוקדמת", "help": "רק עדכוני גישה מוקדמת" }, + "hidePaidUpdates": { + "label": "[TODO: Translate] Hide Paid Updates", + "help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge" + }, "licenseIcons": { "useNewStyle": "השתמש בסמלי רישיון מעודכנים", "useNewStyleHelp": "הצג הרשאות רישיון עם מחוונים צבעוניים (סגנון חדש) או סמלי הגבלה בלבד (סגנון קלאסי). משקף את העיצוב העדכני של CivitAI." @@ -1550,6 +1554,8 @@ "newerTooltip": "גרסה זו חדשה יותר מהגרסה המקומית האחרונה שלך", "earlyAccess": "גישה מוקדמת", "earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai", + "paid": "[TODO: Translate] Paid", + "paidTooltip": "[TODO: Translate] This version requires payment to download", "ignored": "התעלם", "ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו", "onSiteOnly": "רק באתר", @@ -1559,6 +1565,7 @@ "download": "הורדה", "downloadTooltip": "הורד את הגרסה הזו", "downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai", + "downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai", "downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai", "delete": "מחיקה", "deleteTooltip": "מחק את הגרסה המקומית הזו", diff --git a/locales/ja.json b/locales/ja.json index 203b8174..c02ca7d6 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -622,6 +622,10 @@ "label": "早期アクセス更新を非表示", "help": "早期アクセスのみの更新" }, + "hidePaidUpdates": { + "label": "[TODO: Translate] Hide Paid Updates", + "help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge" + }, "licenseIcons": { "useNewStyle": "更新されたライセンスアイコンを使用", "useNewStyleHelp": "カラーインジケーター付きでライセンス許可を表示(新スタイル)するか、制限のみのアイコンを表示(クラシックスタイル)します。現在のCivitAIデザインを反映しています。" @@ -1550,6 +1554,8 @@ "newerTooltip": "このバージョンはローカルの最新バージョンより新しいです", "earlyAccess": "早期アクセス", "earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です", + "paid": "[TODO: Translate] Paid", + "paidTooltip": "[TODO: Translate] This version requires payment to download", "ignored": "無視中", "ignoredTooltip": "このバージョンの更新通知は無効です", "onSiteOnly": "サイト内のみ", @@ -1559,6 +1565,7 @@ "download": "ダウンロード", "downloadTooltip": "このバージョンをダウンロード", "downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード", + "downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai", "downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません", "delete": "削除", "deleteTooltip": "このローカルバージョンを削除", diff --git a/locales/ko.json b/locales/ko.json index 247f10f6..009a0580 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -622,6 +622,10 @@ "label": "얼리 액세스 업데이트 숨기기", "help": "얼리 액세스 업데이트만" }, + "hidePaidUpdates": { + "label": "[TODO: Translate] Hide Paid Updates", + "help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge" + }, "licenseIcons": { "useNewStyle": "업데이트된 라이선스 아이콘 사용", "useNewStyleHelp": "색상 표시기가 있는 라이선스 권한(새 스타일) 또는 제한 전용 아이콘(클래식 스타일)을 표시합니다. 현재 CivitAI 디자인을 반영합니다." @@ -1550,6 +1554,8 @@ "newerTooltip": "이 버전은 로컬의 최신 버전보다 더 새롭습니다", "earlyAccess": "얼리 액세스", "earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다", + "paid": "[TODO: Translate] Paid", + "paidTooltip": "[TODO: Translate] This version requires payment to download", "ignored": "무시됨", "ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다", "onSiteOnly": "사이트 내 전용", @@ -1559,6 +1565,7 @@ "download": "다운로드", "downloadTooltip": "이 버전 다운로드", "downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드", + "downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai", "downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다", "delete": "삭제", "deleteTooltip": "이 로컬 버전 삭제", diff --git a/locales/ru.json b/locales/ru.json index 4dc8e561..595080c7 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -622,6 +622,10 @@ "label": "Скрыть обновления раннего доступа", "help": "Только обновления раннего доступа" }, + "hidePaidUpdates": { + "label": "[TODO: Translate] Hide Paid Updates", + "help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge" + }, "licenseIcons": { "useNewStyle": "Использовать обновлённые значки лицензии", "useNewStyleHelp": "Отображать разрешения лицензии с цветными индикаторами (новый стиль) или только значки ограничений (классический стиль). Соответствует текущему дизайну CivitAI." @@ -1550,6 +1554,8 @@ "newerTooltip": "Эта версия новее вашей последней локальной версии", "earlyAccess": "Ранний доступ", "earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai", + "paid": "[TODO: Translate] Paid", + "paidTooltip": "[TODO: Translate] This version requires payment to download", "ignored": "Игнорируется", "ignoredTooltip": "Уведомления об обновлениях для этой версии отключены", "onSiteOnly": "Только на Сайте", @@ -1559,6 +1565,7 @@ "download": "Скачать", "downloadTooltip": "Скачать эту версию", "downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai", + "downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai", "downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai", "delete": "Удалить", "deleteTooltip": "Удалить эту локальную версию", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index d6cbb712..0b6c0be8 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -622,6 +622,10 @@ "label": "隐藏抢先体验更新", "help": "抢先体验更新" }, + "hidePaidUpdates": { + "label": "[TODO: Translate] Hide Paid Updates", + "help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge" + }, "licenseIcons": { "useNewStyle": "使用新版许可协议图标", "useNewStyleHelp": "以彩色指示器显示许可权限(新样式),或仅显示限制图标(经典样式)。与当前 CivitAI 设计保持一致。" @@ -1550,6 +1554,8 @@ "newerTooltip": "此版本比你本地的最新版本更新", "earlyAccess": "抢先体验", "earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限", + "paid": "[TODO: Translate] Paid", + "paidTooltip": "[TODO: Translate] This version requires payment to download", "ignored": "已忽略", "ignoredTooltip": "此版本已关闭更新通知", "onSiteOnly": "仅站内生成", @@ -1559,6 +1565,7 @@ "download": "下载", "downloadTooltip": "下载此版本", "downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本", + "downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai", "downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载", "delete": "删除", "deleteTooltip": "删除此本地版本", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 5f17c8ba..c4551a9d 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -622,6 +622,10 @@ "label": "隱藏搶先體驗更新", "help": "搶先體驗更新" }, + "hidePaidUpdates": { + "label": "[TODO: Translate] Hide Paid Updates", + "help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge" + }, "licenseIcons": { "useNewStyle": "使用新版許可協議圖標", "useNewStyleHelp": "以彩色指示器顯示許可權限(新樣式),或僅顯示限制圖標(經典樣式)。與當前 CivitAI 設計保持一致。" @@ -1550,6 +1554,8 @@ "newerTooltip": "此版本比你本地的最新版本更新", "earlyAccess": "搶先體驗", "earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限", + "paid": "[TODO: Translate] Paid", + "paidTooltip": "[TODO: Translate] This version requires payment to download", "ignored": "已忽略", "ignoredTooltip": "此版本已關閉更新通知", "onSiteOnly": "僅站內生成", @@ -1559,6 +1565,7 @@ "download": "下載", "downloadTooltip": "下載此版本", "downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本", + "downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai", "downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載", "delete": "刪除", "deleteTooltip": "刪除此本地版本", diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index 2ba5998b..0ef47c21 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -2535,6 +2535,7 @@ class ModelUpdateHandler: return web.json_response({"success": False, "error": str(exc)}, status=500) hide_early_access = False + hide_paid = False if self._settings is not None: try: hide_early_access = bool( @@ -2542,12 +2543,17 @@ class ModelUpdateHandler: ) except Exception: pass + try: + hide_paid = bool(self._settings.get("hide_paid_updates", False)) + except Exception: + pass serialized_records = [] for record in records.values(): has_update_fn = getattr(record, "has_update", None) if callable(has_update_fn) and has_update_fn( - hide_early_access=hide_early_access + hide_early_access=hide_early_access, + hide_paid=hide_paid, ): serialized_records.append(self._serialize_record(record)) @@ -2701,10 +2707,16 @@ class ModelUpdateHandler: if not record or not record.versions: return record - # Find versions that need enrichment + # Find versions that need enrichment. Permanent paid versions are not + # early access (mirror _is_early_access_active) and never carry an end + # time, so skip them to avoid pointless per-version API calls. versions_needing_update = [] for version in record.versions: - if version.is_early_access and not version.early_access_ends_at: + if ( + version.is_early_access + and not version.early_access_ends_at + and not getattr(version, "is_paid", False) + ): versions_needing_update.append(version) if not versions_needing_update: @@ -2934,6 +2946,7 @@ class ModelUpdateHandler: context = version_context or {} # Check user setting for hiding early access versions hide_early_access = False + hide_paid = False if self._settings is not None: try: hide_early_access = bool( @@ -2941,6 +2954,10 @@ class ModelUpdateHandler: ) except Exception: pass + try: + hide_paid = bool(self._settings.get("hide_paid_updates", False)) + except Exception: + pass return { "modelType": record.model_type, "modelId": record.model_id, @@ -2949,7 +2966,10 @@ class ModelUpdateHandler: "inLibraryVersionIds": record.in_library_version_ids, "lastCheckedAt": record.last_checked_at, "shouldIgnore": record.should_ignore_model, - "hasUpdate": record.has_update(hide_early_access=hide_early_access), + "hasUpdate": record.has_update( + hide_early_access=hide_early_access, + hide_paid=hide_paid, + ), "versions": [ self._serialize_version(version, context.get(version.version_id)) for version in record.versions @@ -2968,8 +2988,11 @@ class ModelUpdateHandler: # Determine if version is currently in early access # Two-phase detection: use exact end time if available, otherwise fallback to basic flag + # Mirror _is_early_access_active: permanent paid versions (no end time) are NOT early access is_early_access = False - if version.early_access_ends_at: + if getattr(version, "is_paid", False) and not version.early_access_ends_at: + is_early_access = False + elif version.early_access_ends_at: try: from datetime import datetime, timezone @@ -2984,6 +3007,13 @@ class ModelUpdateHandler: # Fallback to basic EA flag from bulk API is_early_access = True + paid_access_payload = None + if getattr(version, "paid_access", None): + try: + paid_access_payload = json.loads(version.paid_access) + except (TypeError, ValueError): + paid_access_payload = None + return { "versionId": version.version_id, "name": version.name, @@ -2997,6 +3027,8 @@ class ModelUpdateHandler: "earlyAccessEndsAt": version.early_access_ends_at, "isEarlyAccess": is_early_access, "usageControl": version.usage_control, + "isPaid": bool(getattr(version, "is_paid", False)), + "paidAccess": paid_access_payload, "filePath": context.get("file_path"), "fileName": context.get("file_name"), } diff --git a/py/services/base_model_service.py b/py/services/base_model_service.py index f22868b3..2a250a78 100644 --- a/py/services/base_model_service.py +++ b/py/services/base_model_service.py @@ -633,6 +633,13 @@ class BaseModelService(ABC): except Exception: hide_early_access = False + # Check user setting for hiding permanent paid updates + hide_paid = False + try: + hide_paid = bool(self.settings.get("hide_paid_updates", False)) + except Exception: + hide_paid = False + records = None resolved: Optional[Dict[int, bool]] = None if same_base_mode: @@ -641,7 +648,10 @@ class BaseModelService(ABC): try: records = await cast(Awaitable[Any], record_method(self.model_type, ordered_ids)) resolved = { - model_id: record.has_update(hide_early_access=hide_early_access) + model_id: record.has_update( + hide_early_access=hide_early_access, + hide_paid=hide_paid, + ) for model_id, record in records.items() } except Exception as exc: @@ -663,6 +673,7 @@ class BaseModelService(ABC): self.model_type, ordered_ids, hide_early_access=hide_early_access, + hide_paid=hide_paid, )) except Exception as exc: logger.error( @@ -677,7 +688,10 @@ class BaseModelService(ABC): if resolved is None: tasks = [ self.update_service.has_update( - self.model_type, model_id, hide_early_access=hide_early_access + self.model_type, + model_id, + hide_early_access=hide_early_access, + hide_paid=hide_paid, ) for model_id in ordered_ids ] @@ -717,6 +731,7 @@ class BaseModelService(ABC): threshold_version, base_model, hide_early_access=hide_early_access, + hide_paid=hide_paid, ) else: flag = default_flag diff --git a/py/services/download_manager.py b/py/services/download_manager.py index 29d8cf5d..ff4a2ca6 100644 --- a/py/services/download_manager.py +++ b/py/services/download_manager.py @@ -3,6 +3,7 @@ # reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms # import cycles. Breaking them would require an architectural refactor. import copy +import json import logging import os import asyncio @@ -1434,24 +1435,48 @@ class DownloadManager: # Create directory if it doesn't exist os.makedirs(save_dir, exist_ok=True) - # Check if this is an early access model - if version_info.get("earlyAccessEndsAt"): - early_access_date = version_info.get("earlyAccessEndsAt", "") - # Convert to a readable date if possible + # Check if this is a paid or early access model + paid_access = version_info.get("paidAccess") + if isinstance(paid_access, str): + # Some providers (e.g. CivArchive fallback) carry the DTO as JSON text try: - from datetime import datetime - - date_obj = datetime.fromisoformat( - early_access_date.replace("Z", "+00:00") - ) - formatted_date = date_obj.strftime("%Y-%m-%d") + parsed = json.loads(paid_access) + paid_access = parsed if isinstance(parsed, dict) else None + except (TypeError, ValueError): + paid_access = None + if not isinstance(paid_access, dict): + paid_access = None + # An empty DTO ({"permanent": false, "endsAt": null}) is not a gate + if paid_access and not paid_access.get("permanent") and not paid_access.get("endsAt"): + paid_access = None + if version_info.get("earlyAccessEndsAt") or paid_access: + permanent_paid = bool(paid_access.get("permanent")) if paid_access else False + if permanent_paid: early_access_msg = ( - f"This model requires payment (until {formatted_date}). " + "This model requires payment. Please ensure you have " + "purchased access and are logged in to Civitai." ) - except: - early_access_msg = "This model requires payment. " + else: + early_access_date = version_info.get("earlyAccessEndsAt") + if not early_access_date and paid_access: + early_access_date = paid_access.get("endsAt") + if not early_access_date: + early_access_date = "" + # Convert to a readable date if possible + try: + from datetime import datetime - early_access_msg += "Please ensure you have purchased early access and are logged in to Civitai." + date_obj = datetime.fromisoformat( + early_access_date.replace("Z", "+00:00") + ) + formatted_date = date_obj.strftime("%Y-%m-%d") + early_access_msg = ( + f"This model requires payment (until {formatted_date}). " + ) + except Exception: + early_access_msg = "This model requires payment. " + + early_access_msg += "Please ensure you have purchased early access and are logged in to Civitai." logger.warning( f"Early access model detected: {version_info.get('name', 'Unknown')}" ) diff --git a/py/services/model_update_service.py b/py/services/model_update_service.py index fe30f473..08686784 100644 --- a/py/services/model_update_service.py +++ b/py/services/model_update_service.py @@ -6,6 +6,7 @@ from __future__ import annotations import asyncio +import json import logging import os import sqlite3 @@ -74,6 +75,8 @@ class ModelVersionRecord: sort_index: int = 0 is_early_access: bool = False usage_control: Optional[str] = None # "Download", "Generation", "InternalGeneration" + paid_access: Optional[str] = None # JSON string of the CivitAI paidAccess DTO + is_paid: bool = False # True when paidAccess.permanent is True (permanent paid gate) @dataclass @@ -107,13 +110,17 @@ class ModelUpdateRecord: return [version.version_id for version in self.versions if version.is_in_library] def has_update( - self, hide_early_access: bool = False, hide_non_downloadable: bool = True + self, + hide_early_access: bool = False, + hide_non_downloadable: bool = True, + hide_paid: bool = False, ) -> bool: """Return True when a non-ignored remote version newer than the newest local copy is available. Args: hide_early_access: If True, exclude early access versions from update check. hide_non_downloadable: If True, exclude versions that don't allow downloads. + hide_paid: If True, exclude permanent paid versions from update check. """ if self.should_ignore_model: @@ -129,6 +136,7 @@ class ModelUpdateRecord: not version.is_in_library and not version.should_ignore and not (hide_early_access and ModelUpdateRecord._is_early_access_active(version)) + and not (hide_paid and version.is_paid) and not (hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version)) for version in self.versions ) @@ -138,6 +146,8 @@ class ModelUpdateRecord: continue if hide_early_access and ModelUpdateRecord._is_early_access_active(version): continue + if hide_paid and version.is_paid: + continue if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version): continue if version.version_id > max_in_library: @@ -152,6 +162,11 @@ class ModelUpdateRecord: 1. If exact EA end time available (from single version API), use it for precise check 2. Otherwise fallback to basic EA flag (from bulk API) """ + # Permanent paid versions are not early access; they are filtered by + # hide_paid instead. Only timed gates count as early access. + if version.is_paid and not version.early_access_ends_at: + return False + # Phase 2: Precise check with exact end time if version.early_access_ends_at: try: @@ -178,6 +193,7 @@ class ModelUpdateRecord: local_base_model: Optional[str], hide_early_access: bool = False, hide_non_downloadable: bool = True, + hide_paid: bool = False, ) -> bool: """Return True when a newer remote version with the same base model exists. @@ -186,6 +202,7 @@ class ModelUpdateRecord: local_base_model: The base model to filter by. hide_early_access: If True, exclude early access versions from update check. hide_non_downloadable: If True, exclude versions that don't allow downloads. + hide_paid: If True, exclude permanent paid versions from update check. """ if self.should_ignore_model: @@ -216,6 +233,8 @@ class ModelUpdateRecord: continue if hide_early_access and ModelUpdateRecord._is_early_access_active(version): continue + if hide_paid and version.is_paid: + continue if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version): continue version_base = _normalize_base_model(version.base_model) @@ -252,6 +271,8 @@ class ModelUpdateService: is_in_library INTEGER NOT NULL DEFAULT 0, should_ignore INTEGER NOT NULL DEFAULT 0, usage_control TEXT, + paid_access TEXT, + is_paid INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (model_id, version_id), FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE ); @@ -491,6 +512,14 @@ class ModelUpdateService: "ALTER TABLE model_update_versions " "ADD COLUMN usage_control TEXT" ), + "paid_access": ( + "ALTER TABLE model_update_versions " + "ADD COLUMN paid_access TEXT" + ), + "is_paid": ( + "ALTER TABLE model_update_versions " + "ADD COLUMN is_paid INTEGER NOT NULL DEFAULT 0" + ), } for column, statement in migrations.items(): @@ -592,6 +621,8 @@ class ModelUpdateService: should_ignore INTEGER NOT NULL DEFAULT 0, early_access_ends_at TEXT, is_early_access INTEGER NOT NULL DEFAULT 0, + paid_access TEXT, + is_paid INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (model_id, version_id), FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE ) @@ -611,6 +642,8 @@ class ModelUpdateService: "should_ignore", "early_access_ends_at", "is_early_access", + "paid_access", + "is_paid", ] defaults = { "sort_index": "0", @@ -623,6 +656,8 @@ class ModelUpdateService: "should_ignore": "0", "early_access_ends_at": "NULL", "is_early_access": "0", + "paid_access": "NULL", + "is_paid": "0", } select_parts = [] @@ -936,17 +971,30 @@ class ModelUpdateService: async with self._lock: return self._get_record(model_type, model_id) - async def has_update(self, model_type: str, model_id: int, hide_early_access: bool = False) -> bool: + async def has_update( + self, + model_type: str, + model_id: int, + hide_early_access: bool = False, + hide_paid: bool = False, + ) -> bool: """Determine if a model has updates pending.""" record = await self.get_record(model_type, model_id) - return record.has_update(hide_early_access=hide_early_access) if record else False + return ( + record.has_update( + hide_early_access=hide_early_access, hide_paid=hide_paid + ) + if record + else False + ) async def has_updates_bulk( self, model_type: str, model_ids: Sequence[int], hide_early_access: bool = False, + hide_paid: bool = False, ) -> Dict[int, bool]: """Return update availability for each model id in a single database pass.""" @@ -959,7 +1007,9 @@ class ModelUpdateService: return { model_id: ( - records[model_id].has_update(hide_early_access=hide_early_access) + records[model_id].has_update( + hide_early_access=hide_early_access, hide_paid=hide_paid + ) if model_id in records else False ) @@ -1190,6 +1240,7 @@ class ModelUpdateService: "earlyAccessEndsAt": _normalize_string( entry.get("earlyAccessEndsAt") ), + "paidAccess": entry.get("paidAccess"), } except RateLimitError: raise @@ -1214,6 +1265,17 @@ class ModelUpdateService: "earlyAccessEndsAt" ): version["earlyAccessEndsAt"] = extra["earlyAccessEndsAt"] + # Only backfill when the model-level response carries no *active* + # paidAccess signal: a present-but-empty DTO (e.g. + # {"permanent": false, "endsAt": null}) would otherwise block + # the authoritative by-hash data. + extra_paid = ModelUpdateService._normalize_paid_access( + extra.get("paidAccess") + ) + if extra_paid and not ModelUpdateService._normalize_paid_access( + version.get("paidAccess") + ): + version["paidAccess"] = extra["paidAccess"] @staticmethod def _collect_hashes_from_response(response: Mapping[str, Any]) -> Dict[int, str]: @@ -1464,6 +1526,8 @@ class ModelUpdateService: early_access_ends_at=remote_version.early_access_ends_at, is_early_access=remote_version.is_early_access, usage_control=remote_version.usage_control, + paid_access=remote_version.paid_access, + is_paid=remote_version.is_paid, ) ) @@ -1564,6 +1628,18 @@ class ModelUpdateService: is_early_access = availability == "EarlyAccess" usage_control = _normalize_string(entry.get("usageControl")) + # CivitAI's paidAccess DTO ({"permanent": bool, "endsAt": ISO|null}) + # gates versions behind a paid tier while availability stays "Public". + paid_access = self._normalize_paid_access(entry.get("paidAccess")) + paid_access_json = json.dumps(paid_access) if paid_access else None + is_paid = bool(paid_access.get("permanent")) if paid_access else False + if early_access_ends_at is None and paid_access and paid_access.get("endsAt"): + early_access_ends_at = _normalize_string(paid_access.get("endsAt")) + # Only timed gates are early access; permanent paid versions are not + # (consumers filter them via is_paid), so the stored flag stays accurate. + if not is_early_access and paid_access and paid_access.get("endsAt"): + is_early_access = True + return ModelVersionRecord( version_id=version_id, name=name, @@ -1577,8 +1653,36 @@ class ModelUpdateService: sort_index=index, is_early_access=is_early_access, usage_control=usage_control, + paid_access=paid_access_json, + is_paid=is_paid, ) + @staticmethod + def _normalize_paid_access(value) -> Optional[Dict[str, Any]]: + """Normalize a CivitAI ``paidAccess`` DTO into a mapping. + + Accepts a dict, None, or a JSON string (as carried by the by-hash + enrichment path) and returns ``{"permanent": bool, "endsAt": str|None}`` + or None when the input carries no paid-access signal. + """ + if value is None: + return None + if isinstance(value, str): + try: + parsed = json.loads(value) + except (TypeError, ValueError): + return None + if not isinstance(parsed, dict): + return None + value = parsed + if not isinstance(value, Mapping): + return None + permanent = bool(value.get("permanent")) + ends_at = _normalize_string(value.get("endsAt")) + if not permanent and ends_at is None: + return None + return {"permanent": permanent, "endsAt": ends_at} + def _extract_size_bytes(self, files) -> Optional[int]: if not isinstance(files, Iterable): return None @@ -1691,7 +1795,7 @@ class ModelUpdateService: f""" 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, usage_control + is_early_access, usage_control, paid_access, is_paid FROM model_update_versions WHERE model_id IN ({placeholders}) ORDER BY model_id ASC, sort_index ASC, version_id ASC @@ -1720,6 +1824,8 @@ class ModelUpdateService: sort_index=_normalize_int(row["sort_index"]) or 0, is_early_access=bool(row["is_early_access"]), usage_control=row["usage_control"], + paid_access=row["paid_access"], + is_paid=bool(row["is_paid"]), ) ) @@ -1771,13 +1877,19 @@ class ModelUpdateService: (record.model_id,), ) for version in record.versions: + paid_access_value = ( + version.paid_access + if version.paid_access is None + or isinstance(version.paid_access, str) + else json.dumps(version.paid_access) + ) conn.execute( """ INSERT INTO model_update_versions ( version_id, model_id, sort_index, name, base_model, released_at, size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at, - is_early_access, usage_control - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + is_early_access, usage_control, paid_access, is_paid + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( version.version_id, @@ -1793,6 +1905,8 @@ class ModelUpdateService: version.early_access_ends_at, 1 if version.is_early_access else 0, version.usage_control, + paid_access_value, + 1 if version.is_paid else 0, ), ) conn.commit() diff --git a/static/css/components/lora-modal/versions.css b/static/css/components/lora-modal/versions.css index 31cc0e1e..362bc689 100644 --- a/static/css/components/lora-modal/versions.css +++ b/static/css/components/lora-modal/versions.css @@ -447,6 +447,19 @@ border-color: color-mix(in oklch, #F59F00 45%, transparent); } +/* Paid badge - violet tone (#845EF7) to distinguish from early-access amber */ +.version-badge-paid { + background: color-mix(in oklch, #845EF7 25%, transparent); + color: #7048E8; + border-color: color-mix(in oklch, #845EF7 55%, transparent); +} + +[data-theme="dark"] .version-badge-paid { + background: color-mix(in oklch, #845EF7 20%, transparent); + color: #9775FA; + border-color: color-mix(in oklch, #845EF7 45%, transparent); +} + .version-meta-ea { color: #E67700; font-weight: 600; diff --git a/static/js/components/shared/ModelVersionsTab.js b/static/js/components/shared/ModelVersionsTab.js index 3bb30890..a874812e 100644 --- a/static/js/components/shared/ModelVersionsTab.js +++ b/static/js/components/shared/ModelVersionsTab.js @@ -182,6 +182,10 @@ function isEarlyAccessActive(version) { } } +function isPaidPermanent(version) { + return version && version.isPaid === true; +} + function isDownloadAllowed(version) { if (!version.usageControl) { return true; @@ -342,6 +346,7 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) { const strategy = state?.global?.settings?.version_grouping; const sameBaseMode = strategy === DISPLAY_FILTER_MODES.SAME_BASE; const hideEarlyAccess = state?.global?.settings?.hide_early_access_updates; + const hidePaid = state?.global?.settings?.hide_paid_updates; if (!sameBaseMode) { return Boolean(record?.hasUpdate); @@ -388,6 +393,9 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) { if (hideEarlyAccess && isEarlyAccessActive(version)) { return false; } + if (hidePaid && isPaidPermanent(version)) { + return false; + } if (!isDownloadAllowed(version)) { return false; } @@ -469,6 +477,7 @@ function renderRow(version, options) { const downloadedBadgeLabel = translate('modals.model.versions.badges.downloaded', {}, 'Downloaded'); const newerBadgeLabel = translate('modals.model.versions.badges.newer', {}, 'Newer Version'); const earlyAccessBadgeLabel = translate('modals.model.versions.badges.earlyAccess', {}, 'Early Access'); + const paidBadgeLabel = translate('modals.model.versions.badges.paid', {}, 'Paid'); const ignoredBadgeLabel = translate('modals.model.versions.badges.ignored', {}, 'Ignored'); const versionName = version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version'); @@ -522,6 +531,16 @@ function renderRow(version, options) { })); } + if (isPaidPermanent(version)) { + badges.push(buildBadge(paidBadgeLabel, 'paid', { + title: translate( + 'modals.model.versions.badges.paidTooltip', + {}, + 'This version requires payment to download' + ), + })); + } + if (!isDownloadAllowed(version)) { const onSiteOnlyBadgeLabel = translate('modals.model.versions.badges.onSiteOnly', {}, 'On-Site Only'); badges.push(buildBadge(onSiteOnlyBadgeLabel, 'info', { @@ -564,6 +583,12 @@ function renderRow(version, options) { {}, 'This version is only available for on-site generation on Civitai' ); + } else if (isPaidPermanent(version)) { + downloadTitle = translate( + 'modals.model.versions.actions.downloadPaidTooltip', + {}, + 'Download this paid version from Civitai' + ); } else if (isEarlyAccess) { downloadTitle = translate( 'modals.model.versions.actions.downloadEarlyAccessTooltip', diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index 70b11958..27323b5b 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -1064,6 +1064,12 @@ export class SettingsManager { hideEarlyAccessUpdatesCheckbox.checked = state.global.settings.hide_early_access_updates || false; } + // Set hide paid updates setting + const hidePaidUpdatesCheckbox = document.getElementById('hidePaidUpdates'); + if (hidePaidUpdatesCheckbox) { + hidePaidUpdatesCheckbox.checked = state.global.settings.hide_paid_updates || false; + } + const skipPreviouslyDownloadedModelVersionsCheckbox = document.getElementById('skipPreviouslyDownloadedModelVersions'); if (skipPreviouslyDownloadedModelVersionsCheckbox) { skipPreviouslyDownloadedModelVersionsCheckbox.checked = diff --git a/static/js/state/index.js b/static/js/state/index.js index 4749f497..4d0659ad 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -49,6 +49,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({ priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG }, version_grouping: 'same_base', hide_early_access_updates: false, + hide_paid_updates: false, auto_organize_exclusions: [], metadata_refresh_skip_paths: [], skip_previously_downloaded_model_versions: false, diff --git a/templates/components/modals/settings_modal.html b/templates/components/modals/settings_modal.html index df46df75..2bcb3987 100644 --- a/templates/components/modals/settings_modal.html +++ b/templates/components/modals/settings_modal.html @@ -1263,6 +1263,24 @@ + +
+
+
+ +
+
+ +
+
+
diff --git a/tests/routes/test_model_update_handler.py b/tests/routes/test_model_update_handler.py index 8a5fd4ef..cd5e40ed 100644 --- a/tests/routes/test_model_update_handler.py +++ b/tests/routes/test_model_update_handler.py @@ -593,3 +593,103 @@ async def test_fetch_missing_license_data_filters_model_ids(monkeypatch): assert len(payload["updated"]) == 1 assert provider_calls == [[20]] assert len(saved) == 1 + + +def test_serialize_version_permanent_paid_is_not_early_access(): + """Permanent paid versions (is_paid, no end date) must not be flagged as + early access, mirroring _is_early_access_active in the update service.""" + version = ModelVersionRecord( + version_id=7, name="v7", base_model=None, released_at=None, size_bytes=None, + preview_url=None, is_in_library=False, should_ignore=False, + early_access_ends_at=None, is_early_access=True, usage_control="Download", + paid_access=json.dumps({"permanent": True, "endsAt": None}), is_paid=True, + ) + serialized = ModelUpdateHandler._serialize_version(version, None) + assert serialized["isEarlyAccess"] is False + assert serialized["isPaid"] is True + assert serialized["paidAccess"] == {"permanent": True, "endsAt": None} + + +def test_serialize_version_timed_paid_is_early_access(): + """Timed paid gates (endsAt in the future) stay flagged as early access.""" + version = ModelVersionRecord( + version_id=8, name="v8", base_model=None, released_at=None, size_bytes=None, + preview_url=None, is_in_library=False, should_ignore=False, + early_access_ends_at="2099-01-01T00:00:00.000Z", is_early_access=True, + usage_control="Download", + paid_access=json.dumps({"permanent": False, "endsAt": "2099-01-01T00:00:00.000Z"}), + is_paid=False, + ) + serialized = ModelUpdateHandler._serialize_version(version, None) + assert serialized["isEarlyAccess"] is True + assert serialized["isPaid"] is False + + +def test_serialize_version_malformed_paid_access_does_not_crash(): + """A malformed paid_access row must degrade to None instead of failing + the whole versions-list response.""" + version = ModelVersionRecord( + version_id=10, name="v10", base_model=None, released_at=None, size_bytes=None, + preview_url=None, is_in_library=False, should_ignore=False, + early_access_ends_at=None, is_early_access=True, usage_control=None, + paid_access="{not json", is_paid=False, + ) + serialized = ModelUpdateHandler._serialize_version(version, None) + assert serialized["paidAccess"] is None + assert serialized["isEarlyAccess"] is True + + +async def test_enrich_early_access_details_skips_permanent_paid(monkeypatch): + """Permanent paid versions must not trigger per-version CivitAI fetches in + _enrich_early_access_details: they are not early access and can never get + an end time, so enriching them is wasted API traffic.""" + record = ModelUpdateRecord( + model_type="lora", + model_id=1, + versions=[ + ModelVersionRecord( + version_id=100, name="paid", base_model=None, released_at=None, + size_bytes=None, preview_url=None, is_in_library=False, + should_ignore=False, early_access_ends_at=None, + is_early_access=True, usage_control="Download", + paid_access='{"permanent": true, "endsAt": null}', is_paid=True, + ), + ModelVersionRecord( + version_id=200, name="ea", base_model=None, released_at=None, + size_bytes=None, preview_url=None, is_in_library=False, + should_ignore=False, early_access_ends_at=None, + is_early_access=True, usage_control="Download", + paid_access=None, is_paid=False, + ), + ], + last_checked_at=1.0, + should_ignore_model=False, + ) + + fetched: list[int] = [] + + async def fake_version_info(version_id: str): + fetched.append(int(version_id)) + return {"earlyAccessEndsAt": "2099-01-01T00:00:00.000Z"}, None + + provider = SimpleNamespace(get_model_version_info=fake_version_info) + + async def metadata_selector(name): + assert name == "civitai_api" + return provider + + handler = ModelUpdateHandler( + service=DummyService(SimpleNamespace(raw_data=[], version_index={})), + update_service=SimpleNamespace(), + metadata_provider_selector=metadata_selector, + settings_service=SimpleNamespace(get=lambda *_: False), + logger=logging.getLogger(__name__), + ) + + enriched = await handler._enrich_early_access_details(record) + + # Only the timed EA version (200) is fetched; the permanent paid one (100) is skipped. + assert fetched == [200] + enriched_map = {v.version_id: v for v in enriched.versions} + assert enriched_map[200].early_access_ends_at == "2099-01-01T00:00:00.000Z" + assert enriched_map[100].early_access_ends_at is None diff --git a/tests/services/test_base_model_service.py b/tests/services/test_base_model_service.py index fc54b835..cb6dc1c2 100644 --- a/tests/services/test_base_model_service.py +++ b/tests/services/test_base_model_service.py @@ -82,7 +82,9 @@ class StubUpdateService: self.bulk_calls = [] self.bulk_error = bulk_error - async def has_updates_bulk(self, model_type, model_ids, hide_early_access: bool = False): + async def has_updates_bulk( + self, model_type, model_ids, hide_early_access: bool = False, hide_paid: bool = False + ): self.bulk_calls.append((model_type, list(model_ids))) if self.bulk_error: raise RuntimeError("bulk failure") @@ -94,7 +96,9 @@ class StubUpdateService: results[model_id] = result return results - async def has_update(self, model_type, model_id, hide_early_access: bool = False): + async def has_update( + self, model_type, model_id, hide_early_access: bool = False, hide_paid: bool = False + ): self.calls.append((model_type, model_id)) result = self.decisions.get(model_id, False) if isinstance(result, Exception): diff --git a/tests/services/test_model_update_service.py b/tests/services/test_model_update_service.py index dcb6cde1..f2008801 100644 --- a/tests/services/test_model_update_service.py +++ b/tests/services/test_model_update_service.py @@ -59,7 +59,17 @@ class NotFoundProvider: return {} -def make_version(version_id, *, in_library, base_model=None, should_ignore=False): +def make_version( + version_id, + *, + in_library, + base_model=None, + should_ignore=False, + early_access_ends_at=None, + is_early_access=False, + is_paid=False, + paid_access=None, +): return ModelVersionRecord( version_id=version_id, name=None, @@ -69,6 +79,10 @@ def make_version(version_id, *, in_library, base_model=None, should_ignore=False preview_url=None, is_in_library=in_library, should_ignore=should_ignore, + early_access_ends_at=early_access_ends_at, + is_early_access=is_early_access, + is_paid=is_paid, + paid_access=paid_access, ) @@ -622,3 +636,165 @@ async def test_refresh_folder_filter_considers_cross_folder_versions(tmp_path): # has_update must be True (version 20 > max_in_library=15) assert record.has_update() is True + +def test_extract_single_version_paid_access_timed(tmp_path): + """A timed paidAccess gate (permanent=False + future endsAt) is detected + as early access while availability stays 'Public'.""" + db_path = tmp_path / "updates.sqlite" + service = ModelUpdateService(str(db_path)) + + entry = { + "id": 42, + "name": "v1 paid", + "availability": "Public", + "paidAccess": { + "permanent": False, + "endsAt": "2026-08-22T18:30:00.000Z", + }, + "files": [], + "images": [], + } + + version = service._extract_single_version(entry, index=0) + + assert version is not None + assert version.is_early_access is True + assert version.early_access_ends_at == "2026-08-22T18:30:00.000Z" + assert version.is_paid is False + assert version.paid_access is not None + + +def test_extract_single_version_paid_access_permanent(tmp_path): + """A permanent paidAccess gate (permanent=True, no endsAt) is detected and + flagged as paid but is NOT early access and carries no end date.""" + db_path = tmp_path / "updates.sqlite" + service = ModelUpdateService(str(db_path)) + + entry = { + "id": 42, + "name": "v1 paid", + "availability": "Public", + "paidAccess": {"permanent": True, "endsAt": None}, + "files": [], + "images": [], + } + + version = service._extract_single_version(entry, index=0) + + assert version is not None + assert version.is_early_access is False + assert version.is_paid is True + assert version.early_access_ends_at is None + assert version.paid_access is not None + + +def test_normalize_paid_access_accepts_json_string(): + """The by-hash enrichment path may hand paidAccess to _normalize_paid_access + as a JSON string; both the permanent and timed shapes must normalize.""" + service = ModelUpdateService.__new__(ModelUpdateService) + + permanent = ModelUpdateService._normalize_paid_access( + '{"permanent": true, "endsAt": null}' + ) + assert permanent == {"permanent": True, "endsAt": None} + + timed = ModelUpdateService._normalize_paid_access( + '{"permanent": false, "endsAt": "2026-08-22T18:30:00.000Z"}' + ) + assert timed == {"permanent": False, "endsAt": "2026-08-22T18:30:00.000Z"} + + empty = ModelUpdateService._normalize_paid_access( + '{"permanent": false, "endsAt": null}' + ) + assert empty is None + + malformed = ModelUpdateService._normalize_paid_access("{not json") + assert malformed is None + + +def test_has_update_for_base_hide_paid(): + """hide_paid also suppresses permanent paid versions in the same-base + update path (has_update_for_base).""" + record = make_record( + make_version(5, in_library=True, base_model="illustrious"), + make_version( + 7, + in_library=False, + base_model="illustrious", + is_paid=True, + paid_access='{"permanent": true, "endsAt": null}', + ), + ) + + assert record.has_update_for_base(5, "illustrious") is True + assert record.has_update_for_base(5, "illustrious", hide_paid=True) is False + + +def test_has_update_hide_paid(): + """hide_paid suppresses update flags raised by a permanent paid version.""" + record = make_record( + make_version(5, in_library=True), + make_version( + 7, + in_library=False, + is_paid=True, + paid_access='{"permanent": true, "endsAt": null}', + ), + ) + + assert record.has_update() is True + assert record.has_update(hide_paid=True) is False + + +def test_has_update_hide_early_access_paid_timed(): + """hide_early_access suppresses a newer timed paidAccess version.""" + record = make_record( + make_version(5, in_library=True), + make_version( + 7, + in_library=False, + is_early_access=True, + early_access_ends_at="2099-01-01T00:00:00Z", + ), + ) + + assert record.has_update() is True + assert record.has_update(hide_early_access=True) is False + + + +def test_build_record_from_remote_preserves_paid_fields(tmp_path): + """_build_record_from_remote must carry paid_access/is_paid from the + parsed remote versions into the rebuilt record, or the refresh path + silently drops paid data before persistence.""" + db_path = tmp_path / "updates.sqlite" + service = ModelUpdateService(str(db_path)) + + remote_version = ModelVersionRecord( + version_id=7, + name="v7", + base_model=None, + released_at=None, + size_bytes=None, + preview_url=None, + is_in_library=False, + should_ignore=False, + early_access_ends_at=None, + is_early_access=True, + usage_control="Download", + paid_access='{"permanent": true, "endsAt": null}', + is_paid=True, + ) + + record = service._build_record_from_remote( + model_type="lora", + model_id=123, + local_versions=[], + remote_versions=[remote_version], + existing=None, + timestamp=1.0, + ) + + rebuilt = record.versions[0] + assert rebuilt.paid_access == '{"permanent": true, "endsAt": null}' + assert rebuilt.is_paid is True