mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-16 02:33: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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user