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:
Will Miao
2026-08-15 18:08:14 +08:00
parent c85b6b64a1
commit ef3e7d7bf4
22 changed files with 630 additions and 31 deletions
+7
View File
@@ -622,6 +622,10 @@
"label": "Früher Zugriff Updates ausblenden", "label": "Früher Zugriff Updates ausblenden",
"help": "Nur Early-Access-Updates" "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": { "licenseIcons": {
"useNewStyle": "Aktualisierte Lizenzsymbole verwenden", "useNewStyle": "Aktualisierte Lizenzsymbole verwenden",
"useNewStyleHelp": "Lizenzberechtigungen mit farbigen Indikatoren (neuer Stil) oder nur Einschränkungssymbolen (klassischer Stil) anzeigen. Orientiert sich am aktuellen CivitAI-Design." "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", "newerTooltip": "Diese Version ist neuer als Ihre neueste lokale Version",
"earlyAccess": "Früher Zugriff", "earlyAccess": "Früher Zugriff",
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich", "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", "ignored": "Ignoriert",
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert", "ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert",
"onSiteOnly": "Nur On-Site", "onSiteOnly": "Nur On-Site",
@@ -1559,6 +1565,7 @@
"download": "Herunterladen", "download": "Herunterladen",
"downloadTooltip": "Diese Version herunterladen", "downloadTooltip": "Diese Version herunterladen",
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen", "downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar", "downloadNotAllowedTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar",
"delete": "Löschen", "delete": "Löschen",
"deleteTooltip": "Diese lokale Version löschen", "deleteTooltip": "Diese lokale Version löschen",
+7
View File
@@ -622,6 +622,10 @@
"label": "Hide Early Access Updates", "label": "Hide Early Access Updates",
"help": "When enabled, models with only early access updates will not show 'Update available' badge" "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": { "licenseIcons": {
"useNewStyle": "Use updated license icons", "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." "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", "newerTooltip": "This version is newer than your latest local version",
"earlyAccess": "Early Access", "earlyAccess": "Early Access",
"earlyAccessTooltip": "This version currently requires Civitai early access", "earlyAccessTooltip": "This version currently requires Civitai early access",
"paid": "Paid",
"paidTooltip": "This version requires payment to download",
"ignored": "Ignored", "ignored": "Ignored",
"ignoredTooltip": "Update notifications are disabled for this version", "ignoredTooltip": "Update notifications are disabled for this version",
"onSiteOnly": "On-Site Only", "onSiteOnly": "On-Site Only",
@@ -1559,6 +1565,7 @@
"download": "Download", "download": "Download",
"downloadTooltip": "Download this version", "downloadTooltip": "Download this version",
"downloadEarlyAccessTooltip": "Download this early access version from Civitai", "downloadEarlyAccessTooltip": "Download this early access version from Civitai",
"downloadPaidTooltip": "Download this paid version from Civitai",
"downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai", "downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai",
"delete": "Delete", "delete": "Delete",
"deleteTooltip": "Delete this local version", "deleteTooltip": "Delete this local version",
+7
View File
@@ -622,6 +622,10 @@
"label": "Ocultar actualizaciones de acceso temprano", "label": "Ocultar actualizaciones de acceso temprano",
"help": "Solo 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": { "licenseIcons": {
"useNewStyle": "Usar iconos de licencia actualizados", "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." "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", "newerTooltip": "Esta versión es más reciente que tu última versión local",
"earlyAccess": "Acceso temprano", "earlyAccess": "Acceso temprano",
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai", "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", "ignored": "Ignorada",
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión", "ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión",
"onSiteOnly": "Solo en Sitio", "onSiteOnly": "Solo en Sitio",
@@ -1559,6 +1565,7 @@
"download": "Descargar", "download": "Descargar",
"downloadTooltip": "Descargar esta versión", "downloadTooltip": "Descargar esta versión",
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai", "downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai", "downloadNotAllowedTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai",
"delete": "Eliminar", "delete": "Eliminar",
"deleteTooltip": "Eliminar esta versión local", "deleteTooltip": "Eliminar esta versión local",
+7
View File
@@ -622,6 +622,10 @@
"label": "Masquer les mises à jour en accès anticipé", "label": "Masquer les mises à jour en accès anticipé",
"help": "Seulement 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": { "licenseIcons": {
"useNewStyle": "Utiliser les icônes de licence mises à jour", "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." "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", "newerTooltip": "Cette version est plus récente que votre dernière version locale",
"earlyAccess": "Accès anticipé", "earlyAccess": "Accès anticipé",
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai", "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", "ignored": "Ignorée",
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version", "ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version",
"onSiteOnly": "Uniquement sur Site", "onSiteOnly": "Uniquement sur Site",
@@ -1559,6 +1565,7 @@
"download": "Télécharger", "download": "Télécharger",
"downloadTooltip": "Télécharger cette version", "downloadTooltip": "Télécharger cette version",
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai", "downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai", "downloadNotAllowedTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai",
"delete": "Supprimer", "delete": "Supprimer",
"deleteTooltip": "Supprimer cette version locale", "deleteTooltip": "Supprimer cette version locale",
+7
View File
@@ -622,6 +622,10 @@
"label": "הסתר עדכוני גישה מוקדמת", "label": "הסתר עדכוני גישה מוקדמת",
"help": "רק עדכוני גישה מוקדמת" "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": { "licenseIcons": {
"useNewStyle": "השתמש בסמלי רישיון מעודכנים", "useNewStyle": "השתמש בסמלי רישיון מעודכנים",
"useNewStyleHelp": "הצג הרשאות רישיון עם מחוונים צבעוניים (סגנון חדש) או סמלי הגבלה בלבד (סגנון קלאסי). משקף את העיצוב העדכני של CivitAI." "useNewStyleHelp": "הצג הרשאות רישיון עם מחוונים צבעוניים (סגנון חדש) או סמלי הגבלה בלבד (סגנון קלאסי). משקף את העיצוב העדכני של CivitAI."
@@ -1550,6 +1554,8 @@
"newerTooltip": "גרסה זו חדשה יותר מהגרסה המקומית האחרונה שלך", "newerTooltip": "גרסה זו חדשה יותר מהגרסה המקומית האחרונה שלך",
"earlyAccess": "גישה מוקדמת", "earlyAccess": "גישה מוקדמת",
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai", "earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "התעלם", "ignored": "התעלם",
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו", "ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו",
"onSiteOnly": "רק באתר", "onSiteOnly": "רק באתר",
@@ -1559,6 +1565,7 @@
"download": "הורדה", "download": "הורדה",
"downloadTooltip": "הורד את הגרסה הזו", "downloadTooltip": "הורד את הגרסה הזו",
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai", "downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai", "downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai",
"delete": "מחיקה", "delete": "מחיקה",
"deleteTooltip": "מחק את הגרסה המקומית הזו", "deleteTooltip": "מחק את הגרסה המקומית הזו",
+7
View File
@@ -622,6 +622,10 @@
"label": "早期アクセス更新を非表示", "label": "早期アクセス更新を非表示",
"help": "早期アクセスのみの更新" "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": { "licenseIcons": {
"useNewStyle": "更新されたライセンスアイコンを使用", "useNewStyle": "更新されたライセンスアイコンを使用",
"useNewStyleHelp": "カラーインジケーター付きでライセンス許可を表示(新スタイル)するか、制限のみのアイコンを表示(クラシックスタイル)します。現在のCivitAIデザインを反映しています。" "useNewStyleHelp": "カラーインジケーター付きでライセンス許可を表示(新スタイル)するか、制限のみのアイコンを表示(クラシックスタイル)します。現在のCivitAIデザインを反映しています。"
@@ -1550,6 +1554,8 @@
"newerTooltip": "このバージョンはローカルの最新バージョンより新しいです", "newerTooltip": "このバージョンはローカルの最新バージョンより新しいです",
"earlyAccess": "早期アクセス", "earlyAccess": "早期アクセス",
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です", "earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "無視中", "ignored": "無視中",
"ignoredTooltip": "このバージョンの更新通知は無効です", "ignoredTooltip": "このバージョンの更新通知は無効です",
"onSiteOnly": "サイト内のみ", "onSiteOnly": "サイト内のみ",
@@ -1559,6 +1565,7 @@
"download": "ダウンロード", "download": "ダウンロード",
"downloadTooltip": "このバージョンをダウンロード", "downloadTooltip": "このバージョンをダウンロード",
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード", "downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません", "downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません",
"delete": "削除", "delete": "削除",
"deleteTooltip": "このローカルバージョンを削除", "deleteTooltip": "このローカルバージョンを削除",
+7
View File
@@ -622,6 +622,10 @@
"label": "얼리 액세스 업데이트 숨기기", "label": "얼리 액세스 업데이트 숨기기",
"help": "얼리 액세스 업데이트만" "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": { "licenseIcons": {
"useNewStyle": "업데이트된 라이선스 아이콘 사용", "useNewStyle": "업데이트된 라이선스 아이콘 사용",
"useNewStyleHelp": "색상 표시기가 있는 라이선스 권한(새 스타일) 또는 제한 전용 아이콘(클래식 스타일)을 표시합니다. 현재 CivitAI 디자인을 반영합니다." "useNewStyleHelp": "색상 표시기가 있는 라이선스 권한(새 스타일) 또는 제한 전용 아이콘(클래식 스타일)을 표시합니다. 현재 CivitAI 디자인을 반영합니다."
@@ -1550,6 +1554,8 @@
"newerTooltip": "이 버전은 로컬의 최신 버전보다 더 새롭습니다", "newerTooltip": "이 버전은 로컬의 최신 버전보다 더 새롭습니다",
"earlyAccess": "얼리 액세스", "earlyAccess": "얼리 액세스",
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다", "earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "무시됨", "ignored": "무시됨",
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다", "ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
"onSiteOnly": "사이트 내 전용", "onSiteOnly": "사이트 내 전용",
@@ -1559,6 +1565,7 @@
"download": "다운로드", "download": "다운로드",
"downloadTooltip": "이 버전 다운로드", "downloadTooltip": "이 버전 다운로드",
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드", "downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다", "downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
"delete": "삭제", "delete": "삭제",
"deleteTooltip": "이 로컬 버전 삭제", "deleteTooltip": "이 로컬 버전 삭제",
+7
View File
@@ -622,6 +622,10 @@
"label": "Скрыть обновления раннего доступа", "label": "Скрыть обновления раннего доступа",
"help": "Только обновления раннего доступа" "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": { "licenseIcons": {
"useNewStyle": "Использовать обновлённые значки лицензии", "useNewStyle": "Использовать обновлённые значки лицензии",
"useNewStyleHelp": "Отображать разрешения лицензии с цветными индикаторами (новый стиль) или только значки ограничений (классический стиль). Соответствует текущему дизайну CivitAI." "useNewStyleHelp": "Отображать разрешения лицензии с цветными индикаторами (новый стиль) или только значки ограничений (классический стиль). Соответствует текущему дизайну CivitAI."
@@ -1550,6 +1554,8 @@
"newerTooltip": "Эта версия новее вашей последней локальной версии", "newerTooltip": "Эта версия новее вашей последней локальной версии",
"earlyAccess": "Ранний доступ", "earlyAccess": "Ранний доступ",
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai", "earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "Игнорируется", "ignored": "Игнорируется",
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены", "ignoredTooltip": "Уведомления об обновлениях для этой версии отключены",
"onSiteOnly": "Только на Сайте", "onSiteOnly": "Только на Сайте",
@@ -1559,6 +1565,7 @@
"download": "Скачать", "download": "Скачать",
"downloadTooltip": "Скачать эту версию", "downloadTooltip": "Скачать эту версию",
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai", "downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai", "downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai",
"delete": "Удалить", "delete": "Удалить",
"deleteTooltip": "Удалить эту локальную версию", "deleteTooltip": "Удалить эту локальную версию",
+7
View File
@@ -622,6 +622,10 @@
"label": "隐藏抢先体验更新", "label": "隐藏抢先体验更新",
"help": "抢先体验更新" "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": { "licenseIcons": {
"useNewStyle": "使用新版许可协议图标", "useNewStyle": "使用新版许可协议图标",
"useNewStyleHelp": "以彩色指示器显示许可权限(新样式),或仅显示限制图标(经典样式)。与当前 CivitAI 设计保持一致。" "useNewStyleHelp": "以彩色指示器显示许可权限(新样式),或仅显示限制图标(经典样式)。与当前 CivitAI 设计保持一致。"
@@ -1550,6 +1554,8 @@
"newerTooltip": "此版本比你本地的最新版本更新", "newerTooltip": "此版本比你本地的最新版本更新",
"earlyAccess": "抢先体验", "earlyAccess": "抢先体验",
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限", "earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "已忽略", "ignored": "已忽略",
"ignoredTooltip": "此版本已关闭更新通知", "ignoredTooltip": "此版本已关闭更新通知",
"onSiteOnly": "仅站内生成", "onSiteOnly": "仅站内生成",
@@ -1559,6 +1565,7 @@
"download": "下载", "download": "下载",
"downloadTooltip": "下载此版本", "downloadTooltip": "下载此版本",
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本", "downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载", "downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载",
"delete": "删除", "delete": "删除",
"deleteTooltip": "删除此本地版本", "deleteTooltip": "删除此本地版本",
+7
View File
@@ -622,6 +622,10 @@
"label": "隱藏搶先體驗更新", "label": "隱藏搶先體驗更新",
"help": "搶先體驗更新" "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": { "licenseIcons": {
"useNewStyle": "使用新版許可協議圖標", "useNewStyle": "使用新版許可協議圖標",
"useNewStyleHelp": "以彩色指示器顯示許可權限(新樣式),或僅顯示限制圖標(經典樣式)。與當前 CivitAI 設計保持一致。" "useNewStyleHelp": "以彩色指示器顯示許可權限(新樣式),或僅顯示限制圖標(經典樣式)。與當前 CivitAI 設計保持一致。"
@@ -1550,6 +1554,8 @@
"newerTooltip": "此版本比你本地的最新版本更新", "newerTooltip": "此版本比你本地的最新版本更新",
"earlyAccess": "搶先體驗", "earlyAccess": "搶先體驗",
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限", "earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "已忽略", "ignored": "已忽略",
"ignoredTooltip": "此版本已關閉更新通知", "ignoredTooltip": "此版本已關閉更新通知",
"onSiteOnly": "僅站內生成", "onSiteOnly": "僅站內生成",
@@ -1559,6 +1565,7 @@
"download": "下載", "download": "下載",
"downloadTooltip": "下載此版本", "downloadTooltip": "下載此版本",
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本", "downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載", "downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載",
"delete": "刪除", "delete": "刪除",
"deleteTooltip": "刪除此本地版本", "deleteTooltip": "刪除此本地版本",
+37 -5
View File
@@ -2535,6 +2535,7 @@ class ModelUpdateHandler:
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
hide_early_access = False hide_early_access = False
hide_paid = False
if self._settings is not None: if self._settings is not None:
try: try:
hide_early_access = bool( hide_early_access = bool(
@@ -2542,12 +2543,17 @@ class ModelUpdateHandler:
) )
except Exception: except Exception:
pass pass
try:
hide_paid = bool(self._settings.get("hide_paid_updates", False))
except Exception:
pass
serialized_records = [] serialized_records = []
for record in records.values(): for record in records.values():
has_update_fn = getattr(record, "has_update", None) has_update_fn = getattr(record, "has_update", None)
if callable(has_update_fn) and has_update_fn( 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)) serialized_records.append(self._serialize_record(record))
@@ -2701,10 +2707,16 @@ class ModelUpdateHandler:
if not record or not record.versions: if not record or not record.versions:
return record 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 = [] versions_needing_update = []
for version in record.versions: 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) versions_needing_update.append(version)
if not versions_needing_update: if not versions_needing_update:
@@ -2934,6 +2946,7 @@ class ModelUpdateHandler:
context = version_context or {} context = version_context or {}
# Check user setting for hiding early access versions # Check user setting for hiding early access versions
hide_early_access = False hide_early_access = False
hide_paid = False
if self._settings is not None: if self._settings is not None:
try: try:
hide_early_access = bool( hide_early_access = bool(
@@ -2941,6 +2954,10 @@ class ModelUpdateHandler:
) )
except Exception: except Exception:
pass pass
try:
hide_paid = bool(self._settings.get("hide_paid_updates", False))
except Exception:
pass
return { return {
"modelType": record.model_type, "modelType": record.model_type,
"modelId": record.model_id, "modelId": record.model_id,
@@ -2949,7 +2966,10 @@ class ModelUpdateHandler:
"inLibraryVersionIds": record.in_library_version_ids, "inLibraryVersionIds": record.in_library_version_ids,
"lastCheckedAt": record.last_checked_at, "lastCheckedAt": record.last_checked_at,
"shouldIgnore": record.should_ignore_model, "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": [ "versions": [
self._serialize_version(version, context.get(version.version_id)) self._serialize_version(version, context.get(version.version_id))
for version in record.versions for version in record.versions
@@ -2968,8 +2988,11 @@ class ModelUpdateHandler:
# Determine if version is currently in early access # Determine if version is currently in early access
# Two-phase detection: use exact end time if available, otherwise fallback to basic flag # 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 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: try:
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -2984,6 +3007,13 @@ class ModelUpdateHandler:
# Fallback to basic EA flag from bulk API # Fallback to basic EA flag from bulk API
is_early_access = True 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 { return {
"versionId": version.version_id, "versionId": version.version_id,
"name": version.name, "name": version.name,
@@ -2997,6 +3027,8 @@ class ModelUpdateHandler:
"earlyAccessEndsAt": version.early_access_ends_at, "earlyAccessEndsAt": version.early_access_ends_at,
"isEarlyAccess": is_early_access, "isEarlyAccess": is_early_access,
"usageControl": version.usage_control, "usageControl": version.usage_control,
"isPaid": bool(getattr(version, "is_paid", False)),
"paidAccess": paid_access_payload,
"filePath": context.get("file_path"), "filePath": context.get("file_path"),
"fileName": context.get("file_name"), "fileName": context.get("file_name"),
} }
+17 -2
View File
@@ -633,6 +633,13 @@ class BaseModelService(ABC):
except Exception: except Exception:
hide_early_access = False 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 records = None
resolved: Optional[Dict[int, bool]] = None resolved: Optional[Dict[int, bool]] = None
if same_base_mode: if same_base_mode:
@@ -641,7 +648,10 @@ class BaseModelService(ABC):
try: try:
records = await cast(Awaitable[Any], record_method(self.model_type, ordered_ids)) records = await cast(Awaitable[Any], record_method(self.model_type, ordered_ids))
resolved = { 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() for model_id, record in records.items()
} }
except Exception as exc: except Exception as exc:
@@ -663,6 +673,7 @@ class BaseModelService(ABC):
self.model_type, self.model_type,
ordered_ids, ordered_ids,
hide_early_access=hide_early_access, hide_early_access=hide_early_access,
hide_paid=hide_paid,
)) ))
except Exception as exc: except Exception as exc:
logger.error( logger.error(
@@ -677,7 +688,10 @@ class BaseModelService(ABC):
if resolved is None: if resolved is None:
tasks = [ tasks = [
self.update_service.has_update( 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 for model_id in ordered_ids
] ]
@@ -717,6 +731,7 @@ class BaseModelService(ABC):
threshold_version, threshold_version,
base_model, base_model,
hide_early_access=hide_early_access, hide_early_access=hide_early_access,
hide_paid=hide_paid,
) )
else: else:
flag = default_flag flag = default_flag
+29 -4
View File
@@ -3,6 +3,7 @@
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms # reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
# import cycles. Breaking them would require an architectural refactor. # import cycles. Breaking them would require an architectural refactor.
import copy import copy
import json
import logging import logging
import os import os
import asyncio import asyncio
@@ -1434,9 +1435,33 @@ class DownloadManager:
# Create directory if it doesn't exist # Create directory if it doesn't exist
os.makedirs(save_dir, exist_ok=True) os.makedirs(save_dir, exist_ok=True)
# Check if this is an early access model # Check if this is a paid or early access model
if version_info.get("earlyAccessEndsAt"): paid_access = version_info.get("paidAccess")
early_access_date = version_info.get("earlyAccessEndsAt", "") if isinstance(paid_access, str):
# Some providers (e.g. CivArchive fallback) carry the DTO as JSON text
try:
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 = (
"This model requires payment. Please ensure you have "
"purchased access and are logged in to Civitai."
)
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 # Convert to a readable date if possible
try: try:
from datetime import datetime from datetime import datetime
@@ -1448,7 +1473,7 @@ class DownloadManager:
early_access_msg = ( early_access_msg = (
f"This model requires payment (until {formatted_date}). " f"This model requires payment (until {formatted_date}). "
) )
except: except Exception:
early_access_msg = "This model requires payment. " early_access_msg = "This model requires payment. "
early_access_msg += "Please ensure you have purchased early access and are logged in to Civitai." early_access_msg += "Please ensure you have purchased early access and are logged in to Civitai."
+121 -7
View File
@@ -6,6 +6,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import logging import logging
import os import os
import sqlite3 import sqlite3
@@ -74,6 +75,8 @@ class ModelVersionRecord:
sort_index: int = 0 sort_index: int = 0
is_early_access: bool = False is_early_access: bool = False
usage_control: Optional[str] = None # "Download", "Generation", "InternalGeneration" 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 @dataclass
@@ -107,13 +110,17 @@ class ModelUpdateRecord:
return [version.version_id for version in self.versions if version.is_in_library] return [version.version_id for version in self.versions if version.is_in_library]
def has_update( 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: ) -> bool:
"""Return True when a non-ignored remote version newer than the newest local copy is available. """Return True when a non-ignored remote version newer than the newest local copy is available.
Args: Args:
hide_early_access: If True, exclude early access versions from update check. 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_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: if self.should_ignore_model:
@@ -129,6 +136,7 @@ class ModelUpdateRecord:
not version.is_in_library not version.is_in_library
and not version.should_ignore and not version.should_ignore
and not (hide_early_access and ModelUpdateRecord._is_early_access_active(version)) 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)) and not (hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version))
for version in self.versions for version in self.versions
) )
@@ -138,6 +146,8 @@ class ModelUpdateRecord:
continue continue
if hide_early_access and ModelUpdateRecord._is_early_access_active(version): if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
continue continue
if hide_paid and version.is_paid:
continue
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version): if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
continue continue
if version.version_id > max_in_library: 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 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) 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 # Phase 2: Precise check with exact end time
if version.early_access_ends_at: if version.early_access_ends_at:
try: try:
@@ -178,6 +193,7 @@ class ModelUpdateRecord:
local_base_model: Optional[str], local_base_model: Optional[str],
hide_early_access: bool = False, hide_early_access: bool = False,
hide_non_downloadable: bool = True, hide_non_downloadable: bool = True,
hide_paid: bool = False,
) -> bool: ) -> bool:
"""Return True when a newer remote version with the same base model exists. """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. local_base_model: The base model to filter by.
hide_early_access: If True, exclude early access versions from update check. 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_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: if self.should_ignore_model:
@@ -216,6 +233,8 @@ class ModelUpdateRecord:
continue continue
if hide_early_access and ModelUpdateRecord._is_early_access_active(version): if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
continue continue
if hide_paid and version.is_paid:
continue
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version): if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
continue continue
version_base = _normalize_base_model(version.base_model) version_base = _normalize_base_model(version.base_model)
@@ -252,6 +271,8 @@ class ModelUpdateService:
is_in_library INTEGER NOT NULL DEFAULT 0, is_in_library INTEGER NOT NULL DEFAULT 0,
should_ignore INTEGER NOT NULL DEFAULT 0, should_ignore INTEGER NOT NULL DEFAULT 0,
usage_control TEXT, usage_control TEXT,
paid_access TEXT,
is_paid INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (model_id, version_id), PRIMARY KEY (model_id, version_id),
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
); );
@@ -491,6 +512,14 @@ class ModelUpdateService:
"ALTER TABLE model_update_versions " "ALTER TABLE model_update_versions "
"ADD COLUMN usage_control TEXT" "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(): for column, statement in migrations.items():
@@ -592,6 +621,8 @@ class ModelUpdateService:
should_ignore INTEGER NOT NULL DEFAULT 0, should_ignore INTEGER NOT NULL DEFAULT 0,
early_access_ends_at TEXT, early_access_ends_at TEXT,
is_early_access INTEGER NOT NULL DEFAULT 0, is_early_access INTEGER NOT NULL DEFAULT 0,
paid_access TEXT,
is_paid INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (model_id, version_id), PRIMARY KEY (model_id, version_id),
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
) )
@@ -611,6 +642,8 @@ class ModelUpdateService:
"should_ignore", "should_ignore",
"early_access_ends_at", "early_access_ends_at",
"is_early_access", "is_early_access",
"paid_access",
"is_paid",
] ]
defaults = { defaults = {
"sort_index": "0", "sort_index": "0",
@@ -623,6 +656,8 @@ class ModelUpdateService:
"should_ignore": "0", "should_ignore": "0",
"early_access_ends_at": "NULL", "early_access_ends_at": "NULL",
"is_early_access": "0", "is_early_access": "0",
"paid_access": "NULL",
"is_paid": "0",
} }
select_parts = [] select_parts = []
@@ -936,17 +971,30 @@ class ModelUpdateService:
async with self._lock: async with self._lock:
return self._get_record(model_type, model_id) 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.""" """Determine if a model has updates pending."""
record = await self.get_record(model_type, model_id) 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( async def has_updates_bulk(
self, self,
model_type: str, model_type: str,
model_ids: Sequence[int], model_ids: Sequence[int],
hide_early_access: bool = False, hide_early_access: bool = False,
hide_paid: bool = False,
) -> Dict[int, bool]: ) -> Dict[int, bool]:
"""Return update availability for each model id in a single database pass.""" """Return update availability for each model id in a single database pass."""
@@ -959,7 +1007,9 @@ class ModelUpdateService:
return { return {
model_id: ( 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 if model_id in records
else False else False
) )
@@ -1190,6 +1240,7 @@ class ModelUpdateService:
"earlyAccessEndsAt": _normalize_string( "earlyAccessEndsAt": _normalize_string(
entry.get("earlyAccessEndsAt") entry.get("earlyAccessEndsAt")
), ),
"paidAccess": entry.get("paidAccess"),
} }
except RateLimitError: except RateLimitError:
raise raise
@@ -1214,6 +1265,17 @@ class ModelUpdateService:
"earlyAccessEndsAt" "earlyAccessEndsAt"
): ):
version["earlyAccessEndsAt"] = extra["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 @staticmethod
def _collect_hashes_from_response(response: Mapping[str, Any]) -> Dict[int, str]: 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, early_access_ends_at=remote_version.early_access_ends_at,
is_early_access=remote_version.is_early_access, is_early_access=remote_version.is_early_access,
usage_control=remote_version.usage_control, 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" is_early_access = availability == "EarlyAccess"
usage_control = _normalize_string(entry.get("usageControl")) 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( return ModelVersionRecord(
version_id=version_id, version_id=version_id,
name=name, name=name,
@@ -1577,8 +1653,36 @@ class ModelUpdateService:
sort_index=index, sort_index=index,
is_early_access=is_early_access, is_early_access=is_early_access,
usage_control=usage_control, 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]: def _extract_size_bytes(self, files) -> Optional[int]:
if not isinstance(files, Iterable): if not isinstance(files, Iterable):
return None return None
@@ -1691,7 +1795,7 @@ class ModelUpdateService:
f""" f"""
SELECT model_id, version_id, sort_index, name, base_model, released_at, 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, 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 FROM model_update_versions
WHERE model_id IN ({placeholders}) WHERE model_id IN ({placeholders})
ORDER BY model_id ASC, sort_index ASC, version_id ASC 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, sort_index=_normalize_int(row["sort_index"]) or 0,
is_early_access=bool(row["is_early_access"]), is_early_access=bool(row["is_early_access"]),
usage_control=row["usage_control"], 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,), (record.model_id,),
) )
for version in record.versions: 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( conn.execute(
""" """
INSERT INTO model_update_versions ( INSERT INTO model_update_versions (
version_id, model_id, sort_index, name, base_model, released_at, version_id, model_id, sort_index, name, base_model, released_at,
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_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
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
version.version_id, version.version_id,
@@ -1793,6 +1905,8 @@ class ModelUpdateService:
version.early_access_ends_at, version.early_access_ends_at,
1 if version.is_early_access else 0, 1 if version.is_early_access else 0,
version.usage_control, version.usage_control,
paid_access_value,
1 if version.is_paid else 0,
), ),
) )
conn.commit() conn.commit()
@@ -447,6 +447,19 @@
border-color: color-mix(in oklch, #F59F00 45%, transparent); 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 { .version-meta-ea {
color: #E67700; color: #E67700;
font-weight: 600; font-weight: 600;
@@ -182,6 +182,10 @@ function isEarlyAccessActive(version) {
} }
} }
function isPaidPermanent(version) {
return version && version.isPaid === true;
}
function isDownloadAllowed(version) { function isDownloadAllowed(version) {
if (!version.usageControl) { if (!version.usageControl) {
return true; return true;
@@ -342,6 +346,7 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
const strategy = state?.global?.settings?.version_grouping; const strategy = state?.global?.settings?.version_grouping;
const sameBaseMode = strategy === DISPLAY_FILTER_MODES.SAME_BASE; const sameBaseMode = strategy === DISPLAY_FILTER_MODES.SAME_BASE;
const hideEarlyAccess = state?.global?.settings?.hide_early_access_updates; const hideEarlyAccess = state?.global?.settings?.hide_early_access_updates;
const hidePaid = state?.global?.settings?.hide_paid_updates;
if (!sameBaseMode) { if (!sameBaseMode) {
return Boolean(record?.hasUpdate); return Boolean(record?.hasUpdate);
@@ -388,6 +393,9 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
if (hideEarlyAccess && isEarlyAccessActive(version)) { if (hideEarlyAccess && isEarlyAccessActive(version)) {
return false; return false;
} }
if (hidePaid && isPaidPermanent(version)) {
return false;
}
if (!isDownloadAllowed(version)) { if (!isDownloadAllowed(version)) {
return false; return false;
} }
@@ -469,6 +477,7 @@ function renderRow(version, options) {
const downloadedBadgeLabel = translate('modals.model.versions.badges.downloaded', {}, 'Downloaded'); const downloadedBadgeLabel = translate('modals.model.versions.badges.downloaded', {}, 'Downloaded');
const newerBadgeLabel = translate('modals.model.versions.badges.newer', {}, 'Newer Version'); const newerBadgeLabel = translate('modals.model.versions.badges.newer', {}, 'Newer Version');
const earlyAccessBadgeLabel = translate('modals.model.versions.badges.earlyAccess', {}, 'Early Access'); 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 ignoredBadgeLabel = translate('modals.model.versions.badges.ignored', {}, 'Ignored');
const versionName = version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version'); 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)) { if (!isDownloadAllowed(version)) {
const onSiteOnlyBadgeLabel = translate('modals.model.versions.badges.onSiteOnly', {}, 'On-Site Only'); const onSiteOnlyBadgeLabel = translate('modals.model.versions.badges.onSiteOnly', {}, 'On-Site Only');
badges.push(buildBadge(onSiteOnlyBadgeLabel, 'info', { badges.push(buildBadge(onSiteOnlyBadgeLabel, 'info', {
@@ -564,6 +583,12 @@ function renderRow(version, options) {
{}, {},
'This version is only available for on-site generation on Civitai' 'This version is only available for on-site generation on Civitai'
); );
} else if (isPaidPermanent(version)) {
downloadTitle = translate(
'modals.model.versions.actions.downloadPaidTooltip',
{},
'Download this paid version from Civitai'
);
} else if (isEarlyAccess) { } else if (isEarlyAccess) {
downloadTitle = translate( downloadTitle = translate(
'modals.model.versions.actions.downloadEarlyAccessTooltip', 'modals.model.versions.actions.downloadEarlyAccessTooltip',
+6
View File
@@ -1064,6 +1064,12 @@ export class SettingsManager {
hideEarlyAccessUpdatesCheckbox.checked = state.global.settings.hide_early_access_updates || false; 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'); const skipPreviouslyDownloadedModelVersionsCheckbox = document.getElementById('skipPreviouslyDownloadedModelVersions');
if (skipPreviouslyDownloadedModelVersionsCheckbox) { if (skipPreviouslyDownloadedModelVersionsCheckbox) {
skipPreviouslyDownloadedModelVersionsCheckbox.checked = skipPreviouslyDownloadedModelVersionsCheckbox.checked =
+1
View File
@@ -49,6 +49,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG }, priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
version_grouping: 'same_base', version_grouping: 'same_base',
hide_early_access_updates: false, hide_early_access_updates: false,
hide_paid_updates: false,
auto_organize_exclusions: [], auto_organize_exclusions: [],
metadata_refresh_skip_paths: [], metadata_refresh_skip_paths: [],
skip_previously_downloaded_model_versions: false, skip_previously_downloaded_model_versions: false,
@@ -1263,6 +1263,24 @@
</div> </div>
</div> </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> </div>
<!-- Example Images --> <!-- Example Images -->
+100
View File
@@ -593,3 +593,103 @@ async def test_fetch_missing_license_data_filters_model_ids(monkeypatch):
assert len(payload["updated"]) == 1 assert len(payload["updated"]) == 1
assert provider_calls == [[20]] assert provider_calls == [[20]]
assert len(saved) == 1 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
+6 -2
View File
@@ -82,7 +82,9 @@ class StubUpdateService:
self.bulk_calls = [] self.bulk_calls = []
self.bulk_error = bulk_error 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))) self.bulk_calls.append((model_type, list(model_ids)))
if self.bulk_error: if self.bulk_error:
raise RuntimeError("bulk failure") raise RuntimeError("bulk failure")
@@ -94,7 +96,9 @@ class StubUpdateService:
results[model_id] = result results[model_id] = result
return results 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)) self.calls.append((model_type, model_id))
result = self.decisions.get(model_id, False) result = self.decisions.get(model_id, False)
if isinstance(result, Exception): if isinstance(result, Exception):
+177 -1
View File
@@ -59,7 +59,17 @@ class NotFoundProvider:
return {} 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( return ModelVersionRecord(
version_id=version_id, version_id=version_id,
name=None, name=None,
@@ -69,6 +79,10 @@ def make_version(version_id, *, in_library, base_model=None, should_ignore=False
preview_url=None, preview_url=None,
is_in_library=in_library, is_in_library=in_library,
should_ignore=should_ignore, 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) # has_update must be True (version 20 > max_in_library=15)
assert record.has_update() is True 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