mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-15 18:23:21 -03:00
feat(update): detect CivitAI paidAccess versions and add hide paid updates (#1060)
CivitAI's PaidAccess cutover deprecated the availability=EarlyAccess and earlyAccessEndsAt signals; gated versions now report availability=Public with a paidAccess DTO that LoRA Manager previously ignored, so "Hide Early Access Updates" missed paid/early-access models and downloads failed with 401. Parse and persist paidAccess from model-level, bulk, and by-hash responses; treat timed paid gates as early access and permanent paid versions as a distinct is_paid state; add a hide_paid_updates setting with a "Paid" badge in the versions tab; warn before downloading gated versions. Includes SQLite migration, i18n for all locales, and backend/frontend tests.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "מחק את הגרסה המקומית הזו",
|
||||
|
||||
@@ -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": "このローカルバージョンを削除",
|
||||
|
||||
@@ -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": "이 로컬 버전 삭제",
|
||||
|
||||
@@ -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": "Удалить эту локальную версию",
|
||||
|
||||
@@ -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": "删除此本地版本",
|
||||
|
||||
@@ -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": "刪除此本地版本",
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')}"
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1263,6 +1263,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="hidePaidUpdates">
|
||||
{{ t('settings.hidePaidUpdates.label') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.hidePaidUpdates.help') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="hidePaidUpdates"
|
||||
onchange="settingsManager.saveToggleSetting('hidePaidUpdates', 'hide_paid_updates')">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Example Images -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user