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
+17 -2
View File
@@ -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
+39 -14
View File
@@ -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')}"
)
+121 -7
View File
@@ -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()