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
+100
View File
@@ -593,3 +593,103 @@ async def test_fetch_missing_license_data_filters_model_ids(monkeypatch):
assert len(payload["updated"]) == 1
assert provider_calls == [[20]]
assert len(saved) == 1
def test_serialize_version_permanent_paid_is_not_early_access():
"""Permanent paid versions (is_paid, no end date) must not be flagged as
early access, mirroring _is_early_access_active in the update service."""
version = ModelVersionRecord(
version_id=7, name="v7", base_model=None, released_at=None, size_bytes=None,
preview_url=None, is_in_library=False, should_ignore=False,
early_access_ends_at=None, is_early_access=True, usage_control="Download",
paid_access=json.dumps({"permanent": True, "endsAt": None}), is_paid=True,
)
serialized = ModelUpdateHandler._serialize_version(version, None)
assert serialized["isEarlyAccess"] is False
assert serialized["isPaid"] is True
assert serialized["paidAccess"] == {"permanent": True, "endsAt": None}
def test_serialize_version_timed_paid_is_early_access():
"""Timed paid gates (endsAt in the future) stay flagged as early access."""
version = ModelVersionRecord(
version_id=8, name="v8", base_model=None, released_at=None, size_bytes=None,
preview_url=None, is_in_library=False, should_ignore=False,
early_access_ends_at="2099-01-01T00:00:00.000Z", is_early_access=True,
usage_control="Download",
paid_access=json.dumps({"permanent": False, "endsAt": "2099-01-01T00:00:00.000Z"}),
is_paid=False,
)
serialized = ModelUpdateHandler._serialize_version(version, None)
assert serialized["isEarlyAccess"] is True
assert serialized["isPaid"] is False
def test_serialize_version_malformed_paid_access_does_not_crash():
"""A malformed paid_access row must degrade to None instead of failing
the whole versions-list response."""
version = ModelVersionRecord(
version_id=10, name="v10", base_model=None, released_at=None, size_bytes=None,
preview_url=None, is_in_library=False, should_ignore=False,
early_access_ends_at=None, is_early_access=True, usage_control=None,
paid_access="{not json", is_paid=False,
)
serialized = ModelUpdateHandler._serialize_version(version, None)
assert serialized["paidAccess"] is None
assert serialized["isEarlyAccess"] is True
async def test_enrich_early_access_details_skips_permanent_paid(monkeypatch):
"""Permanent paid versions must not trigger per-version CivitAI fetches in
_enrich_early_access_details: they are not early access and can never get
an end time, so enriching them is wasted API traffic."""
record = ModelUpdateRecord(
model_type="lora",
model_id=1,
versions=[
ModelVersionRecord(
version_id=100, name="paid", base_model=None, released_at=None,
size_bytes=None, preview_url=None, is_in_library=False,
should_ignore=False, early_access_ends_at=None,
is_early_access=True, usage_control="Download",
paid_access='{"permanent": true, "endsAt": null}', is_paid=True,
),
ModelVersionRecord(
version_id=200, name="ea", base_model=None, released_at=None,
size_bytes=None, preview_url=None, is_in_library=False,
should_ignore=False, early_access_ends_at=None,
is_early_access=True, usage_control="Download",
paid_access=None, is_paid=False,
),
],
last_checked_at=1.0,
should_ignore_model=False,
)
fetched: list[int] = []
async def fake_version_info(version_id: str):
fetched.append(int(version_id))
return {"earlyAccessEndsAt": "2099-01-01T00:00:00.000Z"}, None
provider = SimpleNamespace(get_model_version_info=fake_version_info)
async def metadata_selector(name):
assert name == "civitai_api"
return provider
handler = ModelUpdateHandler(
service=DummyService(SimpleNamespace(raw_data=[], version_index={})),
update_service=SimpleNamespace(),
metadata_provider_selector=metadata_selector,
settings_service=SimpleNamespace(get=lambda *_: False),
logger=logging.getLogger(__name__),
)
enriched = await handler._enrich_early_access_details(record)
# Only the timed EA version (200) is fetched; the permanent paid one (100) is skipped.
assert fetched == [200]
enriched_map = {v.version_id: v for v in enriched.versions}
assert enriched_map[200].early_access_ends_at == "2099-01-01T00:00:00.000Z"
assert enriched_map[100].early_access_ends_at is None