From 5f4054265dff72f272f88297d961abd80c95794a Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sun, 4 Oct 2026 08:53:12 +0800 Subject: [PATCH 01/10] fix(update): treat a timed paidAccess gate with no recorded end as active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CivitAI only returns a non-null paidAccess for an *active* gate: a lapsed gate stays in the database as a tombstone and is filtered out server-side, so {"permanent": false, "endsAt": null} — a timed gate whose window end has not been recorded yet — is still enforced. Verified live: on model 1802980 that version reports canDownload: false while its lapsed siblings report true. Both the update service and the download gate dropped that shape, so such versions read as free and "Hide Early Access Updates" missed them — the class of bug reported in #1060. The interpretation now lives in py/utils/paid_access.py and is shared, so the badge, the update filter and the download warning cannot disagree. --- py/services/download_manager.py | 43 +++--- py/services/model_update_service.py | 43 ++---- py/utils/paid_access.py | 158 ++++++++++++++++++++ tests/services/test_model_update_service.py | 37 ++++- tests/utils/test_paid_access.py | 112 ++++++++++++++ 5 files changed, 342 insertions(+), 51 deletions(-) create mode 100644 py/utils/paid_access.py create mode 100644 tests/utils/test_paid_access.py diff --git a/py/services/download_manager.py b/py/services/download_manager.py index 570f19c9..29dcca9b 100644 --- a/py/services/download_manager.py +++ b/py/services/download_manager.py @@ -33,6 +33,13 @@ from ..utils.constants import ( VALID_OTHER_CIVITAI_TYPES, ) from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url +from ..utils.paid_access import ( + is_early_access_deadline_active, + is_gate_active, + is_permanent_paid, + normalize_paid_access, + parse_civitai_timestamp, +) from ..utils.file_utils import calculate_sha256, calculate_autov3 from ..utils.preview_selection import resolve_mature_threshold, select_preview_media from ..utils.utils import calculate_filename_for_model, sanitize_folder_name @@ -1985,40 +1992,32 @@ class DownloadManager: os.makedirs(save_dir, exist_ok=True) # 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: - 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 + # CivitAI reports a non-null paidAccess only for an ACTIVE gate, so + # {"permanent": false, "endsAt": null} (a timed gate whose end is not + # recorded yet) still counts as gated here. + paid_access = normalize_paid_access(version_info.get("paidAccess")) + legacy_ea_ends_at = version_info.get("earlyAccessEndsAt") + gate_active = is_gate_active(paid_access) or is_early_access_deadline_active( + legacy_ea_ends_at + ) + if gate_active: + permanent_paid = is_permanent_paid(paid_access) 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") + early_access_date = legacy_ea_ends_at 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 - - date_obj = datetime.fromisoformat( - early_access_date.replace("Z", "+00:00") - ) - formatted_date = date_obj.strftime("%Y-%m-%d") + formatted_date = parse_civitai_timestamp( + early_access_date + ).strftime("%Y-%m-%d") early_access_msg = ( f"This model requires payment (until {formatted_date}). " ) diff --git a/py/services/model_update_service.py b/py/services/model_update_service.py index 00147828..5040eea1 100644 --- a/py/services/model_update_service.py +++ b/py/services/model_update_service.py @@ -20,6 +20,10 @@ from .settings_manager import get_settings_manager from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration from ..utils.constants import MODEL_WEIGHT_FILE_TYPES from ..utils.civitai_utils import rewrite_preview_url +from ..utils.paid_access import ( + is_early_access_deadline_active, + normalize_paid_access as _normalize_paid_access_payload, +) from ..utils.preview_selection import resolve_mature_threshold, select_preview_media logger = logging.getLogger(__name__) @@ -172,16 +176,10 @@ class ModelUpdateRecord: 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 (None when the gate is timed + # but its window end has not been recorded yet -> treated as active below) if version.early_access_ends_at: - try: - ea_date = datetime.fromisoformat( - version.early_access_ends_at.replace("Z", "+00:00") - ) - return ea_date > datetime.now(timezone.utc) - except (ValueError, AttributeError): - # If date parsing fails, treat as active EA (conservative) - return True + return is_early_access_deadline_active(version.early_access_ends_at) # Phase 1: Basic EA flag from bulk API return version.is_early_access @@ -1762,6 +1760,9 @@ class ModelUpdateService: # CivitAI's paidAccess DTO ({"permanent": bool, "endsAt": ISO|null}) # gates versions behind a paid tier while availability stays "Public". + # A non-null DTO from the public API is always an ACTIVE gate: lapsed + # (tombstone) gates come back as null. That includes the timed gate whose + # end is not recorded yet, {"permanent": false, "endsAt": null}. 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 @@ -1769,7 +1770,7 @@ class ModelUpdateService: 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"): + if not is_early_access and paid_access and not paid_access.get("permanent"): is_early_access = True return ModelVersionRecord( @@ -1797,24 +1798,12 @@ class ModelUpdateService: 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. + + Delegates to :mod:`py.utils.paid_access` so the update service and the + download gate cannot disagree about what counts as a gate. """ - 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} + + return _normalize_paid_access_payload(value) @staticmethod def _extract_file_count(files) -> Optional[int]: diff --git a/py/utils/paid_access.py b/py/utils/paid_access.py new file mode 100644 index 00000000..d2d9f15a --- /dev/null +++ b/py/utils/paid_access.py @@ -0,0 +1,158 @@ +"""Shared interpretation of CivitAI's ``paidAccess`` DTO. + +One rule for the whole backend: the update service (badges, update filters, price +tracking) and the download gate both ask these helpers, so a version can never be +"paid" for one consumer and "free" for another. + +Why the rules look like this +---------------------------- +CivitAI's public v1 API only returns a non-null DTO for an *active* gate: a lapsed +gate stays in the database as a tombstone but is filtered out server-side +(``toPublicPaidAccessDto`` in civitai's ``server/services/paid-access.service.ts``). +A timed gate whose end time has not been recorded yet is reported as +``{"permanent": false, "endsAt": null}`` and is still enforced — such a version +reports ``canDownload: false`` on the model page — so it must NOT be discarded. +Dropping it (the previous behaviour) hid real gates, which is the class of bug +reported in issue #1060. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Any, Mapping, Optional + +__all__ = [ + "normalize_paid_access", + "is_permanent_paid", + "is_gate_active", + "is_early_access_deadline_active", + "parse_civitai_timestamp", +] + +# A DTO that carries neither key is not a gate signal at all (defensive: CivitAI +# never emits a bare ``{}``, but other metadata sources might). +_PAID_ACCESS_KEYS = ("permanent", "endsAt") + + +def parse_civitai_timestamp(value: Any) -> Optional[datetime]: + """Parse a CivitAI ISO-8601 timestamp into an aware UTC datetime. + + Returns None for anything that is not a parsable string. Naive timestamps are + assumed to be UTC, matching CivitAI's serialization. + """ + + if not isinstance(value, str): + return None + text = value.strip() + if not text: + return None + if text.endswith("Z"): + text = f"{text[:-1]}+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + + +def normalize_paid_access(value: Any) -> Optional[dict]: + """Normalize a ``paidAccess`` value into ``{"permanent": bool, "endsAt": str|None}``. + + Accepts a mapping, a JSON string (the by-hash enrichment path carries the DTO as + text), or None. Returns None when the value carries no gate signal. + + Note that ``{"permanent": False, "endsAt": None}`` is a *gate*: CivitAI reports + it for a timed gate whose window end is not set yet, and enforces it. + """ + + 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 + + if not any(key in value for key in _PAID_ACCESS_KEYS): + return None + + ends_at = value.get("endsAt") + normalized_ends_at = ends_at.strip() if isinstance(ends_at, str) and ends_at.strip() else None + return { + "permanent": bool(value.get("permanent")), + "endsAt": normalized_ends_at, + } + + +def is_permanent_paid(paid_access: Optional[Mapping[str, Any]]) -> bool: + """True when the gate never expires (a permanent paid version).""" + + return bool(paid_access and paid_access.get("permanent")) + + +def is_gate_active( + paid_access: Optional[Mapping[str, Any]], + *, + now: Optional[datetime] = None, +) -> bool: + """True when a normalized ``paidAccess`` gate is currently in force. + + Active means permanent, or a timed gate whose end is either still in the future + or not recorded yet (CivitAI enforces the latter too). An unparsable end time is + treated as active rather than free: the download would fail anyway, so the + conservative reading matches what the user will experience. + """ + + if not paid_access: + return False + if paid_access.get("permanent"): + return True + + ends_at = paid_access.get("endsAt") + if not ends_at: + # Timed gate with no recorded end — CivitAI still gates the download. + return True + + parsed = parse_civitai_timestamp(ends_at) + if parsed is None: + return True + + reference = now or datetime.now(timezone.utc) + if reference.tzinfo is None: + reference = reference.replace(tzinfo=timezone.utc) + return parsed > reference + + +def is_early_access_deadline_active( + ends_at: Any, + *, + now: Optional[datetime] = None, +) -> bool: + """True when a legacy ``earlyAccessEndsAt`` deadline is still in the future. + + Kept separate from :func:`is_gate_active` because the legacy field is a bare + timestamp rather than a DTO. A present-but-unparsable value is treated as active, + matching the previous conservative behaviour. + """ + + if not ends_at: + return False + + parsed = parse_civitai_timestamp(ends_at) + if parsed is None: + return True + + reference = now or datetime.now(timezone.utc) + if reference.tzinfo is None: + reference = reference.replace(tzinfo=timezone.utc) + return parsed > reference diff --git a/tests/services/test_model_update_service.py b/tests/services/test_model_update_service.py index 61d48bb2..707d8525 100644 --- a/tests/services/test_model_update_service.py +++ b/tests/services/test_model_update_service.py @@ -826,6 +826,32 @@ def test_extract_single_version_paid_access_permanent(tmp_path): assert version.paid_access is not None +def test_extract_single_version_paid_access_pending_end(tmp_path): + """A timed gate whose window end is not recorded yet + ({"permanent": false, "endsAt": null}) is still an active gate, so it is + early access with no known end date rather than a free version.""" + db_path = tmp_path / "updates.sqlite" + service = ModelUpdateService(str(db_path)) + + entry = { + "id": 42, + "name": "v1 paid", + "availability": "Public", + "paidAccess": {"permanent": False, "endsAt": None}, + "files": [], + "images": [], + } + + version = service._extract_single_version(entry, index=0) + + assert version is not None + assert version.is_early_access is True + assert version.is_paid is False + assert version.early_access_ends_at is None + assert version.paid_access == '{"permanent": false, "endsAt": null}' + assert ModelUpdateRecord._is_early_access_active(version) is True + + 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.""" @@ -841,14 +867,21 @@ def test_normalize_paid_access_accepts_json_string(): ) assert timed == {"permanent": False, "endsAt": "2026-08-22T18:30:00.000Z"} - empty = ModelUpdateService._normalize_paid_access( + # A timed gate whose window end is not recorded yet. CivitAI only returns a + # non-null DTO for an ACTIVE gate (tombstones come back as null) and enforces + # this shape too - the model page reports canDownload: false for it - so it + # must be kept. Dropping it was the #1060 class of bug. + pending_end = ModelUpdateService._normalize_paid_access( '{"permanent": false, "endsAt": null}' ) - assert empty is None + assert pending_end == {"permanent": False, "endsAt": None} malformed = ModelUpdateService._normalize_paid_access("{not json") assert malformed is None + # No gate keys at all is not a gate signal. + assert ModelUpdateService._normalize_paid_access("{}") is None + def test_has_update_for_base_hide_paid(): """hide_paid also suppresses permanent paid versions in the same-base diff --git a/tests/utils/test_paid_access.py b/tests/utils/test_paid_access.py new file mode 100644 index 00000000..3d63da0e --- /dev/null +++ b/tests/utils/test_paid_access.py @@ -0,0 +1,112 @@ +"""Tests for the shared CivitAI paidAccess interpretation. + +These rules decide both the Paid/Early Access badges and whether a download is +warned about, so the interesting cases are the boundary shapes CivitAI actually +emits. +""" + +from datetime import datetime, timezone + +import pytest + +from py.utils.paid_access import ( + is_early_access_deadline_active, + is_gate_active, + is_permanent_paid, + normalize_paid_access, + parse_civitai_timestamp, +) + +NOW = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc) + + +@pytest.mark.parametrize( + "value,expected", + [ + (None, None), + ("", None), + ("{not json", None), + ("[1, 2]", None), + ({}, None), + ({"unknown": 1}, None), + # A non-null DTO from CivitAI's public API is always an active gate, so a + # timed gate with no recorded end must survive normalization. + ({"permanent": False, "endsAt": None}, {"permanent": False, "endsAt": None}), + ({"permanent": True, "endsAt": None}, {"permanent": True, "endsAt": None}), + ( + {"permanent": False, "endsAt": "2026-10-10T13:10:17.404Z"}, + {"permanent": False, "endsAt": "2026-10-10T13:10:17.404Z"}, + ), + ], +) +def test_normalize_paid_access_shapes(value, expected): + assert normalize_paid_access(value) == expected + + +def test_normalize_paid_access_accepts_json_text(): + """The by-hash enrichment path can carry the DTO as a JSON string.""" + + assert normalize_paid_access('{"permanent": true, "endsAt": null}') == { + "permanent": True, + "endsAt": None, + } + + +def test_normalize_paid_access_blank_ends_at_is_none(): + assert normalize_paid_access({"permanent": False, "endsAt": " "}) == { + "permanent": False, + "endsAt": None, + } + + +def test_is_permanent_paid(): + assert is_permanent_paid({"permanent": True, "endsAt": None}) is True + assert is_permanent_paid({"permanent": False, "endsAt": None}) is False + assert is_permanent_paid(None) is False + + +@pytest.mark.parametrize( + "paid_access,expected", + [ + (None, False), + ({"permanent": False, "endsAt": None}, True), + ({"permanent": True, "endsAt": None}, True), + # A permanent gate with a stale endsAt stays active. + ({"permanent": True, "endsAt": "2020-01-01T00:00:00.000Z"}, True), + ({"permanent": False, "endsAt": "2026-10-10T13:10:17.404Z"}, True), + ({"permanent": False, "endsAt": "2026-08-27T16:56:57.438Z"}, False), + ({"permanent": False, "endsAt": "not-a-date"}, True), + ], +) +def test_is_gate_active(paid_access, expected): + assert is_gate_active(paid_access, now=NOW) is expected + + +@pytest.mark.parametrize( + "ends_at,expected", + [ + (None, False), + ("", False), + ("2026-10-10T13:10:17.404Z", True), + ("2026-08-27T16:56:57.438Z", False), + ("garbage", True), + ], +) +def test_is_early_access_deadline_active(ends_at, expected): + assert is_early_access_deadline_active(ends_at, now=NOW) is expected + + +@pytest.mark.parametrize( + "value,expected", + [ + (None, None), + (123, None), + ("", None), + ("garbage", None), + ("2026-10-10T13:10:17.404Z", datetime(2026, 10, 10, 13, 10, 17, 404000, tzinfo=timezone.utc)), + # Naive timestamps are assumed UTC. + ("2026-10-10T13:10:17", datetime(2026, 10, 10, 13, 10, 17, tzinfo=timezone.utc)), + ], +) +def test_parse_civitai_timestamp(value, expected): + assert parse_civitai_timestamp(value) == expected From ad2402724b2edce9ac720644a5022ed225865967 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sun, 4 Oct 2026 08:53:24 +0800 Subject: [PATCH 02/10] feat(update): track buzz prices and alert below a threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CivitAI's public API deliberately omits prices — paidAccess is trimmed to {permanent, endsAt} because "pricing belongs to the purchase flow" — but the public model page embeds the site's own model.getById result, including paidAccess.terms, in its server-rendered payload. That is read anonymously (no API key, no internal endpoint, no forged Origin), one request per gated model, so only the ~2% of models that actually carry a gate pay for it. - optional capture, off by default: price_tracking_enabled, price_alert_threshold_buzz (0 = alert on "became free" only) and price_check_ttl_hours; prices refresh on their own TTL and immediately when a gate changes, and a failed fetch keeps the stored price instead of blanking it - versions that stop carrying a gate are marked free (persisted gate_lapsed_at) and gate transitions are reported as events on the refresh response, so a version already in the library can announce that it became free - price_alert_state plus a price_drop edge event; new GET /api/lm/{type}/updates/price-alerts lists what is under the threshold - versions tab shows the price (effective, with the list price struck through and a Blue Buzz note) and a Free Now badge; an update check toasts the transitions in one message - the parser and the alerts query are unit-tested against a trimmed page fixture, and every route definition is now asserted to resolve to a handler Plan, verification notes and the deviations from it are in docs/plans/paid-model-price-tracking.md. --- docs/plans/paid-model-price-tracking.md | 381 +++++++++++ locales/de.json | 23 +- locales/en.json | 23 +- locales/es.json | 23 +- locales/fr.json | 23 +- locales/he.json | 23 +- locales/ja.json | 23 +- locales/ko.json | 23 +- locales/ru.json | 23 +- locales/zh-CN.json | 23 +- locales/zh-TW.json | 23 +- py/routes/handlers/model_handlers.py | 83 ++- py/routes/model_route_registrar.py | 3 + py/services/civitai_client.py | 69 +- py/services/model_metadata_provider.py | 53 ++ py/services/model_update_service.py | 470 ++++++++++++- py/services/settings_manager.py | 5 + py/utils/civitai_page_prices.py | 257 +++++++ static/css/components/lora-modal/versions.css | 14 + static/js/api/apiConfig.js | 1 + static/js/api/baseModelApi.js | 27 + .../js/components/shared/ModelVersionsTab.js | 96 ++- static/js/managers/SettingsManager.js | 16 + static/js/state/index.js | 3 + static/js/utils/updateCheckHelpers.js | 62 ++ .../components/modals/settings/library.html | 51 ++ tests/routes/test_model_update_handler.py | 149 +++++ tests/services/test_civitai_client.py | 62 ++ tests/services/test_model_update_service.py | 631 ++++++++++++++++++ .../fixtures/civitai_model_page_paid.html | 108 +++ tests/utils/test_civitai_page_prices.py | 91 +++ 31 files changed, 2829 insertions(+), 33 deletions(-) create mode 100644 docs/plans/paid-model-price-tracking.md create mode 100644 py/utils/civitai_page_prices.py create mode 100644 tests/utils/fixtures/civitai_model_page_paid.html create mode 100644 tests/utils/test_civitai_page_prices.py diff --git a/docs/plans/paid-model-price-tracking.md b/docs/plans/paid-model-price-tracking.md new file mode 100644 index 00000000..4917c2c7 --- /dev/null +++ b/docs/plans/paid-model-price-tracking.md @@ -0,0 +1,381 @@ +# Plan: Buzz Price Tracking and Threshold Alerts for Paid / Early Access Versions + +**Origin:** FR "Price tracker for models/loras" (Geekier, Discord) — track the buzz price of paid +model versions and flag when it drops below a threshold or becomes free. +**Related:** [#1060 — Some "Early Access" models are not identified correctly](https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1060) +(closed) established `is_paid`, the Paid badge and `hide_paid_updates`; this FR is the next step +after gate *state* — gate *price*. +**Status:** v2 — **P0–P3 implemented** (see §10 for what shipped and the deviations from this plan). +Feasibility was verified against this repo and upstream CivitAI `main` (`6d29ed1368`), including live +probes against `civitai.com` / `civitai.red`. +**Scope:** every model type that goes through `ModelUpdateService` (lora / checkpoint / embedding / +other). Out of scope: purchasing, downloading gated content, and any change that depends on a future +CivitAI API addition. + +--- + +## 1. Problem statement + +Creators increasingly gate model versions behind buzz. LoRA Manager already knows **whether** a +version is gated (`is_paid`, `is_early_access`, `paid_access`) but not **how much it costs**, so it +cannot answer the two questions the FR asks: + +1. "Tell me when this model's price drops below X buzz." +2. "Tell me when it becomes free." + +Additionally, the current code only compares version ids when deciding `has_update`, so a version +that a user already tracks can go from paid to free (or gain a gate) without producing any signal at +all. + +## 2. Verified current state + +### 2.1 LoRA Manager (all references confirmed in this checkout) + +| Fact | Reference | +| --- | --- | +| Gate fields persisted per version: `is_paid`, `is_early_access`, `paid_access` (raw DTO JSON), `early_access_ends_at` | `py/services/model_update_service.py:65-84` | +| Gate data comes from the bulk `GET /api/v1/models?ids=…` response (`availability`, `paidAccess`, `earlyAccessEndsAt`) | `py/services/civitai_client.py:329-380`, parsed at `py/services/model_update_service.py:1756-1793` | +| **No numeric price anywhere in `py/`** | `grep -rn "price" py/` → only license/`allowCommercialUse` hits | +| Update refresh is user-triggered, not scheduled; TTL 24 h | route `py/routes/handlers/model_handlers.py:2899`; `ModelUpdateService.__init__` default `ttl_seconds=24*60*60` at `py/services/model_update_service.py:338` | +| `has_update` compares version ids + gate filters only; no state-transition detection | `py/services/model_update_service.py:117-160` | +| `{permanent:false, endsAt:null}` is deliberately treated as **not** a gate on the download path | `py/services/download_manager.py:2000-2003` | +| Version payload sent to the frontend already carries `isPaid` / `paidAccess` | `py/routes/handlers/model_handlers.py:3444-3455` | +| UI: Paid / Early Access badges, `hide_paid_updates` filter | `static/js/components/shared/ModelVersionsTab.js:169-196, 474-545`; `static/js/state/index.js:58-59`; `templates/components/modals/settings/library.html:191-192` | + +### 2.2 Upstream CivitAI — what is and is not public + +| Fact | Evidence | +| --- | --- | +| Public v1 API intentionally strips pricing: `paidAccess` is reduced to `{permanent, endsAt}`. Source comment: *"Omits terms (pricing belongs to the purchase flow)"* | `~/code/civitai/src/server/services/paid-access.service.ts:640-652`; verified live: `/api/v1/models` and `/api/v1/model-versions/{id}` return exactly that shape | +| The internal tRPC route is **not** usable anonymously. `isAcceptableOrigin` rejects non-site origins with `401 "Please use the public API instead"` | `~/code/civitai/src/server/trpc.ts:120-128`; `acceptableOrigin` = `!isProd \|\| isBearerAuth \|\| isAllowedOriginRequest(req)` (`createContext.ts:43`), and the origin check is header-based (`src/server/utils/origin-helpers.ts:27-32`). **Live probe warning:** an anonymous tRPC GET can return `200` when Cloudflare serves a cached copy (`cf-cache-status: HIT`); uncached it is `401`. Do not build on this path. | +| **The price *is* publicly reachable inside the public model page.** The SSR payload embeds the site's own `model.getById` result, including full `terms` | `https://civitai.com/models/` and `https://civitai.red/models/` → ``; `json.loads` it. +* Walk `props.pageProps.trpcState.json.queries[*]`; pick the entry whose `queryKey[0] == ["model","getById"]` + (the query order is not stable — 7 queries were observed, `getById` first only incidentally). +* From `state.data.modelVersions[*]`, per version emit: + * `price_buzz` = `paidAccess.sale.buyerTerms.download.price` if `sale` present, else + `paidAccess.terms.download.price` + * `list_price_buzz` = `paidAccess.terms.download.price` + * `generation_price_buzz` = `terms.generation.price` (undefined for `{free:true}` / bundled) + * `accepts_blue_buzz`, `price_sale_ends_at` + * no `paidAccess` → the version is ungated: emit an explicit "no price" marker so the caller can + clear stale prices and raise a *became free* event. +* Return `None` (never raise) for: missing script tag, JSON error, shape mismatch, empty + `modelVersions`, or a Cloudflare/challenge page. + +### 4.3 Refresh flow and request budget + +Hook: `_refresh_single_model` (`py/services/model_update_service.py:1099-1215`), inside the existing +"lock released during network I/O" window, after `fetched_versions` is built: + +``` +prices_needed = feature_enabled and (any gated version or any version with a stored price) +if prices_needed: + prices = await provider.get_model_prices(model_id) # never raises; None on failure + if prices is not None: + fetched_versions = [apply_price(v, prices.get(v.version_id)) for v in fetched_versions] + # price_checked_at advances only for versions actually present in the payload +``` + +* Candidate selection uses the gate data already in hand from the bulk API, so no extra call is made + for the ~98% of models with no gate (except models that previously had a price and must confirm + "now free"). +* TTL: `price_check_ttl_hours` (default 24 h), independent of the update TTL — a price refresh can be + skipped while the version list is fresh, and vice versa. Force refresh (`force_refresh=True`) also + forces prices. +* Failure behaviour: keep the previous price, do **not** advance `price_checked_at` (so the next + refresh retries), log at debug/info. A `RateLimitError` from the shared gate must remain a skip, + not a hard failure of the update check. After N consecutive parse failures within one refresh, + stop attempting price fetches for the remainder of that run. +* Offline/cooldown: reuse the existing `ConnectivityGuard` path in `downloader.make_request` — no new + connectivity handling. + +Client/provider seam: + +* `ModelMetadataProvider.get_model_prices(model_id)` — default returns `None` (not abstract, so + CivArchive / SQLite providers are untouched). +* `CivitaiModelMetadataProvider` → `CivitaiClient.get_model_prices(model_id)`. +* Add the pass-through to the composite provider wrapper + (`py/services/model_metadata_provider.py:855-955`) so the rate-limit helper applies. +* `CivitaiClient.get_model_prices`: build the URL with the existing helper + `build_civitai_model_page_url(model_id, host=self._settings.get("civitai_host"))` + (`py/utils/civitai_utils.py:44-66`; `civitai.red` is already a supported page host, + `:10`), fetch with `downloader.make_request("GET", url, use_auth=False, custom_headers={"Accept": "text/html"})` + (returns `str` because the body is not JSON), then call the parser. Cap the body size before + parsing. + +### 4.4 Alert semantics + +* **Threshold hit**: `price_buzz is not None and price_buzz <= threshold_buzz`. `threshold_buzz = 0` + means "free only, plus any gate removal". +* **Became free**: a version with a stored gate/price now has no `paidAccess` (from the bulk API + and/or the page payload). +* **Newly gated**: a version that had none now has `paidAccess`. +* Edge detection uses `price_alert_state` plus the previous `paid_access` JSON, compared in + `_build_record_from_remote` where `existing_map` is already built + (`py/services/model_update_service.py:1636-1665`). Do not put this in `has_update`: it is a + different question, and `has_update` is filtered by the hide-* settings. +* `ModelUpdateRecord` gains a **non-persisted** field (e.g. `events: list | None = None`) carrying + `{version_id, kind: "price_drop"|"became_free"|"new_gate", price_buzz, previous_price_buzz}`. + `_upsert_record` ignores it; `_get_record` leaves it `None`. + +### 4.5 Settings and routes + +New settings (defaults in `DEFAULT_SETTINGS`, `py/services/settings_manager.py:68`; +`settings.json.example` stays minimal per repo policy; frontend defaults in +`static/js/state/index.js:58`): + +| Key | Default | Meaning | +| --- | --- | --- | +| `price_tracking_enabled` | `False` | master switch; off means no model-page fetches at all | +| `price_alert_threshold_buzz` | `0` | alert when effective price ≤ this; 0 = free only | +| `price_check_ttl_hours` | `24` | price freshness window | + +Routes (all under the existing model route registrars, `POST` + `GET` because the companion +extension is GET-only per `AGENTS.md`): + +* `GET /api/lm/models/price-alerts` — aggregate over the update DB: + `{modelId, modelType, versionId, versionName, priceBuzz, listPriceBuzz, thresholdBuzz, isFree}`. +* Prices themselves need no new read route: they ride along in the existing version payload + (`py/routes/handlers/model_handlers.py:3438-3455` gains `priceBuzz`, `listPriceBuzz`, + `generationPriceBuzz`, `acceptsBlueBuzz`, `priceCheckedAt`, `priceAlert`). +* The existing update-refresh response gains `events` (from §4.4) so the frontend can toast. + +### 4.6 UI + +* `static/js/components/shared/ModelVersionsTab.js` + * Extend the existing Paid / Early Access badge tooltips with the price when known + ("Paid · 5,000 Buzz") — helpers at `:169-196`, badges at `:474-545`. + * New badges: price chip (`paid`/`info` styling) and "Free now" for a became-free transition. + * Filter toggle reusing the `hide_paid_updates` pattern (`:349-400`, plus the settings modal and + `SettingsManager.js:1157-1163`) — e.g. "Show price drops only". +* Optional, cheap win: include the price in the paid-download error text + (`py/services/download_manager.py:1987-2031`, `py/services/use_cases/download_model_use_case.py:35`). +* Strings: add to `locales/en.json`, then `python scripts/sync_translation_keys.py`, then **stop** — + do not translate the other locales (see `docs/i18n-translation-guidelines.md` §7). + +## 5. Phased tasks + +### P0 — settle gate semantics (prerequisite, small) + +- [ ] Decide the meaning of `{permanent:false, endsAt:null}`. The repo currently treats it as *no + gate* (`py/services/download_manager.py:2000-2003`), while upstream `isPaidAccessActive` + (`endsAt == null || endsAt > now`) treats it as *active*. Live sample: version 3372655 + currently returns exactly this DTO from `/api/v1/models`. +- [ ] Introduce one shared helper (e.g. `_has_active_gate(paid_access, early_access_ends_at)` in + `py/services/model_update_service.py`) and use it from both the update service and the download + gate, so alerts and download blocking cannot disagree. +- [ ] Unit test for the chosen rule. +- **Why first:** every alert kind inherits the gate-state decision; getting it wrong produces false + "free" alerts. + +### P1 — gate-state change detection (no new data source) + +- [ ] Emit `new_gate` / `became_free` events from `_build_record_from_remote`; add the non-persisted + `events` field; surface in the refresh response. +- [ ] Keep `has_update` semantics unchanged (regression risk to `hide_paid_updates`, + `hide_early_access_updates`). +- [ ] UI: "Free now" badge + one batched toast per refresh; extend paid/EA tooltips. +- [ ] Tests in `tests/services/test_model_update_service.py` for each transition and for the + no-change case. +- **Acceptance:** a version whose gate lapses is visibly marked free after a refresh, with no new + network calls; existing hide-* filters behave identically. + +### P2 — price capture (opt-in) + +- [ ] Schema + all eight column touchpoints from §4.1, with a migration test. +- [ ] `parse_model_page_prices` + fixture-based tests (`tests/services/fixtures/…`, one trimmed page + with a timed gate, one with a permanent gate, one malformed/missing payload). +- [ ] `CivitaiClient.get_model_prices` + provider methods + composite pass-through; assert redirect + following and that a non-JSON body is returned as text. +- [ ] Wire into `_refresh_single_model` behind `price_tracking_enabled`, with the failure matrix from + §4.3. +- [ ] Tests: feature off ⇒ zero extra calls; fetch failure ⇒ previous price retained and + `price_checked_at` unchanged; gated-only candidate selection. +- **Acceptance:** with tracking enabled, a refresh on a gated model stores `price_buzz` for all of + its versions from one request; with tracking disabled or offline, refresh behaves exactly as today. + +### P3 — threshold alerts and surface + +- [ ] Settings + defaults (§4.5) and the settings-modal toggles/inputs. +- [ ] Threshold evaluation + `price_alert_state` edges → `price_drop` events. +- [ ] `GET /api/lm/models/price-alerts` aggregate endpoint. +- [ ] Version payload gains the price fields; UI price chip, "Free now" badge, "price drops only" + filter; optional price in the paid-download error. +- [ ] Locale keys + `scripts/sync_translation_keys.py`. +- [ ] Tests: threshold boundary (`<=` vs `<`), Blue Buzz labelling, sale-adjusted vs list price, + alerts for a version already in the library. +- **Acceptance:** a user can set "alert me under 500 buzz", refresh, and see the models that + qualify; nothing alerts when the price rises above the threshold again (state resets). + +### P4 — docs and upstream + +- [ ] Short feature note in the repo docs; keep this plan's data-source rationale in a code comment + (public page read, no auth or origin spoofing, no internal API). +- [ ] Draft the upstream request: add a price field to the public `paidAccess` DTO. Point out the + asymmetry — writes already go through the official v1 endpoint + (`~/code/civitai/src/pages/api/v1/model-versions/early-access.ts`, sharing + `updateModelVersionPaidAccessSchema`) while reads withhold it deliberately. If it lands, the + page scraper becomes an optional fallback rather than the only path. + +## 6. Test plan summary + +* `pytest tests/services/test_model_update_service.py` — migrations, extraction, transitions, + threshold logic, fetch-failure matrix, feature-off path. +* New parser tests with HTML fixtures (no network in tests). +* `npm run test:js` for badge/helper changes in `ModelVersionsTab`. +* Manual UI verification by the user (per repo policy — no browser automation for layout). +* No live CivitAI calls in the test suite. + +## 7. Risks and mitigations + +| Risk | Mitigation | +| --- | --- | +| Page payload format changes (Next.js is migrating away from `__NEXT_DATA__` to the flight payload) | Parser is a single pure function behind a provider method; fixture test fails loudly at build time; parse failure is non-fatal and keeps the last known price | +| Cloudflare / UA sensitivity (verified: `civitai.com`, `civitai.red` and even no-UA all returned the payload for the sampled pages) | Reuse `downloader` (proxy, rate-limit coordinator, connectivity guard); detect challenge/absence and back off; never retry in a tight loop | +| Traffic/size (~200-270 KB per model) | Gated models only (~2%), one request per model, `price_check_ttl_hours`, force-refresh only on user action | +| False "free" alerts from gate-semantics ambiguity | P0 helper + explicit tests before any alert ships | +| Misleading price (list vs sale-adjusted; Blue Buzz is the same number but non-withdrawable) | Store both list and effective price; tooltip states the unit and Blue Buzz acceptance | +| Alerts are pull-only (no scheduler, no push channel today) | State it in the UI copy; treat background push as a separate follow-up, not a silent gap | + +## 8. Out of scope / follow-ups + +* Purchasing or downloading gated content. +* Generation-fee tracking beyond displaying `generation_price_buzz` (the public `mini/{id}` `fees` + field could feed a richer view later). +* A background/notification channel; today alerts appear when the user refreshes or opens the panel. +* Waiting on CivitAI to expose prices; P1–P3 stand alone. + +## 9. Open questions + +1. `{permanent:false, endsAt:null}` — gate or not? (P0; affects download blocking too.) +2. Should the threshold apply to versions already in the library (watching for a future re-buy) or + only to versions the user does not own? +3. Is a per-model threshold override needed in v1, or is one global threshold enough? + +--- + +## 10. What shipped (P0–P3) + +### 10.1 Answers to the open questions + +1. **`{permanent:false, endsAt:null}` is a gate.** Decided by evidence, not preference: the public v1 + API returns `null` for lapsed gates (`toPublicPaidAccessDto` filters on `isPaidAccessActive`), so a + non-null DTO is always an *active* gate; on the live model 1802980 that version reports + `canDownload: false` while its lapsed-tombstone siblings report `true`. The previous rule dropped + it, which is exactly the #1060 class of bug. +2. The global threshold applies to **every** gated version the update service tracks, in library or + not — the user may be watching a version they intend to buy later. +3. One global threshold in v1; a per-model override is still open. + +### 10.2 Files and seams + +| Area | Where | +| --- | --- | +| Shared gate semantics (`normalize_paid_access`, `is_gate_active`, `is_early_access_deadline_active`) | `py/utils/paid_access.py` (new) | +| Model page price parser (`parse_model_page_prices`) | `py/utils/civitai_page_prices.py` (new) | +| Price fetch | `CivitaiClient.get_model_prices` (`py/services/civitai_client.py`) | +| Provider seam | `ModelMetadataProvider.get_model_prices` default None; CivitAI impl; fallback + rate-limit wrappers (`py/services/model_metadata_provider.py`) | +| Columns, migration, transition + threshold logic, alerts query | `py/services/model_update_service.py` | +| Version payload, refresh `events`, `GET /api/lm/{type}/updates/price-alerts` | `py/routes/handlers/model_handlers.py`, `py/routes/model_route_registrar.py` | +| Badges, price chip, gate/price toast | `static/js/components/shared/ModelVersionsTab.js`, `static/js/utils/updateCheckHelpers.js`, `static/css/components/lora-modal/versions.css` | +| Settings | defaults in `py/services/settings_manager.py`; UI in `templates/components/modals/settings/library.html` + `static/js/managers/SettingsManager.js`; client defaults in `static/js/state/index.js` | +| API client | `getPriceAlerts` in `static/js/api/baseModelApi.js` + `priceAlerts` endpoint in `static/js/api/apiConfig.js` | + +### 10.3 Deviations from the plan + +* **`gate_lapsed_at` column added** (8th column): a transient event would leave a "became free" + marker invisible minutes later, so the lapse timestamp is persisted and the UI shows a `Free Now` + badge with it. +* **Alerts are edge-triggered, and first sight is silent.** When a price first appears under the + threshold, `price_alert_state` is stored and the version shows up in `price-alerts` immediately, + but no toast fires — otherwise switching the feature on would announce every cheap version in the + library at once. The toast fires when a price *crosses* down through the threshold. +* **No dedicated alerts panel.** The surfaces are: price chip + `Free Now` badge in the versions tab, + one combined toast per update check, and the `price-alerts` endpoint (GET, so the companion + extension can use it). A browse surface over the endpoint is a follow-up. +* **Prices are only fetched for models with a gate** (~2% of a typical library), one page request per + model, on `price_check_ttl_hours`, and immediately when the gate DTO changes. +* **The paid-download warning message does not include the price**: that path reads the model's + CivitAI metadata, which has no price, and looking one up would add a request to a failure path. + Left as a follow-up. + +### 10.4 Test coverage added + +* `tests/utils/test_paid_access.py` — gate shapes, activity, timestamps. +* `tests/utils/test_civitai_page_prices.py` + `tests/utils/fixtures/civitai_model_page_paid.html` — + parser, including sale-adjusted vs list price and lapsed tombstones. +* `tests/services/test_model_update_service.py` — migration from a pre-feature DB, column round-trip, + transitions, price capture on/off, failure retention, TTL, threshold edges, alerts query. +* `tests/services/test_civitai_client.py` — page fetch happy path, unusable payloads, rate limits. +* `tests/routes/test_model_update_handler.py` — alerts endpoint, gate events for non-updating records. + diff --git a/locales/de.json b/locales/de.json index 950fffa4..5592db89 100644 --- a/locales/de.json +++ b/locales/de.json @@ -201,7 +201,12 @@ "loading": "Prüfe auf {type}-Updates...", "success": "{count} Update(s) für {type} gefunden", "none": "Alle {type} sind auf dem neuesten Stand", - "error": "Fehler beim Prüfen auf {type}-Updates: {message}" + "error": "Fehler beim Prüfen auf {type}-Updates: {message}", + "gateEvents": { + "becameFree": "[TODO: Translate] {count} version(s) became free", + "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", + "newGate": "[TODO: Translate] {count} version(s) are now paid" + } }, "cleanupExampleImages": { "label": "Beispielbild-Ordner bereinigen", @@ -387,6 +392,7 @@ "downloadPathTemplates": "Download-Pfad-Vorlagen", "priorityTags": "Prioritäts-Tags", "versionScope": "Update-Markierungen", + "priceTracking": "[TODO: Translate] Buzz Price Tracking", "exampleImages": "Beispielbilder", "autoOrganize": "Auto-Organisierung", "metadata": "Metadaten", @@ -753,6 +759,16 @@ "label": "Bezahlte Updates ausblenden", "help": "Wenn aktiviert, zeigen Modelle mit nur bezahlten Updates kein 'Update verfügbar'-Badge an" }, + "priceTracking": { + "label": "[TODO: Translate] Buzz Price Tracking", + "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", + "enabled": "[TODO: Translate] Track Buzz prices for paid versions", + "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", + "threshold": "[TODO: Translate] Price alert threshold (Buzz)", + "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", + "refreshHours": "[TODO: Translate] Price refresh interval (hours)", + "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" + }, "licenseIcons": { "useNewStyle": "Aktualisierte Lizenzsymbole verwenden", "useNewStyleHelp": "Lizenzberechtigungen mit farbigen Indikatoren (neuer Stil) oder nur Einschränkungssymbolen (klassischer Stil) anzeigen. Orientiert sich am aktuellen CivitAI-Design." @@ -2008,6 +2024,11 @@ "earlyAccessTooltip": "Für diese Version ist derzeit CivitAI Early Access erforderlich", "paid": "Bezahlt", "paidTooltip": "Diese Version erfordert eine Zahlung zum Herunterladen", + "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", + "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", + "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", + "freeNow": "[TODO: Translate] Free Now", + "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "Ignoriert", "ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert", "onSiteOnly": "Nur On-Site", diff --git a/locales/en.json b/locales/en.json index 4450155d..a2c22aed 100644 --- a/locales/en.json +++ b/locales/en.json @@ -201,7 +201,12 @@ "loading": "Checking for {type} updates...", "success": "Found {count} update(s) for {type}s", "none": "All {type}s are up to date", - "error": "Failed to check for {type} updates: {message}" + "error": "Failed to check for {type} updates: {message}", + "gateEvents": { + "becameFree": "{count} version(s) became free", + "priceDrop": "{count} version(s) dropped below your price threshold", + "newGate": "{count} version(s) are now paid" + } }, "cleanupExampleImages": { "label": "Clean up example image folders", @@ -387,6 +392,7 @@ "downloadPathTemplates": "Download Path Templates", "priorityTags": "Priority Tags", "versionScope": "Version Scope", + "priceTracking": "Buzz Price Tracking", "exampleImages": "Example Images", "autoOrganize": "Auto-organize", "metadata": "Metadata", @@ -753,6 +759,16 @@ "label": "Hide Paid Updates", "help": "When enabled, models with only paid updates will not show 'Update available' badge" }, + "priceTracking": { + "label": "Buzz Price Tracking", + "help": "CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", + "enabled": "Track Buzz prices for paid versions", + "enabledHelp": "Show the price on paid and early access versions, and alert when a price drops below your threshold", + "threshold": "Price alert threshold (Buzz)", + "thresholdHelp": "Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", + "refreshHours": "Price refresh interval (hours)", + "refreshHoursHelp": "How long a stored price stays fresh before it is fetched again" + }, "licenseIcons": { "useNewStyle": "Use updated license icons", "useNewStyleHelp": "Display license permissions with colored indicators (new style) or restriction-only icons (classic style). Mirroring the current CivitAI design." @@ -2008,6 +2024,11 @@ "earlyAccessTooltip": "This version currently requires CivitAI early access", "paid": "Paid", "paidTooltip": "This version requires payment to download", + "priceSaleTooltip": "On sale, normally {list} Buzz", + "priceBlueBuzzTooltip": "Can also be paid with Blue Buzz", + "priceAlertTooltip": "{price} - below your price alert threshold", + "freeNow": "Free Now", + "freeNowTooltip": "This version no longer requires payment", "ignored": "Ignored", "ignoredTooltip": "Update notifications are disabled for this version", "onSiteOnly": "On-Site Only", diff --git a/locales/es.json b/locales/es.json index 3436ddee..cb39ffc5 100644 --- a/locales/es.json +++ b/locales/es.json @@ -201,7 +201,12 @@ "loading": "Buscando actualizaciones de {type}...", "success": "Se encontraron {count} actualización(es) para {type}", "none": "Todos los {type} están actualizados", - "error": "Error al buscar actualizaciones de {type}: {message}" + "error": "Error al buscar actualizaciones de {type}: {message}", + "gateEvents": { + "becameFree": "[TODO: Translate] {count} version(s) became free", + "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", + "newGate": "[TODO: Translate] {count} version(s) are now paid" + } }, "cleanupExampleImages": { "label": "Limpiar carpetas de imágenes de ejemplo", @@ -387,6 +392,7 @@ "downloadPathTemplates": "Plantillas de rutas de descarga", "priorityTags": "Etiquetas prioritarias", "versionScope": "Indicadores de actualización", + "priceTracking": "[TODO: Translate] Buzz Price Tracking", "exampleImages": "Imágenes de ejemplo", "autoOrganize": "Organización automática", "metadata": "Metadatos", @@ -753,6 +759,16 @@ "label": "Ocultar actualizaciones de pago", "help": "Cuando está activado, los modelos que solo tienen actualizaciones de pago no mostrarán la insignia de 'Actualización disponible'" }, + "priceTracking": { + "label": "[TODO: Translate] Buzz Price Tracking", + "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", + "enabled": "[TODO: Translate] Track Buzz prices for paid versions", + "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", + "threshold": "[TODO: Translate] Price alert threshold (Buzz)", + "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", + "refreshHours": "[TODO: Translate] Price refresh interval (hours)", + "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" + }, "licenseIcons": { "useNewStyle": "Usar iconos de licencia actualizados", "useNewStyleHelp": "Mostrar permisos de licencia con indicadores de color (nuevo estilo) o solo iconos de restricción (estilo clásico). Refleja el diseño actual de CivitAI." @@ -2008,6 +2024,11 @@ "earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de CivitAI", "paid": "De pago", "paidTooltip": "Esta versión requiere pago para descargarse", + "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", + "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", + "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", + "freeNow": "[TODO: Translate] Free Now", + "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "Ignorada", "ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión", "onSiteOnly": "Solo en Sitio", diff --git a/locales/fr.json b/locales/fr.json index 9dcdb70b..55c0af95 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -201,7 +201,12 @@ "loading": "Recherche de mises à jour pour {type}...", "success": "{count} mise(s) à jour trouvée(s) pour {type}", "none": "Tous les {type} sont à jour", - "error": "Échec de la vérification des mises à jour pour {type} : {message}" + "error": "Échec de la vérification des mises à jour pour {type} : {message}", + "gateEvents": { + "becameFree": "[TODO: Translate] {count} version(s) became free", + "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", + "newGate": "[TODO: Translate] {count} version(s) are now paid" + } }, "cleanupExampleImages": { "label": "Supprimer les dossiers d'exemples orphelins", @@ -387,6 +392,7 @@ "downloadPathTemplates": "Modèles de chemin de téléchargement", "priorityTags": "Tags prioritaires", "versionScope": "Indicateurs de mise à jour", + "priceTracking": "[TODO: Translate] Buzz Price Tracking", "exampleImages": "Images d'exemple", "autoOrganize": "Organisation automatique", "metadata": "Métadonnées", @@ -753,6 +759,16 @@ "label": "Masquer les mises à jour payantes", "help": "Lorsque cette option est activée, les modèles n'ayant que des mises à jour payantes n'affichent pas le badge « Mise à jour disponible »" }, + "priceTracking": { + "label": "[TODO: Translate] Buzz Price Tracking", + "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", + "enabled": "[TODO: Translate] Track Buzz prices for paid versions", + "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", + "threshold": "[TODO: Translate] Price alert threshold (Buzz)", + "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", + "refreshHours": "[TODO: Translate] Price refresh interval (hours)", + "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" + }, "licenseIcons": { "useNewStyle": "Utiliser les icônes de licence mises à jour", "useNewStyleHelp": "Afficher les permissions de licence avec des indicateurs colorés (nouveau style) ou des icônes de restriction uniquement (style classique). Reprend le design actuel de CivitAI." @@ -2008,6 +2024,11 @@ "earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé CivitAI", "paid": "Payant", "paidTooltip": "Cette version nécessite un paiement pour être téléchargée", + "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", + "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", + "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", + "freeNow": "[TODO: Translate] Free Now", + "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "Ignorée", "ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version", "onSiteOnly": "Uniquement sur Site", diff --git a/locales/he.json b/locales/he.json index 59e85aae..17cc11a1 100644 --- a/locales/he.json +++ b/locales/he.json @@ -201,7 +201,12 @@ "loading": "בודק עדכונים עבור {type}...", "success": "נמצאו {count} עדכונים עבור {type}", "none": "כל ה-{type} מעודכנים", - "error": "נכשל בבדיקת העדכונים עבור {type}: {message}" + "error": "נכשל בבדיקת העדכונים עבור {type}: {message}", + "gateEvents": { + "becameFree": "[TODO: Translate] {count} version(s) became free", + "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", + "newGate": "[TODO: Translate] {count} version(s) are now paid" + } }, "cleanupExampleImages": { "label": "נקה תיקיות תמונות דוגמה", @@ -387,6 +392,7 @@ "downloadPathTemplates": "תבניות נתיב הורדה", "priorityTags": "תגיות עדיפות", "versionScope": "תגי עדכון", + "priceTracking": "[TODO: Translate] Buzz Price Tracking", "exampleImages": "תמונות דוגמה", "autoOrganize": "ארגון אוטומטי", "metadata": "מטא-נתונים", @@ -753,6 +759,16 @@ "label": "הסתר עדכונים בתשלום", "help": "כשאפשרות זו מופעלת, מודלים עם עדכונים בתשלום בלבד לא יציגו את תגית 'עדכון זמין'" }, + "priceTracking": { + "label": "[TODO: Translate] Buzz Price Tracking", + "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", + "enabled": "[TODO: Translate] Track Buzz prices for paid versions", + "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", + "threshold": "[TODO: Translate] Price alert threshold (Buzz)", + "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", + "refreshHours": "[TODO: Translate] Price refresh interval (hours)", + "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" + }, "licenseIcons": { "useNewStyle": "השתמש בסמלי רישיון מעודכנים", "useNewStyleHelp": "הצג הרשאות רישיון עם מחוונים צבעוניים (סגנון חדש) או סמלי הגבלה בלבד (סגנון קלאסי). משקף את העיצוב העדכני של CivitAI." @@ -2008,6 +2024,11 @@ "earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של CivitAI", "paid": "בתשלום", "paidTooltip": "גרסה זו דורשת תשלום כדי להוריד", + "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", + "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", + "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", + "freeNow": "[TODO: Translate] Free Now", + "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "התעלם", "ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו", "onSiteOnly": "רק באתר", diff --git a/locales/ja.json b/locales/ja.json index df6d00f0..767c1d77 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -201,7 +201,12 @@ "loading": "{type} のアップデートを確認中...", "success": "{type} のアップデートが {count} 件見つかりました", "none": "すべての {type} は最新です", - "error": "{type} のアップデート確認に失敗しました: {message}" + "error": "{type} のアップデート確認に失敗しました: {message}", + "gateEvents": { + "becameFree": "[TODO: Translate] {count} version(s) became free", + "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", + "newGate": "[TODO: Translate] {count} version(s) are now paid" + } }, "cleanupExampleImages": { "label": "例画像フォルダをクリーンアップ", @@ -387,6 +392,7 @@ "downloadPathTemplates": "ダウンロードパステンプレート", "priorityTags": "優先タグ", "versionScope": "アップデートフラグ", + "priceTracking": "[TODO: Translate] Buzz Price Tracking", "exampleImages": "例画像", "autoOrganize": "自動整理", "metadata": "メタデータ", @@ -753,6 +759,16 @@ "label": "有料更新を非表示", "help": "有効にすると、有料の更新のみがあるモデルには「更新あり」バッジが表示されません" }, + "priceTracking": { + "label": "[TODO: Translate] Buzz Price Tracking", + "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", + "enabled": "[TODO: Translate] Track Buzz prices for paid versions", + "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", + "threshold": "[TODO: Translate] Price alert threshold (Buzz)", + "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", + "refreshHours": "[TODO: Translate] Price refresh interval (hours)", + "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" + }, "licenseIcons": { "useNewStyle": "更新されたライセンスアイコンを使用", "useNewStyleHelp": "カラーインジケーター付きでライセンス許可を表示(新スタイル)するか、制限のみのアイコンを表示(クラシックスタイル)します。現在のCivitAIデザインを反映しています。" @@ -2008,6 +2024,11 @@ "earlyAccessTooltip": "このバージョンは現在 CivitAI の早期アクセスが必要です", "paid": "有料", "paidTooltip": "このバージョンのダウンロードには支払いが必要です", + "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", + "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", + "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", + "freeNow": "[TODO: Translate] Free Now", + "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "無視中", "ignoredTooltip": "このバージョンの更新通知は無効です", "onSiteOnly": "サイト内のみ", diff --git a/locales/ko.json b/locales/ko.json index a9f5db33..60dda23e 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -201,7 +201,12 @@ "loading": "{type} 업데이트를 확인 중...", "success": "{type} 업데이트 {count}개를 찾았습니다", "none": "모든 {type}가 최신 상태입니다", - "error": "{type} 업데이트 확인 실패: {message}" + "error": "{type} 업데이트 확인 실패: {message}", + "gateEvents": { + "becameFree": "[TODO: Translate] {count} version(s) became free", + "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", + "newGate": "[TODO: Translate] {count} version(s) are now paid" + } }, "cleanupExampleImages": { "label": "예시 이미지 폴더 정리", @@ -387,6 +392,7 @@ "downloadPathTemplates": "다운로드 경로 템플릿", "priorityTags": "우선순위 태그", "versionScope": "업데이트 표시", + "priceTracking": "[TODO: Translate] Buzz Price Tracking", "exampleImages": "예시 이미지", "autoOrganize": "자동 정리", "metadata": "메타데이터", @@ -753,6 +759,16 @@ "label": "유료 업데이트 숨기기", "help": "활성화하면 유료 업데이트만 있는 모델에 '업데이트 가능' 배지가 표시되지 않습니다" }, + "priceTracking": { + "label": "[TODO: Translate] Buzz Price Tracking", + "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", + "enabled": "[TODO: Translate] Track Buzz prices for paid versions", + "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", + "threshold": "[TODO: Translate] Price alert threshold (Buzz)", + "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", + "refreshHours": "[TODO: Translate] Price refresh interval (hours)", + "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" + }, "licenseIcons": { "useNewStyle": "업데이트된 라이선스 아이콘 사용", "useNewStyleHelp": "색상 표시기가 있는 라이선스 권한(새 스타일) 또는 제한 전용 아이콘(클래식 스타일)을 표시합니다. 현재 CivitAI 디자인을 반영합니다." @@ -2008,6 +2024,11 @@ "earlyAccessTooltip": "이 버전은 현재 CivitAI 얼리 액세스가 필요합니다", "paid": "유료", "paidTooltip": "이 버전은 다운로드하려면 결제가 필요합니다", + "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", + "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", + "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", + "freeNow": "[TODO: Translate] Free Now", + "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "무시됨", "ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다", "onSiteOnly": "사이트 내 전용", diff --git a/locales/ru.json b/locales/ru.json index 992e64e4..cd6e93b1 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -201,7 +201,12 @@ "loading": "Проверка обновлений для {type}...", "success": "Найдено {count} обновлений для {type}", "none": "Все {type} актуальны", - "error": "Не удалось проверить обновления для {type}: {message}" + "error": "Не удалось проверить обновления для {type}: {message}", + "gateEvents": { + "becameFree": "[TODO: Translate] {count} version(s) became free", + "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", + "newGate": "[TODO: Translate] {count} version(s) are now paid" + } }, "cleanupExampleImages": { "label": "Очистить папки с примерами изображений", @@ -387,6 +392,7 @@ "downloadPathTemplates": "Шаблоны путей загрузки", "priorityTags": "Приоритетные теги", "versionScope": "Метки обновлений", + "priceTracking": "[TODO: Translate] Buzz Price Tracking", "exampleImages": "Примеры изображений", "autoOrganize": "Автоорганизация", "metadata": "Метаданные", @@ -753,6 +759,16 @@ "label": "Скрывать платные обновления", "help": "Если включено, у моделей, для которых доступны только платные обновления, не будет отображаться значок «Доступно обновление»" }, + "priceTracking": { + "label": "[TODO: Translate] Buzz Price Tracking", + "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", + "enabled": "[TODO: Translate] Track Buzz prices for paid versions", + "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", + "threshold": "[TODO: Translate] Price alert threshold (Buzz)", + "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", + "refreshHours": "[TODO: Translate] Price refresh interval (hours)", + "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" + }, "licenseIcons": { "useNewStyle": "Использовать обновлённые значки лицензии", "useNewStyleHelp": "Отображать разрешения лицензии с цветными индикаторами (новый стиль) или только значки ограничений (классический стиль). Соответствует текущему дизайну CivitAI." @@ -2008,6 +2024,11 @@ "earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ CivitAI", "paid": "Платная", "paidTooltip": "Скачивание этой версии платное", + "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", + "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", + "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", + "freeNow": "[TODO: Translate] Free Now", + "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "Игнорируется", "ignoredTooltip": "Уведомления об обновлениях для этой версии отключены", "onSiteOnly": "Только на Сайте", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 81532243..00203c38 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -201,7 +201,12 @@ "loading": "正在检查 {type} 更新...", "success": "找到 {count} 条 {type} 更新", "none": "所有 {type} 均已是最新版本", - "error": "检查 {type} 更新失败:{message}" + "error": "检查 {type} 更新失败:{message}", + "gateEvents": { + "becameFree": "[TODO: Translate] {count} version(s) became free", + "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", + "newGate": "[TODO: Translate] {count} version(s) are now paid" + } }, "cleanupExampleImages": { "label": "清理示例图片文件夹", @@ -387,6 +392,7 @@ "downloadPathTemplates": "下载路径模板", "priorityTags": "优先标签", "versionScope": "版本范围", + "priceTracking": "[TODO: Translate] Buzz Price Tracking", "exampleImages": "示例图片", "autoOrganize": "自动整理", "metadata": "元数据", @@ -753,6 +759,16 @@ "label": "隐藏付费更新", "help": "启用后,仅有付费更新的模型将不显示“有可用更新”徽标" }, + "priceTracking": { + "label": "[TODO: Translate] Buzz Price Tracking", + "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", + "enabled": "[TODO: Translate] Track Buzz prices for paid versions", + "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", + "threshold": "[TODO: Translate] Price alert threshold (Buzz)", + "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", + "refreshHours": "[TODO: Translate] Price refresh interval (hours)", + "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" + }, "licenseIcons": { "useNewStyle": "使用新版许可协议图标", "useNewStyleHelp": "以彩色指示器显示许可权限(新样式),或仅显示限制图标(经典样式)。与当前 CivitAI 设计保持一致。" @@ -2008,6 +2024,11 @@ "earlyAccessTooltip": "此版本当前需要 CivitAI 抢先体验权限", "paid": "付费", "paidTooltip": "此版本需要付费后才能下载", + "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", + "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", + "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", + "freeNow": "[TODO: Translate] Free Now", + "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "已忽略", "ignoredTooltip": "此版本已关闭更新通知", "onSiteOnly": "仅站内生成", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index ca730e0b..00f58703 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -201,7 +201,12 @@ "loading": "正在檢查 {type} 更新...", "success": "找到 {count} 個 {type} 更新", "none": "所有 {type} 都是最新版本", - "error": "檢查 {type} 更新失敗:{message}" + "error": "檢查 {type} 更新失敗:{message}", + "gateEvents": { + "becameFree": "[TODO: Translate] {count} version(s) became free", + "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", + "newGate": "[TODO: Translate] {count} version(s) are now paid" + } }, "cleanupExampleImages": { "label": "清理範例圖片資料夾", @@ -387,6 +392,7 @@ "downloadPathTemplates": "下載路徑範本", "priorityTags": "優先標籤", "versionScope": "版本範圍", + "priceTracking": "[TODO: Translate] Buzz Price Tracking", "exampleImages": "範例圖片", "autoOrganize": "自動整理", "metadata": "中繼資料", @@ -753,6 +759,16 @@ "label": "隱藏付費更新", "help": "啟用後,只有付費更新的模型將不會顯示「有可用更新」徽章" }, + "priceTracking": { + "label": "[TODO: Translate] Buzz Price Tracking", + "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", + "enabled": "[TODO: Translate] Track Buzz prices for paid versions", + "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", + "threshold": "[TODO: Translate] Price alert threshold (Buzz)", + "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", + "refreshHours": "[TODO: Translate] Price refresh interval (hours)", + "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" + }, "licenseIcons": { "useNewStyle": "使用新版許可協議圖標", "useNewStyleHelp": "以彩色指示器顯示許可權限(新樣式),或僅顯示限制圖標(經典樣式)。與當前 CivitAI 設計保持一致。" @@ -2008,6 +2024,11 @@ "earlyAccessTooltip": "此版本目前需要 CivitAI 搶先體驗權限", "paid": "付費", "paidTooltip": "此版本需要付費才能下載", + "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", + "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", + "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", + "freeNow": "[TODO: Translate] Free Now", + "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "已忽略", "ignoredTooltip": "此版本已關閉更新通知", "onSiteOnly": "僅站內生成", diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index d2fe867b..2640a6e0 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -50,6 +50,7 @@ from ...services.errors import RateLimitError, ResourceNotFoundError from ...utils.civitai_utils import resolve_license_payload from ...utils.file_utils import calculate_sha256 from ...utils.metadata_manager import MetadataManager +from ...utils.paid_access import is_early_access_deadline_active from ...utils.sidecar_paths import get_metadata_path from ...utils.url_utils import relative_root_prefix @@ -2941,6 +2942,20 @@ class ModelUpdateHandler: same_base_scope = self._uses_same_base_update_scope() + # Gate/price transitions are reported for every refreshed model, not only + # for the ones that qualify as updates: "this version became free" matters + # for a version the user already has, which never shows up as an update. + events = [] + for record in records.values(): + for event in getattr(record, "events", None) or []: + events.append( + { + "modelId": record.model_id, + "modelType": record.model_type, + **event, + } + ) + serialized_records = [] for record in records.values(): has_update_fn = getattr(record, "has_update", None) @@ -2962,6 +2977,7 @@ class ModelUpdateHandler: { "success": True, "records": serialized_records, + "events": events, } ) @@ -3054,6 +3070,46 @@ class ModelUpdateHandler: {"success": True, "record": self._serialize_record(record)} ) + async def get_price_alerts(self, request: web.Request) -> web.Response: + """List gated versions whose stored price crosses the alert threshold. + + Backed by the persisted alert state, so it matches the badges; the + threshold (and whether price tracking runs at all) comes from settings. + """ + + try: + limit = int(request.query.get("limit") or 200) + except (TypeError, ValueError): + limit = 200 + + alerts = await self._update_service.get_price_alerts( + self._service.model_type, limit=limit + ) + threshold = 0 + enabled = False + if self._settings is not None: + try: + threshold = int( + self._settings.get("price_alert_threshold_buzz", 0) or 0 + ) + except (TypeError, ValueError): + threshold = 0 + try: + enabled = bool( + self._settings.get("price_tracking_enabled", False) + ) + except Exception: + enabled = False + + return web.json_response( + { + "success": True, + "enabled": enabled, + "thresholdBuzz": threshold, + "alerts": alerts, + } + ) + async def get_model_versions(self, request: web.Request) -> web.Response: model_id = self._normalize_model_id(request.match_info.get("model_id")) if model_id is None: @@ -3391,6 +3447,7 @@ class ModelUpdateHandler: hide_early_access=hide_early_access, hide_paid=hide_paid, ), + "events": list(getattr(record, "events", None) or []), "versions": [ self._serialize_version(version, context.get(version.version_id)) for version in record.versions @@ -3414,16 +3471,11 @@ class ModelUpdateHandler: if getattr(version, "is_paid", False) and not version.early_access_ends_at: is_early_access = False elif version.early_access_ends_at: - try: - from datetime import datetime, timezone - - ea_date = datetime.fromisoformat( - version.early_access_ends_at.replace("Z", "+00:00") - ) - is_early_access = ea_date > datetime.now(timezone.utc) - except (ValueError, AttributeError): - # If date parsing fails, treat as active EA (conservative) - is_early_access = True + # Shared with the update service and the download gate so the badge, + # the update filter and the download warning cannot disagree. + is_early_access = is_early_access_deadline_active( + version.early_access_ends_at + ) elif getattr(version, "is_early_access", False): # Fallback to basic EA flag from bulk API is_early_access = True @@ -3450,6 +3502,16 @@ class ModelUpdateHandler: "usageControl": version.usage_control, "isPaid": bool(getattr(version, "is_paid", False)), "paidAccess": paid_access_payload, + # Set when a version that used to be gated became free, so the UI can + # keep showing "Free" long after the transition. + "gateLapsedAt": getattr(version, "gate_lapsed_at", None), + "priceBuzz": getattr(version, "price_buzz", None), + "listPriceBuzz": getattr(version, "list_price_buzz", None), + "generationPriceBuzz": getattr(version, "generation_price_buzz", None), + "acceptsBlueBuzz": bool(getattr(version, "accepts_blue_buzz", False)), + "priceSaleEndsAt": getattr(version, "price_sale_ends_at", None), + "priceCheckedAt": getattr(version, "price_checked_at", None), + "priceAlert": bool(getattr(version, "price_alert_state", False)), "filePath": context.get("file_path"), "fileName": context.get("file_name"), # Weight-file variant count (None when unknown); lets the UI hide @@ -3612,5 +3674,6 @@ class ModelHandlerSet: "set_version_update_ignore": self.updates.set_version_update_ignore, "get_model_update_status": self.updates.get_model_update_status, "get_model_versions": self.updates.get_model_versions, + "get_price_alerts": self.updates.get_price_alerts, "cancel_task": self.query.cancel_task, } diff --git a/py/routes/model_route_registrar.py b/py/routes/model_route_registrar.py index 066b3fa3..d62812be 100644 --- a/py/routes/model_route_registrar.py +++ b/py/routes/model_route_registrar.py @@ -110,6 +110,9 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition( "GET", "/api/lm/{prefix}/updates/versions/{model_id}", "get_model_versions" ), + RouteDefinition( + "GET", "/api/lm/{prefix}/updates/price-alerts", "get_price_alerts" + ), RouteDefinition("POST", "/api/lm/download-model", "download_model"), RouteDefinition("GET", "/api/lm/download-model-get", "download_model_get"), RouteDefinition("GET", "/api/lm/cancel-download-get", "cancel_download_get"), diff --git a/py/services/civitai_client.py b/py/services/civitai_client.py index b668bfd9..f0ddffce 100644 --- a/py/services/civitai_client.py +++ b/py/services/civitai_client.py @@ -20,7 +20,8 @@ from .model_metadata_provider import ( ) from .downloader import get_downloader from .errors import RateLimitError, ResourceNotFoundError -from ..utils.civitai_utils import resolve_license_payload +from ..utils.civitai_utils import build_civitai_model_page_url, resolve_license_payload +from ..utils.civitai_page_prices import parse_model_page_prices from ..utils.constants import MODEL_WEIGHT_FILE_TYPES, is_empty_placeholder_hash logger = logging.getLogger(__name__) @@ -383,6 +384,72 @@ class CivitaiClient: logger.error(f"Error fetching model versions in bulk: {exc}") return None + async def get_model_prices( + self, model_id: int + ) -> Optional[Dict[int, Dict[str, Any]]]: + """Fetch per-version buzz prices for one model from its public page. + + CivitAI's public REST API deliberately omits prices, but the model page + embeds the site's own ``model.getById`` result (including + ``paidAccess.terms``) in its server-rendered payload. One request covers + every version of the model. Returns ``{version_id: price fields}``, an + empty dict when the page loads but lists no gated version, or None when + the page could not be read or understood — callers keep any stored price. + + This is a public anonymous page fetch: no API key and no internal + endpoint, so a failure here must never fail the update check itself. + """ + + try: + normalized_id = int(model_id) + except (TypeError, ValueError): + return None + + url = build_civitai_model_page_url(normalized_id, host=self._page_host()) + if not url: + return None + + try: + success, result = await self._make_request( + "GET", + url, + use_auth=False, + custom_headers={"Accept": "text/html"}, + ) + except RateLimitError: + # The shared rate-limit gate already recorded it; skip this model. + raise + except Exception as exc: # pragma: no cover - defensive + logger.debug("Failed to fetch model page for %s: %s", model_id, exc) + return None + + if not success or not isinstance(result, str): + logger.debug( + "No model page payload for %s (success=%s, type=%s)", + model_id, + success, + type(result).__name__, + ) + return None + + prices = parse_model_page_prices(result) + if prices is None: + logger.debug( + "Model page for %s carried no usable price payload", model_id + ) + return prices + + def _page_host(self) -> Optional[str]: + """Resolve the page host from the ``civitai_host`` setting.""" + + try: + from .settings_manager import get_settings_manager + + settings = get_settings_manager() + return settings.get("civitai_host") if settings else None + except Exception: + return None + async def get_model_version( self, model_id: int | None = None, version_id: int | None = None ) -> Optional[Dict[str, Any]]: diff --git a/py/services/model_metadata_provider.py b/py/services/model_metadata_provider.py index 9676c6a3..ef4d736d 100644 --- a/py/services/model_metadata_provider.py +++ b/py/services/model_metadata_provider.py @@ -183,6 +183,17 @@ class ModelMetadataProvider(ABC): """ return None + async def get_model_prices( + self, model_id: int + ) -> Optional[Dict[int, Dict[str, Any]]]: + """Fetch per-version buzz prices for a model, when the provider has them. + + CivitAI publishes prices only inside its public model page payload; + providers that cannot read it (CivArchive, SQLite, OpenModelDB) keep the + default of None, which callers treat as "no price information". + """ + return None + class CivitaiModelMetadataProvider(ModelMetadataProvider): """Provider that uses Civitai API for metadata""" @@ -222,6 +233,11 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider): ) -> Optional[Dict[str, Any]]: return await self.client.get_version_file_mini(version_id, file_id) + async def get_model_prices( + self, model_id: int + ) -> Optional[Dict[int, Dict[str, Any]]]: + return await self.client.get_model_prices(model_id) + class CivArchiveModelMetadataProvider(ModelMetadataProvider): """Provider that uses CivArchive API for metadata""" @@ -783,6 +799,34 @@ class FallbackMetadataProvider(ModelMetadataProvider): def _iter_providers(self): return zip(self.providers, self._provider_labels) + async def get_model_prices( + self, model_id: int + ) -> Optional[Dict[int, Dict[str, Any]]]: + rate_limited = False + for provider, label in self._iter_providers(): + if rate_limited and label not in _LOCAL_PROVIDER_LABELS: + continue + try: + result = await self._call_with_rate_limit( + label, + provider.get_model_prices, + model_id, + ) + if result: + return result + except RateLimitError as exc: + rate_limited = True + logger.warning( + "Provider %s is rate-limited (retry_after=%.0fs); not failing over to other network providers", + label, + exc.retry_after or 0, + ) + continue + except Exception as e: + logger.debug("Provider %s failed for get_model_prices: %s", label, e) + continue + return None + def excluding(self, labels: "frozenset[str] | set[str]") -> "FallbackMetadataProvider": """Return a copy of this chain without the providers named in *labels*. @@ -907,6 +951,15 @@ class RateLimitRetryingProvider(ModelMetadataProvider): file_id, ) + async def get_model_prices( + self, model_id: int + ) -> Optional[Dict[int, Dict[str, Any]]]: + return await self._rate_limit_helper.run( + self._label, + self._provider.get_model_prices, + model_id, + ) + class ModelMetadataProviderManager: """Manager for selecting and using model metadata providers""" diff --git a/py/services/model_update_service.py b/py/services/model_update_service.py index 5040eea1..b2797689 100644 --- a/py/services/model_update_service.py +++ b/py/services/model_update_service.py @@ -11,7 +11,7 @@ import logging import os import sqlite3 import time -from dataclasses import dataclass, replace +from dataclasses import dataclass, field, replace from datetime import datetime, timezone from typing import Any, Dict, Iterable, Iterator, List, Mapping, Optional, Sequence @@ -28,6 +28,17 @@ from ..utils.preview_selection import resolve_mature_threshold, select_preview_m logger = logging.getLogger(__name__) +# Version fields a price payload may set (see py/utils/civitai_page_prices.py). +_PRICE_FIELD_NAMES = frozenset( + { + "price_buzz", + "list_price_buzz", + "generation_price_buzz", + "accepts_blue_buzz", + "price_sale_ends_at", + } +) + def _normalize_int(value) -> Optional[int]: """Safely convert a value to an integer.""" @@ -40,6 +51,16 @@ def _normalize_int(value) -> Optional[int]: return None +def _format_utc_timestamp(value: float) -> str: + """Format a POSIX timestamp the way CivitAI serializes its timestamps.""" + + return ( + datetime.fromtimestamp(value, tz=timezone.utc) + .isoformat() + .replace("+00:00", "Z") + ) + + def _normalize_string(value) -> Optional[str]: """Return a stripped string or None if the value is empty.""" @@ -86,6 +107,23 @@ class ModelVersionRecord: # e.g. records persisted before this field existed or locally-synthesized # entries). Mirrors the frontend isModelWeightFile() filter. file_count: Optional[int] = None + # When a version that used to carry a paid gate stopped carrying one. Kept so + # the UI can mark a version "free" long after the transition happened; cleared + # again if the version is gated once more. + gate_lapsed_at: Optional[str] = None + # Download price in Buzz for the gated version, and friends. All None until a + # price refresh succeeds (see the price tracking feature); `price_buzz` is the + # effective price a buyer pays now (sale-adjusted) and `list_price_buzz` the + # undiscounted stored price. + price_buzz: Optional[int] = None + list_price_buzz: Optional[int] = None + generation_price_buzz: Optional[int] = None + accepts_blue_buzz: bool = False + price_sale_ends_at: Optional[str] = None + price_checked_at: Optional[float] = None + # Last computed "price alert threshold hit" state, used to detect the edge + # (0 -> 1) that fires a one-shot price_drop event. + price_alert_state: bool = False @dataclass @@ -97,6 +135,12 @@ class ModelUpdateRecord: versions: List[ModelVersionRecord] last_checked_at: Optional[float] should_ignore_model: bool + # Gate-state and price transitions observed during the refresh that produced + # this record. Derived, never persisted: a record read back from SQLite has an + # empty list. Each entry is {"versionId", "kind", "versionName", + # "isInLibrary", "priceBuzz"?} with kind in + # {"new_gate", "became_free", "price_drop"}. + events: List[Dict[str, Any]] = field(default_factory=list) @property def largest_version_id(self) -> Optional[int]: @@ -322,6 +366,14 @@ class ModelUpdateService: paid_access TEXT, is_paid INTEGER NOT NULL DEFAULT 0, file_count INTEGER, + gate_lapsed_at TEXT, + price_buzz INTEGER, + list_price_buzz INTEGER, + generation_price_buzz INTEGER, + accepts_blue_buzz INTEGER NOT NULL DEFAULT 0, + price_sale_ends_at TEXT, + price_checked_at REAL, + price_alert_state INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (model_id, version_id), FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE ); @@ -573,6 +625,38 @@ class ModelUpdateService: "ALTER TABLE model_update_versions " "ADD COLUMN file_count INTEGER" ), + "gate_lapsed_at": ( + "ALTER TABLE model_update_versions " + "ADD COLUMN gate_lapsed_at TEXT" + ), + "price_buzz": ( + "ALTER TABLE model_update_versions " + "ADD COLUMN price_buzz INTEGER" + ), + "list_price_buzz": ( + "ALTER TABLE model_update_versions " + "ADD COLUMN list_price_buzz INTEGER" + ), + "generation_price_buzz": ( + "ALTER TABLE model_update_versions " + "ADD COLUMN generation_price_buzz INTEGER" + ), + "accepts_blue_buzz": ( + "ALTER TABLE model_update_versions " + "ADD COLUMN accepts_blue_buzz INTEGER NOT NULL DEFAULT 0" + ), + "price_sale_ends_at": ( + "ALTER TABLE model_update_versions " + "ADD COLUMN price_sale_ends_at TEXT" + ), + "price_checked_at": ( + "ALTER TABLE model_update_versions " + "ADD COLUMN price_checked_at REAL" + ), + "price_alert_state": ( + "ALTER TABLE model_update_versions " + "ADD COLUMN price_alert_state INTEGER NOT NULL DEFAULT 0" + ), } for column, statement in migrations.items(): @@ -677,6 +761,14 @@ class ModelUpdateService: paid_access TEXT, is_paid INTEGER NOT NULL DEFAULT 0, file_count INTEGER, + gate_lapsed_at TEXT, + price_buzz INTEGER, + list_price_buzz INTEGER, + generation_price_buzz INTEGER, + accepts_blue_buzz INTEGER NOT NULL DEFAULT 0, + price_sale_ends_at TEXT, + price_checked_at REAL, + price_alert_state INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (model_id, version_id), FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE ) @@ -699,6 +791,14 @@ class ModelUpdateService: "paid_access", "is_paid", "file_count", + "gate_lapsed_at", + "price_buzz", + "list_price_buzz", + "generation_price_buzz", + "accepts_blue_buzz", + "price_sale_ends_at", + "price_checked_at", + "price_alert_state", ] defaults = { "sort_index": "0", @@ -714,6 +814,14 @@ class ModelUpdateService: "paid_access": "NULL", "is_paid": "0", "file_count": "NULL", + "gate_lapsed_at": "NULL", + "price_buzz": "NULL", + "list_price_buzz": "NULL", + "generation_price_buzz": "NULL", + "accepts_blue_buzz": "0", + "price_sale_ends_at": "NULL", + "price_checked_at": "NULL", + "price_alert_state": "0", } select_parts = [] @@ -1094,6 +1202,73 @@ class ModelUpdateService: async with self._lock: return self._get_records_bulk(model_type, normalized_ids) + async def get_price_alerts( + self, model_type: str, limit: int = 200 + ) -> List[Dict[str, Any]]: + """Return the gated versions whose price currently crosses the threshold. + + Reads the persisted ``price_alert_state`` rather than recomputing, so the + list matches the badges the user sees. Cheapest versions first, since that + is what a price watch is for. + """ + + try: + normalized_limit = max(1, min(int(limit), 1000)) + except (TypeError, ValueError): + normalized_limit = 200 + + with self._connect() as conn: + rows = conn.execute( + """ + SELECT v.model_id, + v.version_id, + v.name, + v.price_buzz, + v.list_price_buzz, + v.accepts_blue_buzz, + v.price_sale_ends_at, + v.price_checked_at, + v.is_in_library, + v.early_access_ends_at, + v.paid_access, + v.is_paid, + v.is_early_access, + s.model_type + FROM model_update_versions v + JOIN model_update_status s ON s.model_id = v.model_id + WHERE v.price_alert_state = 1 + AND v.should_ignore = 0 + AND s.should_ignore_model = 0 + AND s.model_type = ? + ORDER BY v.price_buzz ASC, v.model_id ASC, v.version_id ASC + LIMIT ? + """, + (model_type, normalized_limit), + ).fetchall() + + return [ + { + "modelId": int(row["model_id"]), + "modelType": row["model_type"], + "versionId": int(row["version_id"]), + "versionName": row["name"], + "priceBuzz": _normalize_int(row["price_buzz"]), + "listPriceBuzz": _normalize_int(row["list_price_buzz"]), + "acceptsBlueBuzz": bool(row["accepts_blue_buzz"]), + "priceSaleEndsAt": row["price_sale_ends_at"], + "priceCheckedAt": ( + float(row["price_checked_at"]) + if row["price_checked_at"] is not None + else None + ), + "isInLibrary": bool(row["is_in_library"]), + "earlyAccessEndsAt": row["early_access_ends_at"], + "isPaid": bool(row["is_paid"]), + "isEarlyAccess": bool(row["is_early_access"]), + } + for row in rows + ] + async def _refresh_single_model( self, model_type: str, @@ -1168,6 +1343,20 @@ class ModelUpdateService: elif fallback_attempted and fallback_error_message is None: fallback_error_message = "no response" + # Optional price capture. Runs after the version list is known so only the + # models that actually carry a gate pay for a second (page) request, and + # stays outside the lock along with the other network I/O. + if ( + refresh_succeeded + and isinstance(fetched_versions, list) + and fetched_versions + and metadata_provider is not None + and self._should_fetch_prices(fetched_versions, existing) + ): + fetched_versions = await self._apply_model_prices( + metadata_provider, model_id, fetched_versions + ) + if fallback_attempted: if refresh_succeeded and isinstance(fetched_versions, list): logger.info( @@ -1631,10 +1820,71 @@ class ModelUpdateService: existing_map = {version.version_id: version for version in existing.versions} if existing else {} versions: List[ModelVersionRecord] = [] + events: List[Dict[str, Any]] = [] seen_ids: set[int] = set() + lapse_timestamp = _format_utc_timestamp(timestamp) for index, remote_version in enumerate(remote_versions): version_id = remote_version.version_id seen_ids.add(version_id) + existing_version = existing_map.get(version_id) + is_gated = self._has_structural_gate(remote_version) + was_gated = ( + self._has_structural_gate(existing_version) + if existing_version is not None + else None + ) + should_ignore = ignore_map.get(version_id, remote_version.should_ignore) + + # `gate_lapsed_at` survives refreshes so a version that became free keeps + # its "free since" marker, and is cleared if the gate comes back. + if is_gated: + gate_lapsed_at = None + elif was_gated: + gate_lapsed_at = lapse_timestamp + elif existing_version is not None: + gate_lapsed_at = existing_version.gate_lapsed_at + else: + gate_lapsed_at = None + + price_fields = self._price_fields_for( + remote_version, existing_version, is_gated=is_gated + ) + price_fields["price_alert_state"] = self._evaluate_price_alert( + price_fields.get("price_buzz") + ) + + if ( + existing_version is not None + and not should_ignore + and was_gated != is_gated + ): + events.append( + { + "versionId": version_id, + "kind": "new_gate" if is_gated else "became_free", + "versionName": remote_version.name, + "isInLibrary": version_id in effective_local_set, + } + ) + + # Only the edge fires: a version that keeps sitting under the threshold + # must not re-announce itself on every refresh. + if ( + existing_version is not None + and not should_ignore + and price_fields["price_alert_state"] + and not existing_version.price_alert_state + ): + events.append( + { + "versionId": version_id, + "kind": "price_drop", + "versionName": remote_version.name, + "isInLibrary": version_id in effective_local_set, + "priceBuzz": price_fields.get("price_buzz"), + } + ) + versions.append( ModelVersionRecord( version_id=version_id, @@ -1644,7 +1894,7 @@ class ModelUpdateService: size_bytes=remote_version.size_bytes, preview_url=remote_version.preview_url or preview_map.get(version_id), is_in_library=version_id in effective_local_set, - should_ignore=ignore_map.get(version_id, remote_version.should_ignore), + should_ignore=should_ignore, sort_index=sort_map.get(version_id, index), early_access_ends_at=remote_version.early_access_ends_at, is_early_access=remote_version.is_early_access, @@ -1656,6 +1906,8 @@ class ModelUpdateService: if remote_version.file_count is not None else file_count_map.get(version_id) ), + gate_lapsed_at=gate_lapsed_at, + **price_fields, ) ) @@ -1694,8 +1946,192 @@ class ModelUpdateService: versions=self._sorted_versions(versions), last_checked_at=timestamp, should_ignore_model=existing.should_ignore_model if existing else False, + events=events, ) + @staticmethod + def _has_structural_gate(version: Optional[ModelVersionRecord]) -> bool: + """True when a version carries a paid gate, ignoring the clock. + + Transition detection uses this rather than a time-based check so a gate + that lapsed since the previous refresh still reads as "was gated" — the + public API reports no ``paidAccess`` at all once a gate expires, so the + time-based helper alone would silently see "free before, free now". + """ + + if version is None: + return False + return bool(version.paid_access) or version.is_paid or version.is_early_access + + @staticmethod + def _price_fields_for( + remote_version: ModelVersionRecord, + existing_version: Optional[ModelVersionRecord], + *, + is_gated: bool, + ) -> Dict[str, Any]: + """Resolve the price columns for a refreshed version. + + A refresh only carries prices when a price fetch actually ran (recognizable + by ``price_checked_at``), so otherwise the previously stored price is kept — + but only while the version is still gated: once it is free the stored + numbers are stale and must not keep firing alerts. + """ + + if not is_gated: + return { + "price_buzz": None, + "list_price_buzz": None, + "generation_price_buzz": None, + "accepts_blue_buzz": False, + "price_sale_ends_at": None, + "price_checked_at": None, + "price_alert_state": False, + } + + if remote_version.price_checked_at is not None: + source = remote_version + else: + source = existing_version or remote_version + + return { + "price_buzz": source.price_buzz, + "list_price_buzz": source.list_price_buzz, + "generation_price_buzz": source.generation_price_buzz, + "accepts_blue_buzz": source.accepts_blue_buzz, + "price_sale_ends_at": source.price_sale_ends_at, + "price_checked_at": source.price_checked_at, + "price_alert_state": source.price_alert_state, + } + + def _price_tracking_enabled(self) -> bool: + """Whether the optional (page-fetching) price capture is switched on.""" + + try: + return bool(self._settings.get("price_tracking_enabled", False)) + except Exception: + return False + + def _price_alert_threshold_buzz(self) -> int: + """Buzz threshold for a price alert; 0 means "free only".""" + + try: + value = int(self._settings.get("price_alert_threshold_buzz", 0) or 0) + except (TypeError, ValueError): + return 0 + return max(0, value) + + def _evaluate_price_alert(self, price_buzz: Optional[int]) -> bool: + """Whether a version's price crosses the user's alert threshold. + + Free versions never carry a price, so a threshold of 0 alerts only via the + ``became_free`` event — which is exactly the "or free" half of the request. + """ + + if not self._price_tracking_enabled(): + return False + if price_buzz is None: + return False + return price_buzz <= self._price_alert_threshold_buzz() + + def _price_check_ttl_seconds(self) -> float: + try: + hours = float(self._settings.get("price_check_ttl_hours", 24)) + except (TypeError, ValueError): + hours = 24.0 + if hours <= 0: + hours = 24.0 + return hours * 3600.0 + + def _should_fetch_prices( + self, + remote_versions: Sequence[ModelVersionRecord], + existing: Optional[ModelUpdateRecord], + ) -> bool: + """Whether this model needs a price fetch this round. + + Only gated versions are considered, so an unmodified free library pays + nothing. A stored price is refreshed once its TTL lapses, and immediately + when the gate itself changed (a new end date or sale window is a reason to + believe the price moved). + """ + + if not self._price_tracking_enabled(): + return False + + existing_map = ( + {version.version_id: version for version in existing.versions} + if existing + else {} + ) + now = time.time() + ttl = self._price_check_ttl_seconds() + + for remote_version in remote_versions: + if not self._has_structural_gate(remote_version): + continue + stored = existing_map.get(remote_version.version_id) + if stored is None: + return True + if stored.price_checked_at is None: + return True + if (now - stored.price_checked_at) >= ttl: + return True + if (stored.paid_access or None) != (remote_version.paid_access or None): + return True + + return False + + async def _apply_model_prices( + self, + metadata_provider, + model_id: int, + versions: Sequence[ModelVersionRecord], + ) -> List[ModelVersionRecord]: + """Attach freshly fetched prices to the versions that have them. + + Never raises for a provider problem: price tracking is a convenience, and + an unreadable page (or a provider that has no prices at all) must leave + the update check exactly as it was. + """ + + getter = getattr(metadata_provider, "get_model_prices", None) + if not callable(getter): + return list(versions) + + try: + prices = await getter(model_id) + except RateLimitError: + raise + except Exception as exc: # pragma: no cover - defensive + logger.debug("Price fetch failed for model %s: %s", model_id, exc) + return list(versions) + + if not isinstance(prices, Mapping) or not prices: + return list(versions) + + checked_at = time.time() + enriched: List[ModelVersionRecord] = [] + for version in versions: + fields = prices.get(version.version_id) + if not isinstance(fields, Mapping): + # Not priced (or not understood): leave the stored values alone so + # the next refresh retries instead of recording a blank price. + enriched.append(version) + continue + recognized = { + key: value + for key, value in fields.items() + if key in _PRICE_FIELD_NAMES + } + if not recognized: + enriched.append(version) + continue + enriched.append( + replace(version, price_checked_at=checked_at, **recognized) + ) + return enriched + def _sorted_versions(self, versions: Sequence[ModelVersionRecord]) -> List[ModelVersionRecord]: ordered = sorted(versions, key=lambda version: (version.sort_index, version.version_id)) normalized: List[ModelVersionRecord] = [] @@ -1936,7 +2372,9 @@ 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, paid_access, is_paid, file_count + is_early_access, usage_control, paid_access, is_paid, file_count, + gate_lapsed_at, price_buzz, list_price_buzz, generation_price_buzz, + accepts_blue_buzz, price_sale_ends_at, price_checked_at, price_alert_state FROM model_update_versions WHERE model_id IN ({placeholders}) ORDER BY model_id ASC, sort_index ASC, version_id ASC @@ -1968,6 +2406,18 @@ class ModelUpdateService: paid_access=row["paid_access"], is_paid=bool(row["is_paid"]), file_count=_normalize_int(row["file_count"]), + gate_lapsed_at=row["gate_lapsed_at"], + price_buzz=_normalize_int(row["price_buzz"]), + list_price_buzz=_normalize_int(row["list_price_buzz"]), + generation_price_buzz=_normalize_int(row["generation_price_buzz"]), + accepts_blue_buzz=bool(row["accepts_blue_buzz"]), + price_sale_ends_at=row["price_sale_ends_at"], + price_checked_at=( + float(row["price_checked_at"]) + if row["price_checked_at"] is not None + else None + ), + price_alert_state=bool(row["price_alert_state"]), ) ) @@ -2030,8 +2480,10 @@ class ModelUpdateService: 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, paid_access, is_paid, file_count - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + is_early_access, usage_control, paid_access, is_paid, file_count, + gate_lapsed_at, price_buzz, list_price_buzz, generation_price_buzz, + accepts_blue_buzz, price_sale_ends_at, price_checked_at, price_alert_state + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( version.version_id, @@ -2050,6 +2502,14 @@ class ModelUpdateService: paid_access_value, 1 if version.is_paid else 0, version.file_count, + version.gate_lapsed_at, + version.price_buzz, + version.list_price_buzz, + version.generation_price_buzz, + 1 if version.accepts_blue_buzz else 0, + version.price_sale_ends_at, + version.price_checked_at, + 1 if version.price_alert_state else 0, ), ) conn.commit() diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index 594844ab..bc665810 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -129,6 +129,11 @@ DEFAULT_SETTINGS: Dict[str, Any] = { "model_card_footer_action": "replace_preview", "show_version_on_card": True, "version_grouping": "same_base", + # Buzz price tracking for paid/early-access versions. Opt-in because reading a + # price costs one extra (public) model-page request per gated model. + "price_tracking_enabled": False, + "price_alert_threshold_buzz": 0, # 0 = alert on "became free" only + "price_check_ttl_hours": 24, "auto_organize_exclusions": [], "metadata_refresh_skip_paths": [], "skip_previously_downloaded_model_versions": False, diff --git a/py/utils/civitai_page_prices.py b/py/utils/civitai_page_prices.py new file mode 100644 index 00000000..190b9cfd --- /dev/null +++ b/py/utils/civitai_page_prices.py @@ -0,0 +1,257 @@ +"""Extract buzz prices from a public CivitAI model page. + +CivitAI's public REST API deliberately omits prices: ``paidAccess`` is trimmed to +``{permanent, endsAt}`` because "pricing belongs to the purchase flow" (see the +upstream ``toPublicPaidAccessDto``). The model *page*, however, ships the site's +own ``model.getById`` result inside its server-rendered Next.js payload, and that +payload carries the full ``paidAccess.terms`` — download price, generation price, +sale and the Blue Buzz flag. + +Reading it is a plain anonymous page fetch: no API key, no internal endpoint, no +forged ``Origin``. It is still page data rather than a contract, so nothing in +this module may raise: an unrecognized shape degrades to ``None`` and the caller +keeps whatever price it already stored. + +Shape this understands (verified against civitai.com and civitai.red):: + + +""" + +from __future__ import annotations + +import json +import logging +import re +from typing import Any, Dict, Iterator, Mapping, Optional + +from .paid_access import is_gate_active, normalize_paid_access + +logger = logging.getLogger(__name__) + +__all__ = ["parse_model_page_prices", "MAX_PAGE_BYTES"] + +# Bound the payload before parsing: a model page is a few hundred KB, so anything +# far larger is not a page we want to hold in memory. +MAX_PAGE_BYTES = 8 * 1024 * 1024 + +_NEXT_DATA_RE = re.compile( + r']*id="__NEXT_DATA__"[^>]*>(.*?)', + re.DOTALL, +) + +# The procedure that carries per-version pricing on a model page. +_MODEL_QUERY_PATH = ("model", "getById") + + +def parse_model_page_prices(html: Any) -> Optional[Dict[int, Dict[str, Any]]]: + """Return ``{version_id: price fields}`` for a model page, or None. + + Only versions with an *active* gate appear: the page payload also carries + lapsed gates as tombstones (a version with ``endsAt`` in the past is freely + downloadable), and those must not be reported as priced. + + Returns None when the page carries no usable payload at all (missing script + tag, malformed JSON, unexpected structure, challenge page). + """ + + if not isinstance(html, str) or not html: + return None + if len(html) > MAX_PAGE_BYTES: + logger.debug("CivitAI model page too large to parse (%d bytes)", len(html)) + return None + + match = _NEXT_DATA_RE.search(html) + if match is None: + return None + + try: + payload = json.loads(match.group(1)) + except (TypeError, ValueError): + return None + + versions = _find_model_versions(payload) + if versions is None: + return None + + prices: Dict[int, Dict[str, Any]] = {} + for entry in versions: + if not isinstance(entry, Mapping): + continue + version_id = _coerce_int(entry.get("id")) + if version_id is None: + continue + price_fields = _price_fields(entry.get("paidAccess")) + if price_fields is not None: + prices[version_id] = price_fields + + return prices + + +def _find_model_versions(payload: Any) -> Optional[list]: + """Locate ``modelVersions`` inside the dehydrated tRPC state. + + The query order is not stable (several procedures are dehydrated per page), so + the query is selected by key rather than position. + """ + + if not isinstance(payload, Mapping): + return None + + for state in _iter_trpc_states(payload): + queries = state.get("queries") + if not isinstance(queries, list): + continue + for query in queries: + if not isinstance(query, Mapping): + continue + if not _matches_model_query(query.get("queryKey")): + continue + data = (query.get("state") or {}).get("data") + if isinstance(data, Mapping): + versions = data.get("modelVersions") + if isinstance(versions, list): + return versions + return None + + +def _iter_trpc_states(payload: Mapping[str, Any]) -> Iterator[Mapping[str, Any]]: + """Yield the dehydrated tRPC state objects found under ``props.pageProps``.""" + + props = payload.get("props") + if not isinstance(props, Mapping): + return + page_props = props.get("pageProps") + if not isinstance(page_props, Mapping): + return + + candidates = [page_props.get("trpcState")] + # Older/other Next.js builds keep the state one level deeper. + dehydrated = page_props.get("dehydratedState") + if dehydrated is not None: + candidates.append(dehydrated) + + for candidate in candidates: + if not isinstance(candidate, Mapping): + continue + for key in ("json", "superjson", "dehydratedState"): + nested = candidate.get(key) + if isinstance(nested, Mapping): + yield nested + yield candidate + + +def _matches_model_query(query_key: Any) -> bool: + if not isinstance(query_key, (list, tuple)) or not query_key: + return False + first = query_key[0] + if not isinstance(first, (list, tuple)) or len(first) < 2: + return False + return str(first[0]) == _MODEL_QUERY_PATH[0] and str(first[1]) == _MODEL_QUERY_PATH[1] + + +def _price_fields(raw_paid_access: Any) -> Optional[Dict[str, Any]]: + """Build the price fields for one version, or None when it is not gated.""" + + dto = _coerce_dto(raw_paid_access) + if dto is None: + return None + + info = normalize_paid_access(dto) + if not info or not is_gate_active(info): + return None + + terms = dto.get("terms") + if not isinstance(terms, Mapping): + terms = {} + + list_price = _download_price(terms) + effective_price = list_price + + sale = dto.get("sale") + sale_ends_at = None + if isinstance(sale, Mapping): + sale_ends_at = _coerce_str(sale.get("endsAt")) + buyer_terms = sale.get("buyerTerms") + if isinstance(buyer_terms, Mapping): + sale_price = _download_price(buyer_terms) + if sale_price is not None: + effective_price = sale_price + + return { + "price_buzz": effective_price, + "list_price_buzz": list_price, + "generation_price_buzz": _generation_price(terms), + "accepts_blue_buzz": bool(terms.get("acceptsBlueBuzz")), + "price_sale_ends_at": sale_ends_at, + } + + +def _coerce_dto(value: Any) -> Optional[Mapping[str, Any]]: + if isinstance(value, str): + try: + value = json.loads(value) + except (TypeError, ValueError): + return None + return value if isinstance(value, Mapping) else None + + +def _download_price(terms: Mapping[str, Any]) -> Optional[int]: + download = terms.get("download") + if not isinstance(download, Mapping): + return None + return _coerce_price(download.get("price")) + + +def _generation_price(terms: Mapping[str, Any]) -> Optional[int]: + generation = terms.get("generation") + if not isinstance(generation, Mapping): + return None + # `{free: true}` has no price of its own, and a paid tier may omit `price` to + # fall back to the download price — the download price is already captured + # separately, so only an explicit number is reported here. + return _coerce_price(generation.get("price")) + + +def _coerce_price(value: Any) -> Optional[int]: + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + price = int(value) + return price if price >= 0 else None + if isinstance(value, str): + try: + price = int(float(value.strip())) + except (TypeError, ValueError): + return None + return price if price >= 0 else None + return None + + +def _coerce_int(value: Any) -> Optional[int]: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, str): + try: + return int(value.strip()) + except (TypeError, ValueError): + return None + return None + + +def _coerce_str(value: Any) -> Optional[str]: + if isinstance(value, str) and value.strip(): + return value.strip() + return None diff --git a/static/css/components/lora-modal/versions.css b/static/css/components/lora-modal/versions.css index 362bc689..bbec690d 100644 --- a/static/css/components/lora-modal/versions.css +++ b/static/css/components/lora-modal/versions.css @@ -460,6 +460,20 @@ border-color: color-mix(in oklch, #845EF7 45%, transparent); } +/* Price alert badge - green tone (#37B24D) for a price under the user's threshold */ +.version-badge-price-alert { + background: color-mix(in oklch, #37B24D 25%, transparent); + color: #2B8A3E; + border-color: color-mix(in oklch, #37B24D 55%, transparent); + font-variant-numeric: tabular-nums; +} + +[data-theme="dark"] .version-badge-price-alert { + background: color-mix(in oklch, #37B24D 20%, transparent); + color: #69DB7C; + border-color: color-mix(in oklch, #37B24D 45%, transparent); +} + .version-meta-ea { color: #E67700; font-weight: 600; diff --git a/static/js/api/apiConfig.js b/static/js/api/apiConfig.js index 9b277769..458c2fb5 100644 --- a/static/js/api/apiConfig.js +++ b/static/js/api/apiConfig.js @@ -98,6 +98,7 @@ export function getApiEndpoints(modelType) { modelUpdateVersions: `/api/lm/${modelType}/updates/versions`, ignoreModelUpdate: `/api/lm/${modelType}/updates/ignore`, ignoreVersionUpdate: `/api/lm/${modelType}/updates/ignore-version`, + priceAlerts: `/api/lm/${modelType}/updates/price-alerts`, // Preview management replacePreview: `/api/lm/${modelType}/replace-preview`, diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 5030869d..05c5327b 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -1086,6 +1086,33 @@ export class BaseModelApiClient { } } + /** + * Versions whose stored price currently crosses the alert threshold. + * Backed by the persisted alert state, so it matches the version badges. + * @param {{limit?: number}} [options] + * @returns {Promise<{alerts: Array, thresholdBuzz: number, enabled: boolean}>} + */ + async getPriceAlerts({ limit = 200 } = {}) { + const endpoint = this.apiConfig?.endpoints?.priceAlerts; + if (!endpoint) { + return { alerts: [], thresholdBuzz: 0, enabled: false }; + } + + const response = await fetch(`${endpoint}?limit=${encodeURIComponent(limit)}`, { + method: 'GET' + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload?.success !== true) { + throw new Error(payload?.error || response.statusText || 'Failed to load price alerts'); + } + + return { + alerts: Array.isArray(payload.alerts) ? payload.alerts : [], + thresholdBuzz: payload.thresholdBuzz ?? 0, + enabled: payload.enabled === true + }; + } + async refreshUpdatesForModels(modelIds, { force = false } = {}) { if (!Array.isArray(modelIds) || modelIds.length === 0) { throw new Error('No model IDs provided'); diff --git a/static/js/components/shared/ModelVersionsTab.js b/static/js/components/shared/ModelVersionsTab.js index f14602b7..2dea9b33 100644 --- a/static/js/components/shared/ModelVersionsTab.js +++ b/static/js/components/shared/ModelVersionsTab.js @@ -187,6 +187,53 @@ function isPaidPermanent(version) { return version && version.isPaid === true; } +function isGated(version) { + return isEarlyAccessActive(version) || isPaidPermanent(version); +} + +/** + * Format a Buzz price for display, or '' when the price is unknown (price + * tracking is opt-in, so most versions have no price at all). + */ +function formatBuzzPrice(value) { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + return ''; + } + return `${value.toLocaleString()} Buzz`; +} + +/** + * Tooltip for a known price: notes a sale against the list price and the Blue + * Buzz option, since both change what the number means to a buyer. + */ +function buildPriceTooltip(version, baseTooltip) { + const effective = formatBuzzPrice(version?.priceBuzz); + const segments = [baseTooltip]; + if (!effective) { + return baseTooltip; + } + const list = version?.listPriceBuzz; + if (typeof list === 'number' && Number.isFinite(list) && list > version.priceBuzz) { + segments.push( + translate( + 'modals.model.versions.badges.priceSaleTooltip', + { list: list.toLocaleString() }, + `On sale, normally ${list.toLocaleString()} Buzz` + ) + ); + } + if (version?.acceptsBlueBuzz) { + segments.push( + translate( + 'modals.model.versions.badges.priceBlueBuzzTooltip', + {}, + 'Can also be paid with Blue Buzz' + ) + ); + } + return segments.join(' · '); +} + function isDownloadAllowed(version) { if (!version.usageControl) { return true; @@ -524,20 +571,55 @@ function renderRow(version, options) { if (isEarlyAccess) { badges.push(buildBadge(earlyAccessBadgeLabel, 'early-access', { - title: translate( - 'modals.model.versions.badges.earlyAccessTooltip', - {}, - 'This version currently requires Civitai early access' + title: buildPriceTooltip( + version, + translate( + 'modals.model.versions.badges.earlyAccessTooltip', + {}, + 'This version currently requires Civitai early access' + ) ), })); } if (isPaidPermanent(version)) { badges.push(buildBadge(paidBadgeLabel, 'paid', { + title: buildPriceTooltip( + version, + translate( + 'modals.model.versions.badges.paidTooltip', + {}, + 'This version requires payment to download' + ) + ), + })); + } + + // Known price for a gated version. The price is only captured when the + // optional price tracking is enabled, so this stays hidden otherwise. + const priceLabel = isGated(version) ? formatBuzzPrice(version.priceBuzz) : ''; + if (priceLabel) { + const alertsEnabled = !!version.priceAlert; + badges.push(buildBadge(priceLabel, alertsEnabled ? 'price-alert' : 'paid', { + title: alertsEnabled + ? translate( + 'modals.model.versions.badges.priceAlertTooltip', + { price: priceLabel }, + `${priceLabel} - below your price alert threshold` + ) + : buildPriceTooltip(version, paidBadgeLabel), + })); + } + + // A version that used to be gated and no longer is. `gateLapsedAt` is + // persisted, so the marker survives long after the refresh that saw it. + if (!isGated(version) && version.gateLapsedAt) { + const freeBadgeLabel = translate('modals.model.versions.badges.freeNow', {}, 'Free Now'); + badges.push(buildBadge(freeBadgeLabel, 'success', { title: translate( - 'modals.model.versions.badges.paidTooltip', - {}, - 'This version requires payment to download' + 'modals.model.versions.badges.freeNowTooltip', + { date: formatDateLabel(version.gateLapsedAt) || '' }, + 'This version no longer requires payment' ), })); } diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index 2f9486f0..24508dc9 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -1163,6 +1163,22 @@ export class SettingsManager { hidePaidUpdatesCheckbox.checked = state.global.settings.hide_paid_updates || false; } + // Set buzz price tracking settings + const priceTrackingEnabledCheckbox = document.getElementById('priceTrackingEnabled'); + if (priceTrackingEnabledCheckbox) { + priceTrackingEnabledCheckbox.checked = state.global.settings.price_tracking_enabled || false; + } + + const priceAlertThresholdInput = document.getElementById('priceAlertThresholdBuzz'); + if (priceAlertThresholdInput) { + priceAlertThresholdInput.value = state.global.settings.price_alert_threshold_buzz ?? 0; + } + + const priceCheckTtlInput = document.getElementById('priceCheckTtlHours'); + if (priceCheckTtlInput) { + priceCheckTtlInput.value = state.global.settings.price_check_ttl_hours ?? 24; + } + const skipPreviouslyDownloadedModelVersionsCheckbox = document.getElementById('skipPreviouslyDownloadedModelVersions'); if (skipPreviouslyDownloadedModelVersionsCheckbox) { skipPreviouslyDownloadedModelVersionsCheckbox.checked = diff --git a/static/js/state/index.js b/static/js/state/index.js index d83ef896..54322601 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -57,6 +57,9 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({ version_grouping: 'same_base', hide_early_access_updates: false, hide_paid_updates: false, + price_tracking_enabled: false, + price_alert_threshold_buzz: 0, + price_check_ttl_hours: 24, auto_organize_exclusions: [], metadata_refresh_skip_paths: [], skip_previously_downloaded_model_versions: false, diff --git a/static/js/utils/updateCheckHelpers.js b/static/js/utils/updateCheckHelpers.js index 5ecb7175..ee4f69a7 100644 --- a/static/js/utils/updateCheckHelpers.js +++ b/static/js/utils/updateCheckHelpers.js @@ -8,6 +8,64 @@ import { modalManager } from '../managers/ModalManager.js'; const CHECK_UPDATES_CONFIRMATION_KEY = 'ack_check_updates_for_all_models'; +/** + * Count the gate/price transitions reported by an update refresh. + * @param {Array} events - `events` array from the refresh response. + * @returns {{becameFree: number, newGate: number, priceDrop: number, total: number}} + */ +export function summarizeGateEvents(events) { + const list = Array.isArray(events) ? events : []; + return { + becameFree: list.filter((event) => event?.kind === 'became_free').length, + newGate: list.filter((event) => event?.kind === 'new_gate').length, + priceDrop: list.filter((event) => event?.kind === 'price_drop').length, + total: list.length + }; +} + +/** + * Toast the gate/price transitions of an update refresh, if any. Kept to a single + * toast so a large refresh cannot bury the user in notifications. + * @param {Array} events + */ +export function showGateEventsToast(events) { + const { becameFree, newGate, priceDrop } = summarizeGateEvents(events); + if (!becameFree && !newGate && !priceDrop) { + return; + } + + const parts = []; + if (becameFree) { + parts.push( + translate( + 'globalContextMenu.checkModelUpdates.gateEvents.becameFree', + { count: becameFree }, + `${becameFree} version(s) became free` + ) + ); + } + if (priceDrop) { + parts.push( + translate( + 'globalContextMenu.checkModelUpdates.gateEvents.priceDrop', + { count: priceDrop }, + `${priceDrop} version(s) dropped below your price threshold` + ) + ); + } + if (newGate) { + parts.push( + translate( + 'globalContextMenu.checkModelUpdates.gateEvents.newGate', + { count: newGate }, + `${newGate} version(s) are now paid` + ) + ); + } + + showToast(parts.join(' · '), {}, becameFree || priceDrop ? 'success' : 'info'); +} + /** * Perform a model update check using the shared backend endpoint. * @param {Object} [options] @@ -85,6 +143,8 @@ export async function performModelUpdateCheck({ onStart, onComplete } = {}) { showToast('globalContextMenu.checkModelUpdates.none', { type: displayName }, 'info'); } + showGateEventsToast(payload.events); + await resetAndReload(false); } catch (err) { if (err?.name === 'AbortError') { @@ -180,6 +240,8 @@ export async function performFolderUpdateCheck(folderPath, { onComplete } = {}) showToast('sidebar.folderUpdateCheck.none', { type: displayName }, 'info'); } + showGateEventsToast(payload.events); + await resetAndReload(false); } catch (err) { if (err?.name === 'AbortError') { diff --git a/templates/components/modals/settings/library.html b/templates/components/modals/settings/library.html index 3a342d6b..831d9b10 100644 --- a/templates/components/modals/settings/library.html +++ b/templates/components/modals/settings/library.html @@ -192,6 +192,57 @@ {{ sm.setting_toggle('hidePaidUpdates', 'hide_paid_updates', 'settings.hidePaidUpdates.label', 'settings.hidePaidUpdates.help') }} + +
+ {{ sm.subsection_header('settings.sections.priceTracking') }} +
{{ t('settings.priceTracking.help') }}
+ {{ sm.setting_toggle('priceTrackingEnabled', 'price_tracking_enabled', 'settings.priceTracking.enabled', 'settings.priceTracking.enabledHelp') }} +
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
{{ sm.subsection_header('settings.sections.exampleImages') }} diff --git a/tests/routes/test_model_update_handler.py b/tests/routes/test_model_update_handler.py index 5aea007e..fda73c9f 100644 --- a/tests/routes/test_model_update_handler.py +++ b/tests/routes/test_model_update_handler.py @@ -10,9 +10,11 @@ import pytest from py.config import config from py.routes.handlers.model_handlers import ( ModelCivitaiHandler, + ModelHandlerSet, ModelManagementHandler, ModelUpdateHandler, ) +from py.routes.model_route_registrar import COMMON_ROUTE_DEFINITIONS from py.services.service_registry import ServiceRegistry from py.utils.metadata_manager import MetadataManager from py.services.model_update_service import ModelUpdateRecord, ModelVersionRecord @@ -324,6 +326,105 @@ async def test_refresh_model_updates_filters_records_without_updates(): assert call["target_model_ids"] is None +@pytest.mark.asyncio +async def test_get_price_alerts_returns_flagged_versions(): + """The price alert list is a GET (companion-extension friendly) and reports + the threshold it was computed with.""" + + class AlertsService: + def __init__(self): + self.calls = [] + + async def get_price_alerts(self, model_type, limit=200): + self.calls.append((model_type, limit)) + return [{"modelId": 1, "versionId": 12, "priceBuzz": 250}] + + update_service = AlertsService() + settings = {"price_tracking_enabled": True, "price_alert_threshold_buzz": 300} + handler = ModelUpdateHandler( + service=DummyService(SimpleNamespace(version_index={})), + update_service=update_service, + metadata_provider_selector=lambda *_: None, + settings_service=SimpleNamespace(get=lambda key, default=None: settings.get(key, default)), + logger=logging.getLogger(__name__), + ) + request = SimpleNamespace(query={"limit": "50"}) + + response = await handler.get_price_alerts(request) # pyright: ignore[reportArgumentType] + + assert response.status == 200 + payload = json.loads(response.text) + assert payload["success"] is True + assert payload["enabled"] is True + assert payload["thresholdBuzz"] == 300 + assert payload["alerts"] == [{"modelId": 1, "versionId": 12, "priceBuzz": 250}] + assert update_service.calls == [("lora", 50)] + + +@pytest.mark.asyncio +async def test_refresh_model_updates_reports_gate_events_for_all_records(): + """Gate transitions are reported even for records that do not qualify as + updates (a version already in the library that became free).""" + + cache = SimpleNamespace(version_index={}) + service = DummyService(cache) + record = ModelUpdateRecord( + model_type="lora", + model_id=1, + versions=[ + ModelVersionRecord( + version_id=11, + name="v11", + base_model=None, + released_at=None, + size_bytes=None, + preview_url=None, + is_in_library=True, + should_ignore=False, + ) + ], + last_checked_at=None, + should_ignore_model=False, + events=[{"versionId": 11, "kind": "became_free", "versionName": "v11", "isInLibrary": True}], + ) + update_service = DummyUpdateService({1: record}) + metadata_selector = AsyncMock(return_value=SimpleNamespace()) + + handler = ModelUpdateHandler( + service=service, + update_service=update_service, + metadata_provider_selector=metadata_selector, + settings_service=SimpleNamespace(get=lambda *_: False), + logger=logging.getLogger(__name__), + ) + + class DummyRequest: + can_read_body = True + query = {} + + async def json(self): + return {} + + response = await handler.refresh_model_updates( + DummyRequest() # pyright: ignore[reportArgumentType] + ) + payload = json.loads(response.text) + + # The record itself does not qualify as an update... + assert payload["records"] == [] + # ...but its transition is still surfaced. + assert payload["events"] == [ + { + "modelId": 1, + "modelType": "lora", + "versionId": 11, + "kind": "became_free", + "versionName": "v11", + "isInLibrary": True, + } + ] + + @pytest.mark.asyncio async def test_refresh_model_updates_same_base_scope_excludes_cross_base_updates(): """Issue #1083: with version_grouping=same_base (the default), a newer @@ -1077,3 +1178,51 @@ async def test_relink_civitai_surfaces_provider_unavailable_without_500(): payload = json.loads(response.text) assert payload["success"] is False assert "CivitArchive" in payload["error"] + + +def test_every_common_route_definition_resolves_to_a_handler(): + """Guard the declarative route table against a missing mapping entry. + + Adding a RouteDefinition without registering it in + ``ModelHandlerSet.to_route_mapping`` fails at request time with a bare + KeyError from the handler lookup (and only on a live server), so assert the + whole table resolves here instead. + """ + + class AnyHandler: + def __getattr__(self, _name): + return lambda request: None + + handler_set = ModelHandlerSet( + page_view=AnyHandler(), + listing=AnyHandler(), + management=AnyHandler(), + query=AnyHandler(), + download=AnyHandler(), + civitai=AnyHandler(), + move=AnyHandler(), + auto_organize=AnyHandler(), + filename_template=AnyHandler(), + updates=AnyHandler(), + ) + + mapping = handler_set.to_route_mapping() + missing = [ + definition.handler_name + for definition in COMMON_ROUTE_DEFINITIONS + if definition.handler_name not in mapping + ] + + assert missing == [] + + +def test_get_price_alerts_is_registered_as_a_route(): + """The alerts endpoint must be declared and reachable via GET.""" + + definition = next( + d + for d in COMMON_ROUTE_DEFINITIONS + if d.handler_name == "get_price_alerts" + ) + assert definition.method == "GET" + assert definition.build_path("loras") == "/api/lm/loras/updates/price-alerts" diff --git a/tests/services/test_civitai_client.py b/tests/services/test_civitai_client.py index 84abe602..a0aa3af1 100644 --- a/tests/services/test_civitai_client.py +++ b/tests/services/test_civitai_client.py @@ -859,3 +859,65 @@ async def test_get_version_file_mini_propagates_rate_limit(downloader): with pytest.raises(RateLimitError): await client.get_version_file_mini(1, 2) + + +async def test_get_model_prices_parses_public_page(downloader): + """Prices come from the page payload, read anonymously (no API key).""" + client = await CivitaiClient.get_instance() + page_html = ( + '' + ) + + async def fake_make_request(method, url, use_auth=True, **kwargs): + assert method == "GET" + assert "/models/7" in url + assert use_auth is False + assert kwargs.get("custom_headers", {}).get("Accept") == "text/html" + return True, page_html + + downloader.make_request = fake_make_request + + result = await client.get_model_prices(7) + + assert result is not None + assert result[42]["price_buzz"] == 5000 + + +async def test_get_model_prices_returns_none_on_unusable_page(downloader): + client = await CivitaiClient.get_instance() + + async def fake_make_request(method, url, use_auth=True, **kwargs): + return True, "challenge" + + downloader.make_request = fake_make_request + + assert await client.get_model_prices(7) is None + + +async def test_get_model_prices_rejects_json_body(downloader): + """A JSON response is not the page; it must not be parsed as one.""" + client = await CivitaiClient.get_instance() + + async def fake_make_request(method, url, use_auth=True, **kwargs): + return True, {"error": "nope"} + + downloader.make_request = fake_make_request + + assert await client.get_model_prices(7) is None + + +async def test_get_model_prices_propagates_rate_limit(downloader): + client = await CivitaiClient.get_instance() + + async def fake_make_request(method, url, use_auth=True, **kwargs): + return False, RateLimitError("limited", retry_after=1.0) + + downloader.make_request = fake_make_request + + with pytest.raises(RateLimitError): + await client.get_model_prices(7) diff --git a/tests/services/test_model_update_service.py b/tests/services/test_model_update_service.py index 707d8525..728fd5da 100644 --- a/tests/services/test_model_update_service.py +++ b/tests/services/test_model_update_service.py @@ -1,5 +1,6 @@ import logging import sqlite3 +from dataclasses import replace from types import SimpleNamespace import pytest @@ -1092,3 +1093,633 @@ def test_build_record_from_remote_preserves_file_count(tmp_path): timestamp=2.0, ) assert record.versions[0].file_count == 1 + + +# --- Gate-state transitions and price persistence --------------------------- + + +def _remote_gated(version_id, *, price_checked_at=None, price_buzz=None): + return ModelVersionRecord( + version_id=version_id, + name=f"v{version_id}", + base_model=None, + released_at=None, + size_bytes=None, + preview_url=None, + is_in_library=False, + should_ignore=False, + paid_access='{"permanent": false, "endsAt": "2026-10-10T13:10:17.404Z"}', + is_early_access=True, + early_access_ends_at="2026-10-10T13:10:17.404Z", + price_checked_at=price_checked_at, + price_buzz=price_buzz, + ) + + +def _remote_free(version_id): + return ModelVersionRecord( + version_id=version_id, + name=f"v{version_id}", + base_model=None, + released_at=None, + size_bytes=None, + preview_url=None, + is_in_library=False, + should_ignore=False, + ) + + +def test_build_record_emits_became_free_event(tmp_path): + """A version whose gate lapsed produces a became_free event, keeps a lapse + timestamp, and drops the now-meaningless stored price.""" + + service = ModelUpdateService(str(tmp_path / "updates.sqlite")) + existing = make_record( + replace( + _remote_gated(7, price_checked_at=100.0, price_buzz=500), + ) + ) + + record = service._build_record_from_remote( + model_type="lora", + model_id=999, + local_versions=[], + remote_versions=[_remote_free(7)], + existing=existing, + timestamp=1000.0, + ) + + version = record.versions[0] + assert version.gate_lapsed_at is not None + assert version.price_buzz is None + assert version.price_checked_at is None + assert version.price_alert_state is False + assert [event["kind"] for event in record.events] == ["became_free"] + assert record.events[0]["versionId"] == 7 + + +def test_build_record_emits_new_gate_event(tmp_path): + service = ModelUpdateService(str(tmp_path / "updates.sqlite")) + existing = make_record(_remote_free(7)) + + record = service._build_record_from_remote( + model_type="lora", + model_id=999, + local_versions=[], + remote_versions=[_remote_gated(7)], + existing=existing, + timestamp=1000.0, + ) + + assert [event["kind"] for event in record.events] == ["new_gate"] + assert record.versions[0].gate_lapsed_at is None + + +def test_build_record_no_events_without_previous_snapshot(tmp_path): + """First sight of a model must not report every existing gate as a new one.""" + + service = ModelUpdateService(str(tmp_path / "updates.sqlite")) + + record = service._build_record_from_remote( + model_type="lora", + model_id=999, + local_versions=[], + remote_versions=[_remote_gated(7), _remote_free(8)], + existing=None, + timestamp=1000.0, + ) + + assert record.events == [] + + +def test_build_record_keeps_lapse_marker_and_skips_ignored(tmp_path): + """An already-free version keeps its original lapse marker and an ignored + version reports nothing at all.""" + + service = ModelUpdateService(str(tmp_path / "updates.sqlite")) + lapsed = replace(_remote_free(7), gate_lapsed_at="2026-09-01T00:00:00.000Z") + existing = make_record(lapsed, replace(_remote_gated(8), should_ignore=True)) + + record = service._build_record_from_remote( + model_type="lora", + model_id=999, + local_versions=[], + remote_versions=[_remote_free(7), _remote_free(8)], + existing=existing, + timestamp=1000.0, + ) + + by_id = {version.version_id: version for version in record.versions} + assert by_id[7].gate_lapsed_at == "2026-09-01T00:00:00.000Z" + assert record.events == [] + + +def test_build_record_preserves_price_when_fetch_skipped(tmp_path): + """A refresh that did not run a price fetch (no price_checked_at) must keep the + stored price instead of wiping it.""" + + service = ModelUpdateService(str(tmp_path / "updates.sqlite")) + existing = make_record( + replace(_remote_gated(7, price_checked_at=100.0, price_buzz=500)) + ) + + record = service._build_record_from_remote( + model_type="lora", + model_id=999, + local_versions=[], + remote_versions=[_remote_gated(7)], + existing=existing, + timestamp=1000.0, + ) + + assert record.versions[0].price_buzz == 500 + assert record.versions[0].price_checked_at == 100.0 + assert record.events == [] + + +def test_build_record_applies_fresh_price(tmp_path): + service = ModelUpdateService(str(tmp_path / "updates.sqlite")) + existing = make_record( + replace(_remote_gated(7, price_checked_at=100.0, price_buzz=500)) + ) + + record = service._build_record_from_remote( + model_type="lora", + model_id=999, + local_versions=[], + remote_versions=[_remote_gated(7, price_checked_at=200.0, price_buzz=250)], + existing=existing, + timestamp=1000.0, + ) + + assert record.versions[0].price_buzz == 250 + assert record.versions[0].price_checked_at == 200.0 + + +def _legacy_version_table_sql() -> str: + """The model_update_versions schema before gate-lapse/price columns.""" + + return """ + CREATE TABLE model_update_versions ( + model_id INTEGER NOT NULL, + version_id INTEGER NOT NULL, + sort_index INTEGER NOT NULL DEFAULT 0, + name TEXT, + base_model TEXT, + released_at TEXT, + size_bytes INTEGER, + preview_url TEXT, + is_in_library INTEGER NOT NULL DEFAULT 0, + should_ignore INTEGER NOT NULL DEFAULT 0, + early_access_ends_at TEXT, + is_early_access INTEGER NOT NULL DEFAULT 0, + usage_control TEXT, + paid_access TEXT, + is_paid INTEGER NOT NULL DEFAULT 0, + file_count INTEGER, + PRIMARY KEY (model_id, version_id) + ) + """ + + +def test_migration_adds_price_columns_to_legacy_db(tmp_path): + """Opening a database written before this feature must add the columns and keep + the existing rows (the migration path real users hit).""" + + db_path = tmp_path / "updates.sqlite" + conn = sqlite3.connect(db_path) + conn.execute( + "CREATE TABLE model_update_status (" + "model_id INTEGER PRIMARY KEY, model_type TEXT NOT NULL, " + "last_checked_at REAL, should_ignore_model INTEGER NOT NULL DEFAULT 0)" + ) + conn.execute(_legacy_version_table_sql()) + conn.execute( + "INSERT INTO model_update_status (model_id, model_type, last_checked_at) " + "VALUES (999, 'lora', 1.0)" + ) + conn.execute( + "INSERT INTO model_update_versions (" + "model_id, version_id, is_in_library, is_early_access, paid_access, is_paid" + ") VALUES (999, 7, 1, 1, '{\"permanent\": false, \"endsAt\": null}', 0)" + ) + conn.commit() + conn.close() + + service = ModelUpdateService(str(db_path)) + columns = service._get_table_columns( + service._connect(), "model_update_versions" + ) + for column in ( + "gate_lapsed_at", + "price_buzz", + "list_price_buzz", + "generation_price_buzz", + "accepts_blue_buzz", + "price_sale_ends_at", + "price_checked_at", + "price_alert_state", + ): + assert column in columns + + record = service._get_record("lora", 999) + assert record is not None + assert [version.version_id for version in record.versions] == [7] + version = record.versions[0] + assert version.paid_access == '{"permanent": false, "endsAt": null}' + assert version.price_buzz is None + assert version.price_checked_at is None + assert version.gate_lapsed_at is None + assert version.accepts_blue_buzz is False + + +def test_price_fields_round_trip_through_sqlite(tmp_path): + """Price and lapse columns survive an upsert/read cycle.""" + + service = ModelUpdateService(str(tmp_path / "updates.sqlite")) + version = replace( + _remote_gated(7, price_checked_at=123.5, price_buzz=500), + list_price_buzz=600, + generation_price_buzz=100, + accepts_blue_buzz=True, + price_sale_ends_at="2026-10-01T00:00:00.000Z", + price_alert_state=True, + is_in_library=True, + gate_lapsed_at=None, + ) + service._upsert_record(make_record(version)) + + stored = service._get_record("lora", 999) + assert stored is not None + loaded = stored.versions[0] + assert loaded.price_buzz == 500 + assert loaded.list_price_buzz == 600 + assert loaded.generation_price_buzz == 100 + assert loaded.accepts_blue_buzz is True + assert loaded.price_sale_ends_at == "2026-10-01T00:00:00.000Z" + assert loaded.price_checked_at == 123.5 + assert loaded.price_alert_state is True + + +# --- Optional price capture -------------------------------------------------- + + +class FakeSettings: + """Minimal stand-in for SettingsManager (only `.get` is used by these paths).""" + + def __init__(self, values=None): + self._values = dict(values or {}) + + def get(self, key, default=None): + return self._values.get(key, default) + + +class PriceProvider(DummyProvider): + """DummyProvider that can also serve prices.""" + + def __init__(self, response, *, prices=None, error=None): + super().__init__(response) + self.prices = prices + self.error = error + self.price_calls = 0 + + async def get_model_prices(self, model_id): + self.price_calls += 1 + if self.error is not None: + raise self.error + return self.prices + + +GATED_RESPONSE = { + "modelVersions": [ + { + "id": 12, + "baseModel": "Pony", + "availability": "Public", + "paidAccess": {"permanent": False, "endsAt": "2999-01-01T00:00:00.000Z"}, + "files": [], + "images": [], + } + ] +} +FREE_RESPONSE = { + "modelVersions": [ + {"id": 12, "baseModel": "Pony", "availability": "Public", "files": [], "images": []} + ] +} +LOCAL_RAW_DATA = [{"civitai": {"modelId": 1, "id": 11}, "base_model": "Pony"}] + +PRICE_PAYLOAD = { + 12: { + "price_buzz": 250, + "list_price_buzz": 500, + "generation_price_buzz": 100, + "accepts_blue_buzz": True, + "price_sale_ends_at": "2999-01-02T00:00:00.000Z", + } +} + + +def _price_service(tmp_path, **settings): + return ModelUpdateService( + str(tmp_path / "updates.sqlite"), + ttl_seconds=0, + settings_manager=FakeSettings(settings), + ) + + +@pytest.mark.asyncio +async def test_price_capture_off_by_default(tmp_path): + service = _price_service(tmp_path) + scanner = DummyScanner(LOCAL_RAW_DATA) + provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD) + + await service.refresh_for_model_type("lora", scanner, provider) + record = await service.get_record("lora", 1) + + assert provider.price_calls == 0 + assert record.versions[0].price_checked_at is None + assert record.versions[0].price_buzz is None + + +@pytest.mark.asyncio +async def test_price_capture_stores_prices_for_gated_versions(tmp_path): + service = _price_service(tmp_path, price_tracking_enabled=True) + scanner = DummyScanner(LOCAL_RAW_DATA) + provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD) + + await service.refresh_for_model_type("lora", scanner, provider) + record = await service.get_record("lora", 1) + + assert provider.price_calls == 1 + version = next(v for v in record.versions if v.version_id == 12) + assert version.price_buzz == 250 + assert version.list_price_buzz == 500 + assert version.generation_price_buzz == 100 + assert version.accepts_blue_buzz is True + assert version.price_sale_ends_at == "2999-01-02T00:00:00.000Z" + assert version.price_checked_at is not None + + +@pytest.mark.asyncio +async def test_price_capture_skips_ungated_models(tmp_path): + service = _price_service(tmp_path, price_tracking_enabled=True) + scanner = DummyScanner(LOCAL_RAW_DATA) + provider = PriceProvider(FREE_RESPONSE, prices=PRICE_PAYLOAD) + + await service.refresh_for_model_type("lora", scanner, provider) + + assert provider.price_calls == 0 + + +@pytest.mark.asyncio +async def test_price_capture_failure_keeps_stored_price(tmp_path): + service = _price_service(tmp_path, price_tracking_enabled=True) + scanner = DummyScanner(LOCAL_RAW_DATA) + + await service.refresh_for_model_type( + "lora", scanner, PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD) + ) + first = await service.get_record("lora", 1) + stored_checked_at = next( + v for v in first.versions if v.version_id == 12 + ).price_checked_at + assert stored_checked_at is not None + + # The page is unreadable this time (a changed gate still triggers a fetch): + # the price must survive untouched rather than being blanked. + failing = PriceProvider( + { + "modelVersions": [ + { + "id": 12, + "baseModel": "Pony", + "availability": "Public", + "paidAccess": { + "permanent": False, + "endsAt": "2999-06-01T00:00:00.000Z", + }, + "files": [], + "images": [], + } + ] + }, + prices=None, + ) + await service.refresh_for_model_type("lora", scanner, failing) + + record = await service.get_record("lora", 1) + version = next(v for v in record.versions if v.version_id == 12) + assert failing.price_calls == 1 + assert version.price_buzz == 250 + assert version.price_checked_at == stored_checked_at + + +@pytest.mark.asyncio +async def test_price_capture_respects_ttl(tmp_path): + """A second refresh with an unchanged gate and a fresh price must not refetch.""" + + service = _price_service( + tmp_path, price_tracking_enabled=True, price_check_ttl_hours=24 + ) + scanner = DummyScanner(LOCAL_RAW_DATA) + provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD) + + await service.refresh_for_model_type("lora", scanner, provider) + await service.refresh_for_model_type("lora", scanner, provider) + + assert provider.price_calls == 1 + + +@pytest.mark.asyncio +async def test_price_capture_refetches_when_gate_changes(tmp_path): + service = _price_service(tmp_path, price_tracking_enabled=True) + scanner = DummyScanner(LOCAL_RAW_DATA) + provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD) + + await service.refresh_for_model_type("lora", scanner, provider) + + changed_gate = { + "modelVersions": [ + { + "id": 12, + "baseModel": "Pony", + "availability": "Public", + "paidAccess": { + "permanent": True, + "endsAt": None, + }, + "files": [], + "images": [], + } + ] + } + provider.response = changed_gate + await service.refresh_for_model_type("lora", scanner, provider) + + assert provider.price_calls == 2 + + +@pytest.mark.asyncio +async def test_price_capture_survives_provider_without_support(tmp_path): + """A provider that cannot serve prices must not break the refresh.""" + + service = _price_service(tmp_path, price_tracking_enabled=True) + scanner = DummyScanner(LOCAL_RAW_DATA) + provider = DummyProvider(GATED_RESPONSE) + + await service.refresh_for_model_type("lora", scanner, provider) + record = await service.get_record("lora", 1) + + assert record is not None + version = next(v for v in record.versions if v.version_id == 12) + assert version.price_buzz is None + + +@pytest.mark.asyncio +async def test_price_capture_drops_price_when_version_becomes_free(tmp_path): + service = _price_service(tmp_path, price_tracking_enabled=True) + scanner = DummyScanner(LOCAL_RAW_DATA) + + await service.refresh_for_model_type( + "lora", scanner, PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD) + ) + + await service.refresh_for_model_type("lora", scanner, PriceProvider(FREE_RESPONSE)) + record = await service.get_record("lora", 1) + version = next(v for v in record.versions if v.version_id == 12) + + assert version.price_buzz is None + assert version.price_checked_at is None + assert version.gate_lapsed_at is not None + + +# --- Price alerts ------------------------------------------------------------ + + +def _gated_response(ends_at: str) -> dict: + """Gated response with a specific EA end. + + Varying the end date between refreshes is what forces a re-fetch inside a + single test (a price is otherwise considered fresh for the whole TTL). + """ + + return { + "modelVersions": [ + { + "id": 12, + "baseModel": "Pony", + "availability": "Public", + "paidAccess": {"permanent": False, "endsAt": ends_at}, + "files": [], + "images": [], + } + ] + } + + +def _prices(price_buzz: int) -> dict: + return {12: {"price_buzz": price_buzz, "list_price_buzz": price_buzz}} + + +@pytest.mark.asyncio +async def test_price_alert_state_persists_and_fires_on_crossing(tmp_path): + service = _price_service( + tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=300 + ) + scanner = DummyScanner(LOCAL_RAW_DATA) + + # First sight: the state is recorded (so the alerts list shows it) but nothing + # is announced - otherwise enabling the feature would toast every cheap + # version in the library at once. + first_refresh = await service.refresh_for_model_type( + "lora", + scanner, + PriceProvider(_gated_response("2999-01-01T00:00:00.000Z"), prices=_prices(250)), + ) + version = next(v for v in first_refresh[1].versions if v.version_id == 12) + assert version.price_alert_state is True + assert first_refresh[1].events == [] + + alerts = await service.get_price_alerts("lora") + assert [alert["versionId"] for alert in alerts] == [12] + assert alerts[0]["priceBuzz"] == 250 + assert alerts[0]["modelId"] == 1 + + # Price rises above the threshold: the state resets silently. + raised = await service.refresh_for_model_type( + "lora", + scanner, + PriceProvider(_gated_response("2999-02-01T00:00:00.000Z"), prices=_prices(900)), + ) + assert raised[1].versions[0].price_alert_state is False + assert raised[1].events == [] + assert await service.get_price_alerts("lora") == [] + + # …and drops back under it: now it is news. + dropped = await service.refresh_for_model_type( + "lora", + scanner, + PriceProvider(_gated_response("2999-03-01T00:00:00.000Z"), prices=_prices(200)), + ) + assert dropped[1].versions[0].price_alert_state is True + assert [event["kind"] for event in dropped[1].events] == ["price_drop"] + assert dropped[1].events[0]["priceBuzz"] == 200 + # Events are derived, never persisted: a later read reports none. + stored = await service.get_record("lora", 1) + assert stored.events == [] + assert stored.versions[0].price_alert_state is True + + +@pytest.mark.asyncio +async def test_price_alert_not_fired_above_threshold(tmp_path): + service = _price_service( + tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=100 + ) + scanner = DummyScanner(LOCAL_RAW_DATA) + provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD) + + await service.refresh_for_model_type("lora", scanner, provider) + record = await service.get_record("lora", 1) + + assert record.versions[0].price_alert_state is False + assert record.events == [] + assert await service.get_price_alerts("lora") == [] + + +@pytest.mark.asyncio +async def test_price_alert_requires_price_tracking(tmp_path): + """With tracking off there are no prices, so no price alert either.""" + + service = _price_service(tmp_path, price_alert_threshold_buzz=100000) + scanner = DummyScanner(LOCAL_RAW_DATA) + provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD) + + await service.refresh_for_model_type("lora", scanner, provider) + record = await service.get_record("lora", 1) + + assert record.versions[0].price_alert_state is False + assert await service.get_price_alerts("lora") == [] + + +@pytest.mark.asyncio +async def test_price_alerts_respect_ignores_and_model_type(tmp_path): + service = _price_service( + tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=1000 + ) + scanner = DummyScanner(LOCAL_RAW_DATA) + provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD) + + await service.refresh_for_model_type("lora", scanner, provider) + assert len(await service.get_price_alerts("lora")) == 1 + + await service.set_version_should_ignore("lora", 1, 12, True) + assert await service.get_price_alerts("lora") == [] + assert await service.get_price_alerts("checkpoint") == [] + + +@pytest.mark.asyncio +async def test_get_price_alerts_tolerates_bad_limit(tmp_path): + service = ModelUpdateService(str(tmp_path / "updates.sqlite")) + + assert await service.get_price_alerts("lora", limit="not-a-number") == [] diff --git a/tests/utils/fixtures/civitai_model_page_paid.html b/tests/utils/fixtures/civitai_model_page_paid.html new file mode 100644 index 00000000..0640855f --- /dev/null +++ b/tests/utils/fixtures/civitai_model_page_paid.html @@ -0,0 +1,108 @@ + + + + + Fixture model + + + + + diff --git a/tests/utils/test_civitai_page_prices.py b/tests/utils/test_civitai_page_prices.py new file mode 100644 index 00000000..7c1542a5 --- /dev/null +++ b/tests/utils/test_civitai_page_prices.py @@ -0,0 +1,91 @@ +"""Tests for the CivitAI model page price parser. + +The fixture mirrors a real page payload; the cases that matter are the ones that +decide whether a user gets a price at all, and whether a lapsed gate is mistaken +for a live one. +""" + +from pathlib import Path + +import pytest + +from py.utils.civitai_page_prices import MAX_PAGE_BYTES, parse_model_page_prices + +FIXTURE = Path(__file__).parent / "fixtures" / "civitai_model_page_paid.html" + + +@pytest.fixture(scope="module") +def fixture_html() -> str: + return FIXTURE.read_text(encoding="utf-8") + + +def test_parses_prices_for_gated_versions(fixture_html): + prices = parse_model_page_prices(fixture_html) + + assert prices is not None + # Lapsed gates and never-gated versions must not appear. + assert set(prices) == {1001, 1002, 1003} + + +def test_permanent_gate_price(fixture_html): + prices = parse_model_page_prices(fixture_html) + permanent = prices[1001] + + assert permanent["price_buzz"] == 5000 + assert permanent["list_price_buzz"] == 5000 + assert permanent["generation_price_buzz"] == 100 + assert permanent["accepts_blue_buzz"] is False + assert permanent["price_sale_ends_at"] is None + + +def test_timed_gate_uses_sale_price_as_effective(fixture_html): + prices = parse_model_page_prices(fixture_html) + timed = prices[1002] + + # The stored/list price stays visible for a strikethrough, the effective price + # is what a buyer pays now. + assert timed["price_buzz"] == 100 + assert timed["list_price_buzz"] == 125 + assert timed["accepts_blue_buzz"] is True + assert timed["price_sale_ends_at"] == "2999-09-01T00:00:00.000Z" + # Generation is bundled with the download tier here (`trialLimit` only). + assert timed["generation_price_buzz"] is None + + +def test_timed_gate_without_recorded_end_is_still_priced(fixture_html): + prices = parse_model_page_prices(fixture_html) + + assert prices[1003]["price_buzz"] == 300 + assert prices[1003]["generation_price_buzz"] is None + + +@pytest.mark.parametrize( + "html", + [ + None, + "", + "no payload here", + '', + # Valid JSON, but not the shape we need. + '', + # Model query present, but no versions. + '', + ], +) +def test_unusable_payloads_return_none(html): + assert parse_model_page_prices(html) is None + + +def test_oversized_page_is_skipped(): + oversized = "x" * (MAX_PAGE_BYTES + 1) + assert parse_model_page_prices(oversized) is None + + +def test_unrelated_first_query_does_not_win(fixture_html): + """Query order varies per page; selection is by procedure name.""" + + prices = parse_model_page_prices(fixture_html) + assert prices is not None + assert 1001 in prices From 7ed19c185cb6e62e04c1d6a83511de7e6cb49ac6 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sun, 4 Oct 2026 15:08:48 +0800 Subject: [PATCH 03/10] feat(update): add a Buzz price alerts panel to the notification bell P5a of docs/plans/paid-model-price-tracking.md: one surface that answers "what got cheaper / became free", without a permanent button (the grid filter was dropped by owner decision, so the panel carries the actions itself). - price_alert_since records when an alert started, so the panel can say "dropped 3 d ago" and count what is new since the user last looked; it is set on the first sight of an already-cheap version, preserved while the alert stands, and cleared when the price rises back above the threshold - get_price_alerts() compares the threshold at read time (editing it takes effect immediately, no refresh needed) and returns both kinds in one list; model_type=None covers every type, which the shared update DB makes a single query. "became free" needs no price data, so it is reported even while price tracking is off - GET /api/lm/price-alerts, registered once in MiscRoutes rather than per model type, decorating rows best-effort with the local model name and file path from the scanner indexes (a cold cache just omits them) - a third tab in the notification bell: segments for under-threshold and became-free, the three states (tracking off / nothing matching / stale), and per-row actions (CivitAI always, Open when the model is local) - two non-permanent entry points share one helper: the controls-bar updates dropdown and the global context menu, whose label carries the unread count - unread state stays client-side (localStorage watermark); the count is fetched once on init and only when price tracking is enabled - the per-type frontend client method is removed as dead code; the per-type backend route stays for the companion extension --- docs/plans/paid-model-price-tracking.md | 223 +++++++++- locales/de.json | 36 +- locales/en.json | 36 +- locales/es.json | 36 +- locales/fr.json | 36 +- locales/he.json | 36 +- locales/ja.json | 36 +- locales/ko.json | 36 +- locales/ru.json | 36 +- locales/zh-CN.json | 36 +- locales/zh-TW.json | 36 +- py/routes/handlers/misc_handlers.py | 153 +++++++ py/routes/misc_route_registrar.py | 3 + py/routes/misc_routes.py | 6 + py/services/model_update_service.py | 173 ++++++-- static/css/components/modal/update-modal.css | 164 +++++++ static/js/api/apiConfig.js | 4 +- static/js/api/baseModelApi.js | 27 -- .../ContextMenu/GlobalContextMenu.js | 17 + static/js/components/controls/PageControls.js | 12 + static/js/managers/UpdateService.js | 411 ++++++++++++++++++ templates/components/context_menu.html | 3 + templates/components/controls.html | 3 + templates/components/modals/update_modal.html | 32 ++ .../managers/updateServicePriceAlerts.test.js | 255 +++++++++++ tests/routes/test_misc_routes.py | 221 +++++++++- tests/services/test_model_update_service.py | 161 +++++++ 27 files changed, 2142 insertions(+), 86 deletions(-) create mode 100644 tests/frontend/managers/updateServicePriceAlerts.test.js diff --git a/docs/plans/paid-model-price-tracking.md b/docs/plans/paid-model-price-tracking.md index 4917c2c7..95c054c7 100644 --- a/docs/plans/paid-model-price-tracking.md +++ b/docs/plans/paid-model-price-tracking.md @@ -5,7 +5,9 @@ model versions and flag when it drops below a threshold or becomes free. **Related:** [#1060 — Some "Early Access" models are not identified correctly](https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1060) (closed) established `is_paid`, the Paid badge and `hide_paid_updates`; this FR is the next step after gate *state* — gate *price*. -**Status:** v2 — **P0–P3 implemented** (see §10 for what shipped and the deviations from this plan). +**Status:** v3 — **P0–P3 implemented** (§10 records what shipped and the deviations); **P5 (alerts +panel) planned** in §11, not implemented. The grid-level "price alert only" filter was dropped by +owner decision, so §11 defines the panel as the only new browsing surface. Feasibility was verified against this repo and upstream CivitAI `main` (`6d29ed1368`), including live probes against `civitai.com` / `civitai.red`. **Scope:** every model type that goes through `ModelUpdateService` (lora / checkpoint / embedding / @@ -379,3 +381,222 @@ extension is GET-only per `AGENTS.md`): * `tests/services/test_civitai_client.py` — page fetch happy path, unusable payloads, rate limits. * `tests/routes/test_model_update_handler.py` — alerts endpoint, gate events for non-updating records. +--- + +## 11. P5 — Price alerts panel + +**Goal:** a single surface that answers "what got cheaper / became free, and can I act on it". + +**Status:** **P5a implemented** (see §11.9); P5b (inline threshold editing, *Refresh prices*, type +chips, ignore action) and P5c (polish) are not. + +**Naming:** called P4a–c in earlier discussion; renumbered to **P5** because §5 already uses P4 for +"docs and upstream". + +**Owner decision:** the grid-level "price alert only" filter is **out of scope**. The panel is the +only new browsing surface; the existing per-version badges and the update-check toast stay as they +are. Consequence: the panel carries the "act on it" affordances itself (§11.2), and discoverability +rests on the bell badge plus the controls-dropdown entry (§11.1 D8). + +### 11.1 Locked decisions + +| # | Decision | Why / evidence | +| --- | --- | --- | +| D1 | Host it as a **third tab in the existing notification bell modal**, driven by `UpdateService` | `templates/components/modals.html` is included by `templates/base.html:75`, so the bell exists on every page; `UpdateService` already has `toggleUpdateModal()`, `switchNotificationTab()`, tab badges, arrow-key tab navigation and a list/empty-state pattern (`renderRecentBanners`) | +| D2 | One **global** endpoint `GET /api/lm/price-alerts`, registered **once** in `MiscRoutes` | The update DB is one file per library shared by all model types (`cache/model_update/.sqlite`), and `ServiceRegistry.get_model_update_service()` returns one shared instance (`py/services/service_registry.py:153`). `get_price_alerts` only filters on `s.model_type`, so `model_type=None` is all types in one query. Adding it to `COMMON_ROUTE_DEFINITIONS` would bind the same path once per model type (4×) | +| D3 | **Compare the threshold at read time** (`WHERE v.price_buzz <= ?`); keep `price_alert_state` for the toast edge only | As shipped, panel membership only changes after a refresh, so editing the threshold in the panel would look broken | +| D4 | New column `price_alert_since REAL` | `price_alert_state` is a boolean; "dropped 3 days ago" and an unread count both need the moment the state flipped, which the existing edge detection already knows | +| D5 | Unread state is **client-side** (`localStorage` watermark compared against `price_alert_since`) | No per-user read-state table and no sync logic; the worst case is a conservative badge on a second browser | +| D6 | Row actions: **CivitAI** always; **Open** only when the payload resolved a local file path | `showModelModal(model, modelType)` needs a local metadata object and re-fetches by `file_path` (`static/js/components/shared/ModelModal.js:343-361`); a gated version the user does not own has no local file | +| D7 | **Refresh prices** must bypass the price TTL | Prices stay fresh for `price_check_ttl_hours` (24 h by default), so without a force flag the button would appear to do nothing | +| D8 | **Two non-permanent entry points**, both opening the bell on the Price alerts tab: the **updates dropdown** in the controls bar, and the **global context menu** (right-click on empty space) | The controls dropdown already hosts `checkUpdatesMenuItem` (`templates/components/controls.html:114-126`) and costs no layout space. The global menu is the established home for library-wide occasional actions and already holds the sibling `check-model-updates` (`templates/components/context_menu.html:185`); it has only 7 items and existing hide/separator machinery (`GlobalContextMenu.showMenu` / `_updateSeparatorVisibility`). The **model-card menu is deliberately left alone** — it already has 20 items, and P5b's *Refresh prices* covers the per-model case | +| D9 | Bell badge shows the **unread alert count**; when there are none it keeps today's behaviour (dot for app updates). The same cached count is appended to the global context menu item (`Price alerts (3)`) | Otherwise a price drop is invisible until the user opens the bell. The count is fetched once on init **only when `price_tracking_enabled`**, so users who never enable the feature pay nothing | +| D10 | The global context menu item is **always visible** on model pages (hidden on recipes, like its siblings) | The panel's disabled state is the explanation plus a deep link into Settings; hiding the entry would make the feature undiscoverable for exactly the users who have not enabled it yet | + +### 11.2 Information architecture + +``` +┌ Price alerts [Refresh prices] ┐ +│ Threshold: 500 Buzz (click to edit) │ +│ [ Under threshold ] [ Became free ] Type: All · LoRA · … │ +├──────────────────────────────────────────────────────────────────────┤ +│ ▸ Glorious Art · Checkpoint · 2 versions │ +│ Alpha · 250 Buzz (was 500) · Blue Buzz OK │ +│ EA until Oct 10 · Not in library · dropped 3 days ago │ +│ [CivitAI] [Open] [Ignore] │ +│ ▸ Eira Kishida · LoRA · 1 version │ +│ 125 Buzz · In library · dropped today [CivitAI] [Open] │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +* Segments cover both halves of the FR: `Under threshold` (price at or below the threshold) and + `Became free` (versions with `gate_lapsed_at`, no price). +* Grouped by model (a model often has several versions at the same price), sorted cheapest first + then most recently crossed; the type chips filter client-side over the loaded list. +* Row: effective price with the list price struck through when on sale, Blue Buzz note, EA end date, + in-library marker, and "dropped X ago" from `price_alert_since`. +* Actions: open on CivitAI, open the local model modal (only when a file path was resolved), ignore + this version (reuses `setVersionUpdateIgnore`). + +### 11.3 Data contract + +``` +GET /api/lm/price-alerts?limit=200 +{ + "success": true, + "enabled": true, // price_tracking_enabled + "thresholdBuzz": 500, + "newestCheckedAt": 1791039694.5, // staleness copy ("prices last checked X ago") + "alerts": [ + { + "modelId": 2981320, "modelType": "checkpoint", + "modelName": "Glorious Art", // best-effort, from the scanner cache + "versionId": 3379626, "versionName": "Alpha", + "kind": "below_threshold", // or "became_free" + "priceBuzz": 250, "listPriceBuzz": 500, + "acceptsBlueBuzz": true, "priceSaleEndsAt": null, + "priceAlertSince": 1791000000.0, + "gateLapsedAt": null, + "earlyAccessEndsAt": null, "isPaid": true, "isEarlyAccess": false, + "isInLibrary": false, + "filePath": null, // set only when resolvable + "civitaiUrl": "https://civitai.com/models/2981320?modelVersionId=3379626" + } + ] +} +``` + +One list with a `kind` discriminator (not two lists) so "All" needs no second request. `civitaiUrl` +is built server-side with the existing `build_civitai_model_page_url` so `civitai_host` stays the +single source of truth. + +### 11.4 States (all three must be designed, not just the happy path) + +1. **Tracking off** — explain that CivitAI publishes no price in its public API and that the feature + reads the model page, plus a button that opens Settings → Library. +2. **On, nothing matching** — "nothing under N Buzz right now", with the threshold editable inline. +3. **Stale / offline / rate-limited** — keep the last known list, add a "prices last checked X ago" + line and a retry; never blank the panel. + +### 11.5 Tasks + +**P5a — panel usable end to end** + +1. `py/services/model_update_service.py` + * `get_price_alerts(model_type=None, *, threshold_buzz, limit=200)` — optional type filter, live + threshold comparison, `kind`, `price_alert_since`; keep the shipped per-type call site working. + * `price_alert_since` column through all eight enumerations (§4.1), set on the `0 → 1` edge and + cleared on `1 → 0` inside `_build_record_from_remote`. +2. `py/routes/misc_route_registrar.py` + `py/routes/handlers/misc_handlers.py` — register + `GET /api/lm/price-alerts` **once**, using `ServiceRegistry.get_model_update_service()` and the + settings service; resolve `modelName` / `filePath` best-effort per model type from the scanner + caches (`version_index[version_id]` → `file_path`, `file_name`; item `model_name` with a + `file_name` fallback), and omit them when resolution fails. +3. `templates/components/modals/update_modal.html` — third tab + panel skeleton (segments, threshold + row, list container, empty/disabled blocks), reusing `data-notification-tab` / + `data-notification-panel`. +4. `static/js/managers/UpdateService.js` — `renderPriceAlerts()`, panel fetch on open, tab badge + count, threshold display, row rendering and actions, `localStorage` watermark; extend + `updateTabBadges()` and `switchNotificationTab()`. +5. `static/js/api/apiConfig.js` + a small fetch helper — the endpoint is global, so it does not + belong in the per-model-type `endpoints` map. +6. `templates/components/controls.html` + `static/js/components/controls/PageControls.js` — the + controls-dropdown item (D8). +7. `templates/components/context_menu.html` + `static/js/components/ContextMenu/GlobalContextMenu.js` + — the global-context-menu item (D8): one template entry, the recipes-page hide list in + `showMenu()`, one `case` in `handleMenuAction`, and the optional `(N)` count from the cached + alert count. Both entries call one shared `openPriceAlertsPanel()` helper. +8. `locales/en.json` + `python scripts/sync_translation_keys.py`. + +*Acceptance:* with tracking on and one refresh done, the bell shows a count, the tab lists the +versions with prices, CivitAI opens the right page, Open appears only for in-library models and opens +the modal, all three states render, and it works from every page type. + +**P5b — actions and polish** + +* Inline threshold editing (read-time comparison makes it instant) and **Refresh prices** with a + `force_price_refresh` flag on the existing refresh endpoint (or a dedicated + `POST /api/lm/price-alerts/refresh`). +* Type chips and a "only versions I do not own" toggle (this is open question 2 from §9). +* Ignore-this-version action. + +**P5c — optional** + +* Thumbnails from the scanner cache, "unread only", focus-management pass on the new tab, and a + deep link from the panel into the settings section. + +### 11.6 Tests + +* `tests/services/test_model_update_service.py` — `model_type=None` spans types; live threshold + parameter; `price_alert_since` set/cleared on the edge. +* `tests/routes/` — the global route is registered exactly once and returns the documented shape; + `filePath` resolution is best-effort (mock scanner; omit on failure). +* `tests/frontend/` — vitest for `renderPriceAlerts` covering rows, both segments, the three states + and the badge count with a mocked fetch. +* Manual eyeball for layout, per the repo's UI verification policy. + +### 11.7 Risks + +| Risk | Mitigation | +| --- | --- | +| The bell modal is nominally about app updates; a third tab changes its character | Clear labelling, and only count/badge when the feature is on; the app-update dot behaviour is unchanged when there are no alerts | +| Resolving local file paths pulls scanner caches into an app-wide route | Best-effort with `try/except`, one `get_cached_data()` per type (in-memory), omit the field and hide the Open button when it fails | +| `price_alert_since` is another migration | Same eight-touchpoint discipline as §4.1, covered by the existing migration test | +| A global route in `COMMON_ROUTE_DEFINITIONS` would bind 4× | Register it in `MiscRoutes` instead (D2) | +| Menu bloat / entry sprawl | Two entries maximum (D8), both calling one helper; the 20-item model-card menu is explicitly untouched | +| Panel data goes stale between refreshes | Show `newestCheckedAt`; the refresh action (P5b) makes it explicit rather than silent | + +### 11.8 Open questions for the owner + +1. **Badge policy** — count unread alerts on the bell (recommended, D9), or leave the bell alone and + put the number only on the tab? +2. **Unread at all** — the `localStorage` watermark (recommended, D5), or simply "current matches" + with no read state? +3. **Threshold editing inside the panel** — convenient, but it silently changes a global setting from + a surface the user opened just to look. +4. **"Open in library"** — worth the scanner-cache coupling (D6), or should the row offer only + "CivitAI" plus a copy-link action? +5. **Thumbnails** — skip for weight (recommended) or show them? +6. **Entry visibility when tracking is off** — always show the global-context-menu item + (recommended, D10: the panel's disabled state educates and deep-links to Settings), or hide it + until the feature is enabled for a cleaner menu? +7. **Count in the menu label** — `Price alerts (3)` using the cached count (recommended, D9), or a + plain label with the number only on the bell/tab? + +### 11.9 What shipped (P5a) + +Answers to §11.8: badge counts unread alerts (D9), unread uses the `localStorage` watermark (D5), +threshold editing is **display-only in P5a** (editing is P5b), "Open in library" ships with the +best-effort path resolution (D6), no thumbnails, the context-menu item is always visible (D10), and +the menu label carries the count (D9). + +| Area | Where | +| --- | --- | +| `price_alert_since` column through all eight enumerations, set on the `0 → 1` edge, preserved while the alert stands, cleared when the price rises | `py/services/model_update_service.py` | +| `get_price_alerts(model_type=None, *, threshold_buzz, limit)` — read-time threshold, `kind`, one list across types; `newest_price_checked_at()` | `py/services/model_update_service.py` | +| Global endpoint `GET /api/lm/price-alerts`, registered once | `py/routes/misc_route_registrar.py`, `py/routes/misc_routes.py`, `PriceAlertsHandler` in `py/routes/handlers/misc_handlers.py` | +| Third bell tab, panel skeleton, segments, three states | `templates/components/modals/update_modal.html`, `static/css/components/modal/update-modal.css` | +| Loader, renderer, unread watermark, badge, `openPriceAlertsPanel()` | `static/js/managers/UpdateService.js` | +| Entry points (controls dropdown + global context menu, count in the label) | `templates/components/controls.html`, `static/js/components/controls/PageControls.js`, `templates/components/context_menu.html`, `static/js/components/ContextMenu/GlobalContextMenu.js` | + +Deviations and decisions made while implementing: + +* **`became_free` is reported even while price tracking is off.** It needs no price data, so the + panel shows the "tracking is off" explanation *and* whatever became free instead of hiding the + half of the feature that already works. +* **`price_alert_since` is also set on first sight** of an already-cheap version. The toast stays + silent (edge-triggered, §10.3), but the count is non-zero, which is what invites the user into the + panel after enabling the feature. +* **The per-type frontend client method was removed** (`baseModelApi.getPriceAlerts` and the + `priceAlerts` entry in `apiConfig.js`): with the global endpoint it was dead code. The per-type + **backend** route stays for the companion extension and a possible future grid filter. +* **`openPriceAlertsTab()` exists because `toggleUpdateModal()` closes an open bell** — an entry + point calling it unconditionally would dismiss the modal instead of switching tabs. +* **Local context uses the existing indexes** (`cache.model_id_index` for `model_name`, + `cache.version_index` for `file_path`/`file_name`), so it is O(1) per row; every failure just + omits the fields and the row loses its "Open" button. + +Verification: `pytest` 3652 passed / 7 skipped, `npm run test:js` 1444 passed, plus a sandboxed +standalone server run that seeded the update DB and confirmed the payload shape, the `kind` +split, the `civitaiUrl`, and that changing `price_alert_threshold_buzz` through `POST /api/lm/settings` +changes panel membership immediately with no refresh. diff --git a/locales/de.json b/locales/de.json index 5592db89..8b65c215 100644 --- a/locales/de.json +++ b/locales/de.json @@ -234,6 +234,9 @@ }, "groupByModel": { "label": "Nach Modell gruppieren" + }, + "priceAlerts": { + "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -942,7 +945,9 @@ "action": "Updates", "menuLabel": "Weitere Update-Optionen anzeigen", "check": "Updates prüfen", - "checkTooltip": "Die Aktualisierungssuche kann einige Zeit dauern." + "checkTooltip": "Die Aktualisierungssuche kann einige Zeit dauern.", + "priceAlerts": "[TODO: Translate] Price alerts", + "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" } }, "bulkOperations": { @@ -2321,7 +2326,8 @@ "notificationsTitle": "Benachrichtigungszentrum", "tabs": { "updates": "Aktualisierungen", - "messages": "Mitteilungen" + "messages": "Mitteilungen", + "priceAlerts": "[TODO: Translate] Price Alerts" }, "updateAvailable": "Update verfügbar", "noChangelogAvailable": "Kein detailliertes Changelog verfügbar. Weitere Informationen auf GitHub.", @@ -2377,6 +2383,32 @@ "shown": "{time} angezeigt", "dismissed": "{time} geschlossen", "active": "Aktiv" + }, + "priceAlerts": { + "disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.", + "openSettings": "[TODO: Translate] Open settings", + "segmentsLabel": "[TODO: Translate] Alert kind", + "segments": { + "belowThreshold": "[TODO: Translate] Under threshold", + "becameFree": "[TODO: Translate] Became free" + }, + "thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz", + "empty": "[TODO: Translate] Nothing is under your price threshold right now.", + "emptyFree": "[TODO: Translate] No version has become free yet.", + "loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.", + "lastChecked": "[TODO: Translate] Prices last checked {when}", + "justNow": "[TODO: Translate] just now", + "minutesAgo": "[TODO: Translate] {count} min ago", + "hoursAgo": "[TODO: Translate] {count} h ago", + "daysAgo": "[TODO: Translate] {count} d ago", + "freeNow": "[TODO: Translate] Free now", + "blueBuzz": "[TODO: Translate] Blue Buzz OK", + "earlyAccessUntil": "[TODO: Translate] Early access until {date}", + "inLibrary": "[TODO: Translate] In library", + "notInLibrary": "[TODO: Translate] Not in library", + "droppedAgo": "[TODO: Translate] dropped {when}", + "openCivitai": "[TODO: Translate] Open on CivitAI", + "openLocal": "[TODO: Translate] Open" } }, "support": { diff --git a/locales/en.json b/locales/en.json index a2c22aed..e877ea94 100644 --- a/locales/en.json +++ b/locales/en.json @@ -234,6 +234,9 @@ }, "groupByModel": { "label": "Group by Model" + }, + "priceAlerts": { + "label": "Price alerts" } }, "header": { @@ -942,7 +945,9 @@ "action": "Updates", "menuLabel": "Show update options", "check": "Check updates", - "checkTooltip": "Checking updates may take a while." + "checkTooltip": "Checking updates may take a while.", + "priceAlerts": "Price alerts", + "priceAlertsTooltip": "Buzz price alerts for paid versions" } }, "bulkOperations": { @@ -2321,7 +2326,8 @@ "notificationsTitle": "Notifications", "tabs": { "updates": "Updates", - "messages": "Messages" + "messages": "Messages", + "priceAlerts": "Price Alerts" }, "updateAvailable": "Update Available", "noChangelogAvailable": "No detailed changelog available. Check GitHub for more information.", @@ -2377,6 +2383,32 @@ "shown": "Shown {time}", "dismissed": "Dismissed {time}", "active": "Active" + }, + "priceAlerts": { + "disabled": "CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.", + "openSettings": "Open settings", + "segmentsLabel": "Alert kind", + "segments": { + "belowThreshold": "Under threshold", + "becameFree": "Became free" + }, + "thresholdLabel": "Alert threshold: {buzz} Buzz", + "empty": "Nothing is under your price threshold right now.", + "emptyFree": "No version has become free yet.", + "loadFailed": "Could not refresh the alerts; showing the last known list.", + "lastChecked": "Prices last checked {when}", + "justNow": "just now", + "minutesAgo": "{count} min ago", + "hoursAgo": "{count} h ago", + "daysAgo": "{count} d ago", + "freeNow": "Free now", + "blueBuzz": "Blue Buzz OK", + "earlyAccessUntil": "Early access until {date}", + "inLibrary": "In library", + "notInLibrary": "Not in library", + "droppedAgo": "dropped {when}", + "openCivitai": "Open on CivitAI", + "openLocal": "Open" } }, "support": { diff --git a/locales/es.json b/locales/es.json index cb39ffc5..aea10e71 100644 --- a/locales/es.json +++ b/locales/es.json @@ -234,6 +234,9 @@ }, "groupByModel": { "label": "Agrupar por modelo" + }, + "priceAlerts": { + "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -942,7 +945,9 @@ "action": "Actualizaciones", "menuLabel": "Mostrar opciones de actualización", "check": "Buscar actualizaciones", - "checkTooltip": "Comprobar actualizaciones puede tardar." + "checkTooltip": "Comprobar actualizaciones puede tardar.", + "priceAlerts": "[TODO: Translate] Price alerts", + "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" } }, "bulkOperations": { @@ -2321,7 +2326,8 @@ "notificationsTitle": "Centro de notificaciones", "tabs": { "updates": "Actualizaciones", - "messages": "Mensajes" + "messages": "Mensajes", + "priceAlerts": "[TODO: Translate] Price Alerts" }, "updateAvailable": "Actualización disponible", "noChangelogAvailable": "No hay registro de cambios detallado disponible. Revisa GitHub para más información.", @@ -2377,6 +2383,32 @@ "shown": "Mostrado {time}", "dismissed": "Descartado {time}", "active": "Activo" + }, + "priceAlerts": { + "disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.", + "openSettings": "[TODO: Translate] Open settings", + "segmentsLabel": "[TODO: Translate] Alert kind", + "segments": { + "belowThreshold": "[TODO: Translate] Under threshold", + "becameFree": "[TODO: Translate] Became free" + }, + "thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz", + "empty": "[TODO: Translate] Nothing is under your price threshold right now.", + "emptyFree": "[TODO: Translate] No version has become free yet.", + "loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.", + "lastChecked": "[TODO: Translate] Prices last checked {when}", + "justNow": "[TODO: Translate] just now", + "minutesAgo": "[TODO: Translate] {count} min ago", + "hoursAgo": "[TODO: Translate] {count} h ago", + "daysAgo": "[TODO: Translate] {count} d ago", + "freeNow": "[TODO: Translate] Free now", + "blueBuzz": "[TODO: Translate] Blue Buzz OK", + "earlyAccessUntil": "[TODO: Translate] Early access until {date}", + "inLibrary": "[TODO: Translate] In library", + "notInLibrary": "[TODO: Translate] Not in library", + "droppedAgo": "[TODO: Translate] dropped {when}", + "openCivitai": "[TODO: Translate] Open on CivitAI", + "openLocal": "[TODO: Translate] Open" } }, "support": { diff --git a/locales/fr.json b/locales/fr.json index 55c0af95..81b44219 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -234,6 +234,9 @@ }, "groupByModel": { "label": "Grouper par modèle" + }, + "priceAlerts": { + "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -942,7 +945,9 @@ "action": "Mises à jour", "menuLabel": "Afficher les options de mise à jour", "check": "Rechercher des mises à jour", - "checkTooltip": "La vérification peut prendre du temps." + "checkTooltip": "La vérification peut prendre du temps.", + "priceAlerts": "[TODO: Translate] Price alerts", + "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" } }, "bulkOperations": { @@ -2321,7 +2326,8 @@ "notificationsTitle": "Notifications", "tabs": { "updates": "Mises à jour", - "messages": "Messages" + "messages": "Messages", + "priceAlerts": "[TODO: Translate] Price Alerts" }, "updateAvailable": "Mise à jour disponible", "noChangelogAvailable": "Aucun journal des modifications détaillé disponible. Consultez GitHub pour plus d'informations.", @@ -2377,6 +2383,32 @@ "shown": "Affiché {time}", "dismissed": "Ignoré {time}", "active": "Actif" + }, + "priceAlerts": { + "disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.", + "openSettings": "[TODO: Translate] Open settings", + "segmentsLabel": "[TODO: Translate] Alert kind", + "segments": { + "belowThreshold": "[TODO: Translate] Under threshold", + "becameFree": "[TODO: Translate] Became free" + }, + "thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz", + "empty": "[TODO: Translate] Nothing is under your price threshold right now.", + "emptyFree": "[TODO: Translate] No version has become free yet.", + "loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.", + "lastChecked": "[TODO: Translate] Prices last checked {when}", + "justNow": "[TODO: Translate] just now", + "minutesAgo": "[TODO: Translate] {count} min ago", + "hoursAgo": "[TODO: Translate] {count} h ago", + "daysAgo": "[TODO: Translate] {count} d ago", + "freeNow": "[TODO: Translate] Free now", + "blueBuzz": "[TODO: Translate] Blue Buzz OK", + "earlyAccessUntil": "[TODO: Translate] Early access until {date}", + "inLibrary": "[TODO: Translate] In library", + "notInLibrary": "[TODO: Translate] Not in library", + "droppedAgo": "[TODO: Translate] dropped {when}", + "openCivitai": "[TODO: Translate] Open on CivitAI", + "openLocal": "[TODO: Translate] Open" } }, "support": { diff --git a/locales/he.json b/locales/he.json index 17cc11a1..fd9249ef 100644 --- a/locales/he.json +++ b/locales/he.json @@ -234,6 +234,9 @@ }, "groupByModel": { "label": "קיבוץ לפי מודל" + }, + "priceAlerts": { + "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -942,7 +945,9 @@ "action": "עדכונים", "menuLabel": "הצגת אפשרויות עדכון", "check": "בדוק עדכונים", - "checkTooltip": "בדיקת עדכונים עלולה לקחת זמן." + "checkTooltip": "בדיקת עדכונים עלולה לקחת זמן.", + "priceAlerts": "[TODO: Translate] Price alerts", + "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" } }, "bulkOperations": { @@ -2321,7 +2326,8 @@ "notificationsTitle": "מרכז התראות", "tabs": { "updates": "עדכונים", - "messages": "הודעות" + "messages": "הודעות", + "priceAlerts": "[TODO: Translate] Price Alerts" }, "updateAvailable": "עדכון זמין", "noChangelogAvailable": "אין יומן שינויים מפורט זמין. בדוק ב-GitHub למידע נוסף.", @@ -2377,6 +2383,32 @@ "shown": "הוצג {time}", "dismissed": "הוסר {time}", "active": "פעיל" + }, + "priceAlerts": { + "disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.", + "openSettings": "[TODO: Translate] Open settings", + "segmentsLabel": "[TODO: Translate] Alert kind", + "segments": { + "belowThreshold": "[TODO: Translate] Under threshold", + "becameFree": "[TODO: Translate] Became free" + }, + "thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz", + "empty": "[TODO: Translate] Nothing is under your price threshold right now.", + "emptyFree": "[TODO: Translate] No version has become free yet.", + "loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.", + "lastChecked": "[TODO: Translate] Prices last checked {when}", + "justNow": "[TODO: Translate] just now", + "minutesAgo": "[TODO: Translate] {count} min ago", + "hoursAgo": "[TODO: Translate] {count} h ago", + "daysAgo": "[TODO: Translate] {count} d ago", + "freeNow": "[TODO: Translate] Free now", + "blueBuzz": "[TODO: Translate] Blue Buzz OK", + "earlyAccessUntil": "[TODO: Translate] Early access until {date}", + "inLibrary": "[TODO: Translate] In library", + "notInLibrary": "[TODO: Translate] Not in library", + "droppedAgo": "[TODO: Translate] dropped {when}", + "openCivitai": "[TODO: Translate] Open on CivitAI", + "openLocal": "[TODO: Translate] Open" } }, "support": { diff --git a/locales/ja.json b/locales/ja.json index 767c1d77..f7c2e006 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -234,6 +234,9 @@ }, "groupByModel": { "label": "モデルでグループ化" + }, + "priceAlerts": { + "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -942,7 +945,9 @@ "action": "アップデート", "menuLabel": "更新オプションを表示", "check": "アップデートを確認", - "checkTooltip": "確認には時間がかかる場合があります。" + "checkTooltip": "確認には時間がかかる場合があります。", + "priceAlerts": "[TODO: Translate] Price alerts", + "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" } }, "bulkOperations": { @@ -2321,7 +2326,8 @@ "notificationsTitle": "通知センター", "tabs": { "updates": "更新", - "messages": "メッセージ" + "messages": "メッセージ", + "priceAlerts": "[TODO: Translate] Price Alerts" }, "updateAvailable": "更新が利用可能", "noChangelogAvailable": "詳細な変更ログは利用できません。詳細はGitHubでご確認ください。", @@ -2377,6 +2383,32 @@ "shown": "{time} に表示", "dismissed": "{time} に非表示", "active": "アクティブ" + }, + "priceAlerts": { + "disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.", + "openSettings": "[TODO: Translate] Open settings", + "segmentsLabel": "[TODO: Translate] Alert kind", + "segments": { + "belowThreshold": "[TODO: Translate] Under threshold", + "becameFree": "[TODO: Translate] Became free" + }, + "thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz", + "empty": "[TODO: Translate] Nothing is under your price threshold right now.", + "emptyFree": "[TODO: Translate] No version has become free yet.", + "loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.", + "lastChecked": "[TODO: Translate] Prices last checked {when}", + "justNow": "[TODO: Translate] just now", + "minutesAgo": "[TODO: Translate] {count} min ago", + "hoursAgo": "[TODO: Translate] {count} h ago", + "daysAgo": "[TODO: Translate] {count} d ago", + "freeNow": "[TODO: Translate] Free now", + "blueBuzz": "[TODO: Translate] Blue Buzz OK", + "earlyAccessUntil": "[TODO: Translate] Early access until {date}", + "inLibrary": "[TODO: Translate] In library", + "notInLibrary": "[TODO: Translate] Not in library", + "droppedAgo": "[TODO: Translate] dropped {when}", + "openCivitai": "[TODO: Translate] Open on CivitAI", + "openLocal": "[TODO: Translate] Open" } }, "support": { diff --git a/locales/ko.json b/locales/ko.json index 60dda23e..a16157ad 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -234,6 +234,9 @@ }, "groupByModel": { "label": "모델별 그룹화" + }, + "priceAlerts": { + "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -942,7 +945,9 @@ "action": "업데이트", "menuLabel": "업데이트 옵션 표시", "check": "업데이트 확인", - "checkTooltip": "업데이트 확인에는 시간이 걸릴 수 있습니다." + "checkTooltip": "업데이트 확인에는 시간이 걸릴 수 있습니다.", + "priceAlerts": "[TODO: Translate] Price alerts", + "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" } }, "bulkOperations": { @@ -2321,7 +2326,8 @@ "notificationsTitle": "알림 센터", "tabs": { "updates": "업데이트", - "messages": "메시지" + "messages": "메시지", + "priceAlerts": "[TODO: Translate] Price Alerts" }, "updateAvailable": "업데이트 사용 가능", "noChangelogAvailable": "상세한 변경 로그가 없습니다. 더 많은 정보는 GitHub를 확인하세요.", @@ -2377,6 +2383,32 @@ "shown": "{time}에 표시", "dismissed": "{time}에 닫힘", "active": "활성" + }, + "priceAlerts": { + "disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.", + "openSettings": "[TODO: Translate] Open settings", + "segmentsLabel": "[TODO: Translate] Alert kind", + "segments": { + "belowThreshold": "[TODO: Translate] Under threshold", + "becameFree": "[TODO: Translate] Became free" + }, + "thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz", + "empty": "[TODO: Translate] Nothing is under your price threshold right now.", + "emptyFree": "[TODO: Translate] No version has become free yet.", + "loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.", + "lastChecked": "[TODO: Translate] Prices last checked {when}", + "justNow": "[TODO: Translate] just now", + "minutesAgo": "[TODO: Translate] {count} min ago", + "hoursAgo": "[TODO: Translate] {count} h ago", + "daysAgo": "[TODO: Translate] {count} d ago", + "freeNow": "[TODO: Translate] Free now", + "blueBuzz": "[TODO: Translate] Blue Buzz OK", + "earlyAccessUntil": "[TODO: Translate] Early access until {date}", + "inLibrary": "[TODO: Translate] In library", + "notInLibrary": "[TODO: Translate] Not in library", + "droppedAgo": "[TODO: Translate] dropped {when}", + "openCivitai": "[TODO: Translate] Open on CivitAI", + "openLocal": "[TODO: Translate] Open" } }, "support": { diff --git a/locales/ru.json b/locales/ru.json index cd6e93b1..83312489 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -234,6 +234,9 @@ }, "groupByModel": { "label": "Группировать по модели" + }, + "priceAlerts": { + "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -942,7 +945,9 @@ "action": "Обновления", "menuLabel": "Показать параметры обновления", "check": "Проверить обновления", - "checkTooltip": "Проверка может занять время." + "checkTooltip": "Проверка может занять время.", + "priceAlerts": "[TODO: Translate] Price alerts", + "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" } }, "bulkOperations": { @@ -2321,7 +2326,8 @@ "notificationsTitle": "Центр уведомлений", "tabs": { "updates": "Обновления", - "messages": "Сообщения" + "messages": "Сообщения", + "priceAlerts": "[TODO: Translate] Price Alerts" }, "updateAvailable": "Доступно обновление", "noChangelogAvailable": "Подробный список изменений недоступен. Проверьте GitHub для получения дополнительной информации.", @@ -2377,6 +2383,32 @@ "shown": "Показано {time}", "dismissed": "Закрыто {time}", "active": "Активно" + }, + "priceAlerts": { + "disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.", + "openSettings": "[TODO: Translate] Open settings", + "segmentsLabel": "[TODO: Translate] Alert kind", + "segments": { + "belowThreshold": "[TODO: Translate] Under threshold", + "becameFree": "[TODO: Translate] Became free" + }, + "thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz", + "empty": "[TODO: Translate] Nothing is under your price threshold right now.", + "emptyFree": "[TODO: Translate] No version has become free yet.", + "loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.", + "lastChecked": "[TODO: Translate] Prices last checked {when}", + "justNow": "[TODO: Translate] just now", + "minutesAgo": "[TODO: Translate] {count} min ago", + "hoursAgo": "[TODO: Translate] {count} h ago", + "daysAgo": "[TODO: Translate] {count} d ago", + "freeNow": "[TODO: Translate] Free now", + "blueBuzz": "[TODO: Translate] Blue Buzz OK", + "earlyAccessUntil": "[TODO: Translate] Early access until {date}", + "inLibrary": "[TODO: Translate] In library", + "notInLibrary": "[TODO: Translate] Not in library", + "droppedAgo": "[TODO: Translate] dropped {when}", + "openCivitai": "[TODO: Translate] Open on CivitAI", + "openLocal": "[TODO: Translate] Open" } }, "support": { diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 00203c38..0d68a8f5 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -234,6 +234,9 @@ }, "groupByModel": { "label": "按模型分组" + }, + "priceAlerts": { + "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -942,7 +945,9 @@ "action": "更新", "menuLabel": "显示更新选项", "check": "检查更新", - "checkTooltip": "检查更新可能耗时。" + "checkTooltip": "检查更新可能耗时。", + "priceAlerts": "[TODO: Translate] Price alerts", + "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" } }, "bulkOperations": { @@ -2321,7 +2326,8 @@ "notificationsTitle": "通知中心", "tabs": { "updates": "更新", - "messages": "消息" + "messages": "消息", + "priceAlerts": "[TODO: Translate] Price Alerts" }, "updateAvailable": "更新可用", "noChangelogAvailable": "没有详细的更新日志可用。请查看 GitHub 以获取更多信息。", @@ -2377,6 +2383,32 @@ "shown": "{time} 显示", "dismissed": "{time} 关闭", "active": "仍在显示" + }, + "priceAlerts": { + "disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.", + "openSettings": "[TODO: Translate] Open settings", + "segmentsLabel": "[TODO: Translate] Alert kind", + "segments": { + "belowThreshold": "[TODO: Translate] Under threshold", + "becameFree": "[TODO: Translate] Became free" + }, + "thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz", + "empty": "[TODO: Translate] Nothing is under your price threshold right now.", + "emptyFree": "[TODO: Translate] No version has become free yet.", + "loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.", + "lastChecked": "[TODO: Translate] Prices last checked {when}", + "justNow": "[TODO: Translate] just now", + "minutesAgo": "[TODO: Translate] {count} min ago", + "hoursAgo": "[TODO: Translate] {count} h ago", + "daysAgo": "[TODO: Translate] {count} d ago", + "freeNow": "[TODO: Translate] Free now", + "blueBuzz": "[TODO: Translate] Blue Buzz OK", + "earlyAccessUntil": "[TODO: Translate] Early access until {date}", + "inLibrary": "[TODO: Translate] In library", + "notInLibrary": "[TODO: Translate] Not in library", + "droppedAgo": "[TODO: Translate] dropped {when}", + "openCivitai": "[TODO: Translate] Open on CivitAI", + "openLocal": "[TODO: Translate] Open" } }, "support": { diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 00f58703..9b650d42 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -234,6 +234,9 @@ }, "groupByModel": { "label": "按模型分組" + }, + "priceAlerts": { + "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -942,7 +945,9 @@ "action": "更新", "menuLabel": "顯示更新選項", "check": "檢查更新", - "checkTooltip": "檢查更新可能耗時。" + "checkTooltip": "檢查更新可能耗時。", + "priceAlerts": "[TODO: Translate] Price alerts", + "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" } }, "bulkOperations": { @@ -2321,7 +2326,8 @@ "notificationsTitle": "通知中心", "tabs": { "updates": "更新", - "messages": "訊息" + "messages": "訊息", + "priceAlerts": "[TODO: Translate] Price Alerts" }, "updateAvailable": "有新版本可用", "noChangelogAvailable": "無詳細更新日誌。請至 GitHub 查看更多資訊。", @@ -2377,6 +2383,32 @@ "shown": "{time} 顯示", "dismissed": "{time} 關閉", "active": "仍在顯示" + }, + "priceAlerts": { + "disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.", + "openSettings": "[TODO: Translate] Open settings", + "segmentsLabel": "[TODO: Translate] Alert kind", + "segments": { + "belowThreshold": "[TODO: Translate] Under threshold", + "becameFree": "[TODO: Translate] Became free" + }, + "thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz", + "empty": "[TODO: Translate] Nothing is under your price threshold right now.", + "emptyFree": "[TODO: Translate] No version has become free yet.", + "loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.", + "lastChecked": "[TODO: Translate] Prices last checked {when}", + "justNow": "[TODO: Translate] just now", + "minutesAgo": "[TODO: Translate] {count} min ago", + "hoursAgo": "[TODO: Translate] {count} h ago", + "daysAgo": "[TODO: Translate] {count} d ago", + "freeNow": "[TODO: Translate] Free now", + "blueBuzz": "[TODO: Translate] Blue Buzz OK", + "earlyAccessUntil": "[TODO: Translate] Early access until {date}", + "inLibrary": "[TODO: Translate] In library", + "notInLibrary": "[TODO: Translate] Not in library", + "droppedAgo": "[TODO: Translate] dropped {when}", + "openCivitai": "[TODO: Translate] Open on CivitAI", + "openLocal": "[TODO: Translate] Open" } }, "support": { diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index 0acf06ad..0044cc12 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -48,6 +48,7 @@ from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStat from ...services.use_cases.sidecar_migration_use_case import SidecarMigrationUseCase from ...services.websocket_progress_callback import WebSocketBroadcastCallback from ...utils.models import BaseModelMetadata +from ...utils.civitai_utils import build_civitai_model_page_url from ...utils.constants import ( CIVITAI_USER_MODEL_TYPES, DEFAULT_NODE_COLOR, @@ -2241,6 +2242,11 @@ class ServiceRegistryAdapter: get_downloaded_version_history_service: Callable[[], Awaitable[Any]] get_backup_service: Callable[[], Awaitable[Any]] = _noop_backup_service get_other_scanner: Callable[[], Awaitable[Any]] = ServiceRegistry.get_other_scanner + # Shared across model types (the update DB is per library), which is what lets + # the global price-alerts endpoint cover every type in one query. + get_model_update_service: Callable[[], Awaitable[Any]] = ( + ServiceRegistry.get_model_update_service + ) class ModelLibraryHandler: @@ -4313,6 +4319,149 @@ class SidecarMigrationHandler: return web.json_response({"success": False, "error": str(exc)}, status=500) +class PriceAlertsHandler: + """Data for the global Buzz price-alerts panel. + + App-wide on purpose: the update DB is one file per library shared by every + model type, and ``ServiceRegistry.get_model_update_service()`` hands out one + instance, so the whole panel is a single query. Registered once (see + ``MISC_ROUTE_DEFINITIONS``) rather than once per model type. + """ + + # Model type -> adapter attribute, for the best-effort local name/path lookup. + _SCANNER_ATTRS = { + "lora": "get_lora_scanner", + "checkpoint": "get_checkpoint_scanner", + "embedding": "get_embedding_scanner", + "other": "get_other_scanner", + } + + def __init__( + self, + *, + settings_service, + service_registry: ServiceRegistryAdapter, + ) -> None: + self._settings = settings_service + self._service_registry = service_registry + + def _setting(self, key: str, default: Any) -> Any: + if self._settings is None: + return default + try: + value = self._settings.get(key, default) + except Exception: + return default + return default if value is None else value + + async def get_price_alerts(self, request: web.Request) -> web.Response: + """Versions under the alert threshold, plus the ones that became free. + + GET with an optional ``limit`` so the companion extension can call it too. + """ + + try: + limit = int(request.query.get("limit") or 200) + except (TypeError, ValueError): + limit = 200 + + threshold = 0 + try: + threshold = max(0, int(self._setting("price_alert_threshold_buzz", 0))) + except (TypeError, ValueError): + threshold = 0 + + try: + update_service = await self._service_registry.get_model_update_service() + except Exception as exc: # pragma: no cover - defensive + logger.error("Price alerts unavailable: %s", exc, exc_info=True) + return web.json_response( + {"success": False, "error": "Update service unavailable"}, status=503 + ) + + try: + alerts = await update_service.get_price_alerts( + None, threshold_buzz=threshold, limit=limit + ) + newest_checked_at = update_service.newest_price_checked_at() + except Exception as exc: + logger.error("Failed to load price alerts: %s", exc, exc_info=True) + return web.json_response( + {"success": False, "error": str(exc)}, status=500 + ) + + await self._decorate_local_context(alerts) + + civitai_host = self._setting("civitai_host", "civitai.com") + for alert in alerts: + alert["civitaiUrl"] = build_civitai_model_page_url( + alert.get("modelId"), + alert.get("versionId"), + host=civitai_host, + ) + + return web.json_response( + { + "success": True, + "enabled": bool(self._setting("price_tracking_enabled", False)), + "thresholdBuzz": threshold, + "newestCheckedAt": newest_checked_at, + "alerts": alerts, + } + ) + + async def _decorate_local_context(self, alerts: Sequence[Dict[str, Any]]) -> None: + """Attach ``modelName`` / ``filePath`` / ``fileName`` when the model is local. + + Best-effort by design: the panel must render even when a scanner cache is + cold or a type is unavailable, so every failure just leaves the fields as + None and the UI hides the affordances that need them. + """ + + by_type: Dict[str, set] = {} + for alert in alerts: + model_type = alert.get("modelType") + model_id = alert.get("modelId") + if not model_type or model_id is None: + continue + by_type.setdefault(model_type, set()).add(model_id) + + for model_type, model_ids in by_type.items(): + attr = self._SCANNER_ATTRS.get(model_type) + scanner_getter = getattr(self._service_registry, attr, None) if attr else None + if scanner_getter is None: + continue + try: + scanner = await scanner_getter() + cache = await scanner.get_cached_data() + except Exception as exc: + logger.debug( + "Skipping local context for %s price alerts: %s", model_type, exc + ) + continue + + model_index = getattr(cache, "model_id_index", None) or {} + version_index = getattr(cache, "version_index", None) or {} + for alert in alerts: + if alert.get("modelType") != model_type: + continue + if alert.get("modelId") not in model_ids: + continue + + entries = model_index.get(alert.get("modelId")) or [] + if entries and isinstance(entries[0], Mapping): + first = entries[0] + alert["modelName"] = first.get("model_name") or first.get( + "file_name" + ) + + version_entry = version_index.get(alert.get("versionId")) + if isinstance(version_entry, Mapping): + alert["filePath"] = version_entry.get("file_path") + alert["fileName"] = version_entry.get("file_name") + alert.setdefault("modelName", version_entry.get("model_name")) + + class MiscHandlerSet: """Aggregate handlers into a lookup compatible with the registrar.""" @@ -4336,6 +4485,7 @@ class MiscHandlerSet: doctor: DoctorHandler, example_workflows: ExampleWorkflowsHandler, base_model: BaseModelHandlerSet, + price_alerts: PriceAlertsHandler, model_source_handler: Any = None, agent_handler: Any = None, download_routing: Any = None, @@ -4358,6 +4508,7 @@ class MiscHandlerSet: self.doctor = doctor self.example_workflows = example_workflows self.base_model = base_model + self.price_alerts = price_alerts self.model_source_handler = model_source_handler self.agent_handler = agent_handler self.download_routing = download_routing @@ -4371,6 +4522,7 @@ class MiscHandlerSet: "get_init_status": self.health.get_init_status, "get_settings": self.settings.get_settings, "update_settings": self.settings.update_settings, + "get_price_alerts": self.price_alerts.get_price_alerts, "get_doctor_diagnostics": self.doctor.get_doctor_diagnostics, "repair_doctor_cache": self.doctor.repair_doctor_cache, "resolve_doctor_filename_conflicts": self.doctor.resolve_filename_conflicts, @@ -4447,4 +4599,5 @@ def build_service_registry_adapter() -> ServiceRegistryAdapter: get_other_scanner=ServiceRegistry.get_other_scanner, get_downloaded_version_history_service=ServiceRegistry.get_downloaded_version_history_service, get_backup_service=ServiceRegistry.get_backup_service, + get_model_update_service=ServiceRegistry.get_model_update_service, ) diff --git a/py/routes/misc_route_registrar.py b/py/routes/misc_route_registrar.py index ef6c9e9d..b479391f 100644 --- a/py/routes/misc_route_registrar.py +++ b/py/routes/misc_route_registrar.py @@ -22,6 +22,9 @@ class RouteDefinition: MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition("GET", "/api/lm/settings", "get_settings"), RouteDefinition("POST", "/api/lm/settings", "update_settings"), + # App-wide and registered once: the alerts panel spans every model type, and + # the update DB is shared, so there is nothing per-type about it. + RouteDefinition("GET", "/api/lm/price-alerts", "get_price_alerts"), RouteDefinition("GET", "/api/lm/llm/models", "get_llm_models"), RouteDefinition("GET", "/api/lm/llm/provider-models", "get_provider_models"), RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"), diff --git a/py/routes/misc_routes.py b/py/routes/misc_routes.py index 03b980a0..bc1444e3 100644 --- a/py/routes/misc_routes.py +++ b/py/routes/misc_routes.py @@ -31,6 +31,7 @@ from .handlers.misc_handlers import ( ModelLibraryHandler, NodeRegistry, NodeRegistryHandler, + PriceAlertsHandler, SettingsHandler, SidecarMigrationHandler, SupportersHandler, @@ -140,6 +141,10 @@ class MiscRoutes: doctor = DoctorHandler(settings_service=self._settings) example_workflows = ExampleWorkflowsHandler() base_model = BaseModelHandlerSet() + price_alerts = PriceAlertsHandler( + settings_service=self._settings, + service_registry=self._service_registry_adapter, + ) model_source_handler = ModelSourceHandler() agent_handler = AgentHandler() download_routing = DownloadRoutingHandler() @@ -163,6 +168,7 @@ class MiscRoutes: doctor=doctor, example_workflows=example_workflows, base_model=base_model, + price_alerts=price_alerts, model_source_handler=model_source_handler, agent_handler=agent_handler, download_routing=download_routing, diff --git a/py/services/model_update_service.py b/py/services/model_update_service.py index b2797689..28ee8d32 100644 --- a/py/services/model_update_service.py +++ b/py/services/model_update_service.py @@ -124,6 +124,10 @@ class ModelVersionRecord: # Last computed "price alert threshold hit" state, used to detect the edge # (0 -> 1) that fires a one-shot price_drop event. price_alert_state: bool = False + # When the alert state last flipped to True (POSIX seconds); cleared when the + # price rises back above the threshold. Gives the panel "dropped X ago" and + # the unread count something to compare against. + price_alert_since: Optional[float] = None @dataclass @@ -374,6 +378,7 @@ class ModelUpdateService: price_sale_ends_at TEXT, price_checked_at REAL, price_alert_state INTEGER NOT NULL DEFAULT 0, + price_alert_since REAL, PRIMARY KEY (model_id, version_id), FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE ); @@ -657,6 +662,10 @@ class ModelUpdateService: "ALTER TABLE model_update_versions " "ADD COLUMN price_alert_state INTEGER NOT NULL DEFAULT 0" ), + "price_alert_since": ( + "ALTER TABLE model_update_versions " + "ADD COLUMN price_alert_since REAL" + ), } for column, statement in migrations.items(): @@ -769,6 +778,7 @@ class ModelUpdateService: price_sale_ends_at TEXT, price_checked_at REAL, price_alert_state INTEGER NOT NULL DEFAULT 0, + price_alert_since REAL, PRIMARY KEY (model_id, version_id), FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE ) @@ -799,6 +809,7 @@ class ModelUpdateService: "price_sale_ends_at", "price_checked_at", "price_alert_state", + "price_alert_since", ] defaults = { "sort_index": "0", @@ -822,6 +833,7 @@ class ModelUpdateService: "price_sale_ends_at": "NULL", "price_checked_at": "NULL", "price_alert_state": "0", + "price_alert_since": "NULL", } select_parts = [] @@ -1203,13 +1215,28 @@ class ModelUpdateService: return self._get_records_bulk(model_type, normalized_ids) async def get_price_alerts( - self, model_type: str, limit: int = 200 + self, + model_type: Optional[str] = None, + *, + threshold_buzz: Optional[int] = None, + limit: int = 200, ) -> List[Dict[str, Any]]: - """Return the gated versions whose price currently crosses the threshold. + """Return the versions worth alerting on, for one type or for all of them. - Reads the persisted ``price_alert_state`` rather than recomputing, so the - list matches the badges the user sees. Cheapest versions first, since that - is what a price watch is for. + The threshold is compared **at read time** rather than reading the persisted + ``price_alert_state``: otherwise editing the threshold in the panel would + not take effect until the next refresh. ``price_alert_state`` stays the + source of the one-shot toast edge. + + Two kinds are returned in one list: + + * ``below_threshold`` — gated versions whose effective price is at or below + the threshold (only possible when price capture has run); + * ``became_free`` — versions whose gate lapsed, which needs no price data at + all, so they are reported even while price tracking is switched off. + + ``model_type=None`` covers every type, which is what the global alerts panel + wants; the update DB is shared across types, so that is still one query. """ try: @@ -1217,9 +1244,25 @@ class ModelUpdateService: except (TypeError, ValueError): normalized_limit = 200 + if threshold_buzz is None: + threshold = self._price_alert_threshold_buzz() + else: + try: + threshold = max(0, int(threshold_buzz)) + except (TypeError, ValueError): + threshold = self._price_alert_threshold_buzz() + + params: List[Any] = [] + type_filter = "" + if model_type: + type_filter = "AND s.model_type = ?" + params.append(model_type) + params.append(threshold) + params.append(normalized_limit) + with self._connect() as conn: rows = conn.execute( - """ + f""" SELECT v.model_id, v.version_id, v.name, @@ -1228,6 +1271,8 @@ class ModelUpdateService: v.accepts_blue_buzz, v.price_sale_ends_at, v.price_checked_at, + v.price_alert_since, + v.gate_lapsed_at, v.is_in_library, v.early_access_ends_at, v.paid_access, @@ -1236,38 +1281,74 @@ class ModelUpdateService: s.model_type FROM model_update_versions v JOIN model_update_status s ON s.model_id = v.model_id - WHERE v.price_alert_state = 1 - AND v.should_ignore = 0 + WHERE v.should_ignore = 0 AND s.should_ignore_model = 0 - AND s.model_type = ? - ORDER BY v.price_buzz ASC, v.model_id ASC, v.version_id ASC + {type_filter} + AND ( + (v.price_buzz IS NOT NULL AND v.price_buzz <= ?) + OR (v.gate_lapsed_at IS NOT NULL + AND v.paid_access IS NULL + AND v.is_paid = 0 + AND v.is_early_access = 0) + ) + ORDER BY (v.price_buzz IS NULL) ASC, + v.price_buzz ASC, + v.gate_lapsed_at DESC, + v.model_id ASC, + v.version_id ASC LIMIT ? """, - (model_type, normalized_limit), + tuple(params), ).fetchall() - return [ - { - "modelId": int(row["model_id"]), - "modelType": row["model_type"], - "versionId": int(row["version_id"]), - "versionName": row["name"], - "priceBuzz": _normalize_int(row["price_buzz"]), - "listPriceBuzz": _normalize_int(row["list_price_buzz"]), - "acceptsBlueBuzz": bool(row["accepts_blue_buzz"]), - "priceSaleEndsAt": row["price_sale_ends_at"], - "priceCheckedAt": ( - float(row["price_checked_at"]) - if row["price_checked_at"] is not None - else None - ), - "isInLibrary": bool(row["is_in_library"]), - "earlyAccessEndsAt": row["early_access_ends_at"], - "isPaid": bool(row["is_paid"]), - "isEarlyAccess": bool(row["is_early_access"]), - } - for row in rows - ] + alerts: List[Dict[str, Any]] = [] + for row in rows: + price_buzz = _normalize_int(row["price_buzz"]) + below_threshold = price_buzz is not None and price_buzz <= threshold + alerts.append( + { + "modelId": int(row["model_id"]), + "modelType": row["model_type"], + "versionId": int(row["version_id"]), + "versionName": row["name"], + "kind": "below_threshold" if below_threshold else "became_free", + "priceBuzz": price_buzz if below_threshold else None, + "listPriceBuzz": ( + _normalize_int(row["list_price_buzz"]) + if below_threshold + else None + ), + "acceptsBlueBuzz": bool(row["accepts_blue_buzz"]), + "priceSaleEndsAt": row["price_sale_ends_at"], + "priceCheckedAt": ( + float(row["price_checked_at"]) + if row["price_checked_at"] is not None + else None + ), + "priceAlertSince": ( + float(row["price_alert_since"]) + if row["price_alert_since"] is not None + else None + ), + "gateLapsedAt": row["gate_lapsed_at"], + "isInLibrary": bool(row["is_in_library"]), + "earlyAccessEndsAt": row["early_access_ends_at"], + "isPaid": bool(row["is_paid"]), + "isEarlyAccess": bool(row["is_early_access"]), + } + ) + return alerts + + def newest_price_checked_at(self) -> Optional[float]: + """Newest successful price fetch across the tracked versions.""" + + with self._connect() as conn: + row = conn.execute( + "SELECT MAX(price_checked_at) AS newest FROM model_update_versions" + ).fetchone() + if row is None or row["newest"] is None: + return None + return float(row["newest"]) async def _refresh_single_model( self, @@ -1852,6 +1933,16 @@ class ModelUpdateService: price_fields["price_alert_state"] = self._evaluate_price_alert( price_fields.get("price_buzz") ) + # `price_alert_since` records when the alert last started, so the panel + # can say "dropped 3 days ago" and count what is new since the user + # last looked. A still-standing alert keeps its original timestamp; a + # price back above the threshold clears it. + if not price_fields["price_alert_state"]: + price_fields["price_alert_since"] = None + elif existing_version is not None and existing_version.price_alert_state: + price_fields["price_alert_since"] = existing_version.price_alert_since + else: + price_fields["price_alert_since"] = timestamp if ( existing_version is not None @@ -1987,6 +2078,7 @@ class ModelUpdateService: "price_sale_ends_at": None, "price_checked_at": None, "price_alert_state": False, + "price_alert_since": None, } if remote_version.price_checked_at is not None: @@ -2002,6 +2094,7 @@ class ModelUpdateService: "price_sale_ends_at": source.price_sale_ends_at, "price_checked_at": source.price_checked_at, "price_alert_state": source.price_alert_state, + "price_alert_since": source.price_alert_since, } def _price_tracking_enabled(self) -> bool: @@ -2374,7 +2467,8 @@ class ModelUpdateService: size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at, is_early_access, usage_control, paid_access, is_paid, file_count, gate_lapsed_at, price_buzz, list_price_buzz, generation_price_buzz, - accepts_blue_buzz, price_sale_ends_at, price_checked_at, price_alert_state + accepts_blue_buzz, price_sale_ends_at, price_checked_at, price_alert_state, + price_alert_since FROM model_update_versions WHERE model_id IN ({placeholders}) ORDER BY model_id ASC, sort_index ASC, version_id ASC @@ -2418,6 +2512,11 @@ class ModelUpdateService: else None ), price_alert_state=bool(row["price_alert_state"]), + price_alert_since=( + float(row["price_alert_since"]) + if row["price_alert_since"] is not None + else None + ), ) ) @@ -2482,8 +2581,9 @@ class ModelUpdateService: size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at, is_early_access, usage_control, paid_access, is_paid, file_count, gate_lapsed_at, price_buzz, list_price_buzz, generation_price_buzz, - accepts_blue_buzz, price_sale_ends_at, price_checked_at, price_alert_state - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + accepts_blue_buzz, price_sale_ends_at, price_checked_at, price_alert_state, + price_alert_since + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( version.version_id, @@ -2510,6 +2610,7 @@ class ModelUpdateService: version.price_sale_ends_at, version.price_checked_at, 1 if version.price_alert_state else 0, + version.price_alert_since, ), ) conn.commit() diff --git a/static/css/components/modal/update-modal.css b/static/css/components/modal/update-modal.css index bcc0b572..37a05196 100644 --- a/static/css/components/modal/update-modal.css +++ b/static/css/components/modal/update-modal.css @@ -652,3 +652,167 @@ margin-top: 5px; } } + +/* --- Buzz price alerts panel --------------------------------------------- */ + +.price-alerts { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.price-alerts-disabled { + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); + background: var(--lora-surface); + padding: var(--space-2); + display: flex; + flex-direction: column; + gap: var(--space-2); + align-items: flex-start; +} + +.price-alerts-disabled-text { + margin: 0; + color: var(--text-color); + opacity: 0.85; + font-size: 0.9rem; +} + +.price-alerts-settings-btn { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: 0.4rem 0.7rem; + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); + background: var(--card-bg, #fff); + color: var(--text-color); + cursor: pointer; +} + +.price-alerts-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + flex-wrap: wrap; +} + +.price-alerts-segments { + display: inline-flex; + gap: var(--space-1); +} + +.price-alerts-segment { + padding: 0.35rem 0.65rem; + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); + background: var(--lora-surface); + color: var(--text-color); + cursor: pointer; + font-size: 0.85rem; +} + +.price-alerts-segment.active { + background: var(--lora-accent, #845ef7); + border-color: var(--lora-accent, #845ef7); + color: #fff; +} + +.price-alerts-threshold { + font-size: 0.85rem; + opacity: 0.8; +} + +.price-alerts-note { + margin: 0; + font-size: 0.8rem; + opacity: 0.75; +} + +.price-alerts-empty { + margin: var(--space-2) 0 0; + opacity: 0.7; + font-size: 0.9rem; +} + +.price-alerts-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); + max-height: 46vh; + overflow-y: auto; +} + +.price-alert-item { + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); + padding: var(--space-2); + background: var(--card-bg, #fff); + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.price-alert-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + flex-wrap: wrap; +} + +.price-alert-model { + font-weight: 600; +} + +.price-alert-price { + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.price-alert-price.is-alert { + color: #2b8a3e; +} + +[data-theme="dark"] .price-alert-price.is-alert { + color: #69db7c; +} + +.price-alert-was { + text-decoration: line-through; + opacity: 0.6; + font-weight: 400; + margin-left: 0.35rem; +} + +.price-alert-meta { + font-size: 0.8rem; + opacity: 0.75; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.price-alert-actions { + display: flex; + gap: var(--space-1); + flex-wrap: wrap; +} + +.price-alert-action { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.25rem 0.55rem; + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); + background: var(--lora-surface); + color: var(--text-color); + cursor: pointer; + font-size: 0.8rem; +} diff --git a/static/js/api/apiConfig.js b/static/js/api/apiConfig.js index 458c2fb5..8d0b3dc4 100644 --- a/static/js/api/apiConfig.js +++ b/static/js/api/apiConfig.js @@ -98,7 +98,9 @@ export function getApiEndpoints(modelType) { modelUpdateVersions: `/api/lm/${modelType}/updates/versions`, ignoreModelUpdate: `/api/lm/${modelType}/updates/ignore`, ignoreVersionUpdate: `/api/lm/${modelType}/updates/ignore-version`, - priceAlerts: `/api/lm/${modelType}/updates/price-alerts`, + + // Price alerts are app-wide (all model types in one query), so they are + // not part of this per-type endpoint map; see UpdateService.js. // Preview management replacePreview: `/api/lm/${modelType}/replace-preview`, diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 05c5327b..5030869d 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -1086,33 +1086,6 @@ export class BaseModelApiClient { } } - /** - * Versions whose stored price currently crosses the alert threshold. - * Backed by the persisted alert state, so it matches the version badges. - * @param {{limit?: number}} [options] - * @returns {Promise<{alerts: Array, thresholdBuzz: number, enabled: boolean}>} - */ - async getPriceAlerts({ limit = 200 } = {}) { - const endpoint = this.apiConfig?.endpoints?.priceAlerts; - if (!endpoint) { - return { alerts: [], thresholdBuzz: 0, enabled: false }; - } - - const response = await fetch(`${endpoint}?limit=${encodeURIComponent(limit)}`, { - method: 'GET' - }); - const payload = await response.json().catch(() => ({})); - if (!response.ok || payload?.success !== true) { - throw new Error(payload?.error || response.statusText || 'Failed to load price alerts'); - } - - return { - alerts: Array.isArray(payload.alerts) ? payload.alerts : [], - thresholdBuzz: payload.thresholdBuzz ?? 0, - enabled: payload.enabled === true - }; - } - async refreshUpdatesForModels(modelIds, { force = false } = {}) { if (!Array.isArray(modelIds) || modelIds.length === 0) { throw new Error('No model IDs provided'); diff --git a/static/js/components/ContextMenu/GlobalContextMenu.js b/static/js/components/ContextMenu/GlobalContextMenu.js index 36e5fad4..c4b037ce 100644 --- a/static/js/components/ContextMenu/GlobalContextMenu.js +++ b/static/js/components/ContextMenu/GlobalContextMenu.js @@ -4,6 +4,7 @@ import { translate } from '../../utils/i18nHelpers.js'; import { state } from '../../state/index.js'; import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js'; import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js'; +import { openPriceAlertsPanel, updateService } from '../../managers/UpdateService.js'; import { rematchModalManager } from '../../managers/RematchModalManager.js'; import { showRematchSummary } from '../RematchSummaryModal.js'; @@ -27,6 +28,7 @@ export class GlobalContextMenu extends BaseContextMenu { const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]'); const rematchRecipesItem = this.menu.querySelector('[data-action="rematch-recipes"]'); const groupByModelItem = this.menu.querySelector('[data-action="toggle-group-by-model"]'); + const priceAlertsItem = this.menu.querySelector('[data-action="price-alerts"]'); const groupByModelCheck = groupByModelItem?.querySelector('.check-indicator'); // Update check indicator for group-by-model @@ -35,6 +37,16 @@ export class GlobalContextMenu extends BaseContextMenu { groupByModelCheck.style.display = isEnabled ? 'block' : 'none'; } + // Append the unread count when it is already known (loaded once on init, + // and only when price tracking is on), so the label stays a plain label + // for everyone else. + const priceAlertsLabel = priceAlertsItem?.querySelector('#globalPriceAlertsLabel'); + if (priceAlertsLabel) { + const unread = updateService?.getUnreadPriceAlertCount?.() || 0; + const base = translate('globalContextMenu.priceAlerts.label', {}, 'Price alerts'); + priceAlertsLabel.textContent = unread > 0 ? `${base} (${unread})` : base; + } + if (isRecipesPage) { modelUpdateItem?.classList.add('hidden'); licenseRefreshItem?.classList.add('hidden'); @@ -42,6 +54,7 @@ export class GlobalContextMenu extends BaseContextMenu { cleanupExamplesItem?.classList.add('hidden'); excludedModelsItem?.classList.add('hidden'); groupByModelItem?.classList.add('hidden'); + priceAlertsItem?.classList.add('hidden'); rematchRecipesItem?.classList.remove('hidden'); } else { modelUpdateItem?.classList.remove('hidden'); @@ -50,6 +63,7 @@ export class GlobalContextMenu extends BaseContextMenu { cleanupExamplesItem?.classList.remove('hidden'); excludedModelsItem?.classList.remove('hidden'); groupByModelItem?.classList.remove('hidden'); + priceAlertsItem?.classList.remove('hidden'); rematchRecipesItem?.classList.add('hidden'); } @@ -89,6 +103,9 @@ export class GlobalContextMenu extends BaseContextMenu { console.error('Failed to check model updates:', error); }); break; + case 'price-alerts': + openPriceAlertsPanel(); + break; case 'fetch-missing-licenses': this.fetchMissingLicenses(menuItem).catch((error) => { console.error('Failed to refresh missing license metadata:', error); diff --git a/static/js/components/controls/PageControls.js b/static/js/components/controls/PageControls.js index 294651dd..4161a710 100644 --- a/static/js/components/controls/PageControls.js +++ b/static/js/components/controls/PageControls.js @@ -4,6 +4,7 @@ import { getStorageItem, setStorageItem, removeStorageItem, getSessionItem, setS import { showToast, openCivitaiByMetadata, isTypingContext } from '../../utils/uiHelpers.js'; import { eventManager } from '../../utils/EventManager.js'; import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js'; +import { openPriceAlertsPanel } from '../../managers/UpdateService.js'; import { sidebarManager } from '../SidebarManager.js'; import { initSortDropdown, applySortToSelect, randomizeSortValue } from './SortDropdown.js'; @@ -256,6 +257,17 @@ export class PageControls { }); } + // Opens the notification bell on the price alerts tab (same action as the + // global context menu entry). + const priceAlertsOption = document.getElementById('priceAlertsMenuItem'); + if (priceAlertsOption) { + priceAlertsOption.addEventListener('click', (e) => { + e.stopPropagation(); + document.querySelector('.dropdown-group.active')?.classList.remove('active'); + openPriceAlertsPanel(); + }); + } + // Close dropdowns when clicking outside document.addEventListener('click', (e) => { if (!e.target.closest('.dropdown-group')) { diff --git a/static/js/managers/UpdateService.js b/static/js/managers/UpdateService.js index 1ddcdade..12c77c0c 100644 --- a/static/js/managers/UpdateService.js +++ b/static/js/managers/UpdateService.js @@ -9,6 +9,77 @@ import { import { state } from '../state/index.js'; import { bannerService } from './BannerService.js'; import { translate } from '../utils/i18nHelpers.js'; +import { showModelModal } from '../components/shared/ModelModal.js'; + +/** Global endpoint: the alerts panel spans every model type. */ +const PRICE_ALERTS_ENDPOINT = '/api/lm/price-alerts'; +/** localStorage watermark of the newest alert the user has already seen. */ +const PRICE_ALERTS_VIEWED_KEY = 'lm_price_alerts_viewed_at'; + +async function fetchPriceAlerts(limit = 200) { + const response = await fetch( + `${PRICE_ALERTS_ENDPOINT}?limit=${encodeURIComponent(limit)}` + ); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload?.success !== true) { + throw new Error(payload?.error || response.statusText || 'Failed to load price alerts'); + } + return { + alerts: Array.isArray(payload.alerts) ? payload.alerts : [], + enabled: payload.enabled === true, + thresholdBuzz: payload.thresholdBuzz ?? 0, + newestCheckedAt: payload.newestCheckedAt ?? null + }; +} + +/** POSIX seconds of the moment an alert started, for unread/sorting comparisons. */ +function priceAlertTimestamp(alert) { + if (typeof alert?.priceAlertSince === 'number') { + return alert.priceAlertSince; + } + if (alert?.gateLapsedAt) { + const parsed = Date.parse(alert.gateLapsedAt); + if (!Number.isNaN(parsed)) { + return parsed / 1000; + } + } + return 0; +} + +function formatRelativeTime(timestampSeconds) { + if (!timestampSeconds) return ''; + const elapsedMs = Date.now() - timestampSeconds * 1000; + const minutes = Math.floor(elapsedMs / 60000); + if (minutes < 1) return translate('update.priceAlerts.justNow', {}, 'just now'); + if (minutes < 60) { + return translate('update.priceAlerts.minutesAgo', { count: minutes }, `${minutes} min ago`); + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return translate('update.priceAlerts.hoursAgo', { count: hours }, `${hours} h ago`); + } + const days = Math.floor(hours / 24); + return translate('update.priceAlerts.daysAgo', { count: days }, `${days} d ago`); +} + +function formatShortDate(value) { + if (!value) return ''; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return ''; + return parsed.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric' + }); +} + +/** + * Open the notification bell on the Buzz price alerts tab. + * Shared by the controls dropdown item and the global context menu item. + */ +export function openPriceAlertsPanel() { + updateService.openPriceAlertsTab(); +} export class UpdateService { constructor() { @@ -35,6 +106,16 @@ export class UpdateService { this.activeNotificationTab = 'updates'; this.handleBannerHistoryUpdated = this.handleBannerHistoryUpdated.bind(this); this.handleNotificationTabKeydown = this.handleNotificationTabKeydown.bind(this); + // Buzz price alerts (panel data, loaded on demand; the count is cached so + // the bell badge and the context-menu label can share it). + this.priceAlerts = null; + this.priceAlertsEnabled = false; + this.priceAlertsThreshold = 0; + this.priceAlertsNewestCheckedAt = null; + this.priceAlertsLoading = false; + this.priceAlertSegment = 'below_threshold'; + this.unreadPriceAlertCount = 0; + this.handlePriceAlertSegmentClick = this.handlePriceAlertSegmentClick.bind(this); } initialize() { @@ -250,7 +331,321 @@ export class UpdateService { }); this.renderRecentBanners(); + this.wirePriceAlertsPanel(); this.switchNotificationTab(this.activeNotificationTab); + + // One request, and only for users who turned price tracking on. It feeds + // the tab badge and the global-context-menu count. + if (state?.global?.settings?.price_tracking_enabled) { + this.loadPriceAlerts().catch((error) => { + console.warn('Failed to preload price alerts:', error); + }); + } + } + + wirePriceAlertsPanel() { + const modal = document.getElementById('updateModal'); + if (!modal) return; + + this.priceAlertSegments = Array.from( + modal.querySelectorAll('[data-price-alert-segment]') + ); + this.priceAlertSegments.forEach((segment) => { + segment.addEventListener('click', this.handlePriceAlertSegmentClick); + }); + + const settingsBtn = document.getElementById('priceAlertsSettingsBtn'); + if (settingsBtn) { + settingsBtn.addEventListener('click', () => { + this.toggleUpdateModal(); + if (window.settingsManager) { + window.settingsManager.toggleSettings(); + } + }); + } + } + + handlePriceAlertSegmentClick(event) { + const segment = event?.currentTarget?.getAttribute('data-price-alert-segment'); + if (!segment) return; + this.priceAlertSegment = segment; + this.renderPriceAlerts(); + } + + /** + * Open the bell on the price alerts tab. + * + * ``toggleUpdateModal()`` *closes* an open bell, so an entry point that called + * it unconditionally would dismiss the modal instead of switching to the tab. + */ + openPriceAlertsTab() { + const modal = modalManager.getModal('updateModal'); + if (!modal || !modal.isOpen) { + this.toggleUpdateModal(); + } + this.switchNotificationTab('priceAlerts', { markRead: true }); + } + + /** + * Load the global alerts payload (all model types in one query). + * @param {{force?: boolean}} [options] + */ + async loadPriceAlerts({ force = false } = {}) { + if (this.priceAlertsLoading) return this.priceAlerts; + if (!force && this.priceAlerts) { + this.renderPriceAlerts(); + return this.priceAlerts; + } + + this.priceAlertsLoading = true; + try { + const payload = await fetchPriceAlerts(); + this.priceAlerts = payload.alerts; + this.priceAlertsEnabled = payload.enabled; + this.priceAlertsThreshold = payload.thresholdBuzz; + this.priceAlertsNewestCheckedAt = payload.newestCheckedAt; + this.refreshUnreadPriceAlertCount(); + this.renderPriceAlerts(); + return this.priceAlerts; + } catch (error) { + console.error('Failed to load price alerts:', error); + // Keep whatever we already had: a stale list beats an empty panel. + this.renderPriceAlerts({ error: true }); + return this.priceAlerts; + } finally { + this.priceAlertsLoading = false; + } + } + + /** Alerts newer than the last time the user opened the panel. */ + refreshUnreadPriceAlertCount() { + const alerts = Array.isArray(this.priceAlerts) ? this.priceAlerts : []; + const viewedAt = Number(getStorageItem(PRICE_ALERTS_VIEWED_KEY, 0)) || 0; + this.unreadPriceAlertCount = alerts.filter( + (alert) => priceAlertTimestamp(alert) > viewedAt + ).length; + this.updateTabBadges(); + return this.unreadPriceAlertCount; + } + + getUnreadPriceAlertCount() { + return this.unreadPriceAlertCount || 0; + } + + markPriceAlertsViewed() { + const alerts = Array.isArray(this.priceAlerts) ? this.priceAlerts : []; + const newest = alerts.reduce( + (latest, alert) => Math.max(latest, priceAlertTimestamp(alert)), + 0 + ); + if (newest > 0) { + setStorageItem(PRICE_ALERTS_VIEWED_KEY, newest); + } + this.unreadPriceAlertCount = 0; + this.updateTabBadges(); + } + + renderPriceAlerts({ error = false } = {}) { + const list = document.getElementById('priceAlertsList'); + const empty = document.getElementById('priceAlertsEmpty'); + const disabled = document.getElementById('priceAlertsDisabled'); + const stale = document.getElementById('priceAlertsStale'); + const thresholdLabel = document.getElementById('priceAlertsThreshold'); + if (!list || !empty) return; + + if (disabled) { + disabled.classList.toggle('hidden', this.priceAlertsEnabled !== false); + } + if (thresholdLabel) { + thresholdLabel.textContent = this.priceAlertsEnabled + ? translate( + 'update.priceAlerts.thresholdLabel', + { buzz: this.priceAlertsThreshold }, + `Alert threshold: ${this.priceAlertsThreshold} Buzz` + ) + : ''; + } + if (stale) { + const checkedAt = this.priceAlertsNewestCheckedAt; + const staleText = error + ? translate( + 'update.priceAlerts.loadFailed', + {}, + 'Could not refresh the alerts; showing the last known list.' + ) + : checkedAt + ? translate( + 'update.priceAlerts.lastChecked', + { when: formatRelativeTime(checkedAt) }, + `Prices last checked ${formatRelativeTime(checkedAt)}` + ) + : ''; + stale.textContent = staleText; + stale.classList.toggle('hidden', !staleText); + } + + if (Array.isArray(this.priceAlertSegments)) { + this.priceAlertSegments.forEach((segment) => { + const isActive = + segment.getAttribute('data-price-alert-segment') === + this.priceAlertSegment; + segment.classList.toggle('active', isActive); + segment.setAttribute('aria-selected', isActive ? 'true' : 'false'); + }); + } + + const alerts = (Array.isArray(this.priceAlerts) ? this.priceAlerts : []).filter( + (alert) => (alert.kind || 'below_threshold') === this.priceAlertSegment + ); + + list.innerHTML = ''; + if (!alerts.length) { + empty.textContent = this.priceAlertSegment === 'became_free' + ? translate( + 'update.priceAlerts.emptyFree', + {}, + 'No version has become free yet.' + ) + : translate( + 'update.priceAlerts.empty', + {}, + 'Nothing is under your price threshold right now.' + ); + empty.classList.remove('hidden'); + return; + } + + empty.classList.add('hidden'); + alerts.forEach((alert) => { + list.appendChild(this.buildPriceAlertItem(alert)); + }); + } + + buildPriceAlertItem(alert) { + const item = document.createElement('li'); + item.className = 'price-alert-item'; + + const head = document.createElement('div'); + head.className = 'price-alert-head'; + + const name = document.createElement('span'); + name.className = 'price-alert-model'; + const modelLabel = alert.modelName || alert.versionName || `#${alert.modelId}`; + name.textContent = alert.versionName + ? `${modelLabel} · ${alert.versionName}` + : modelLabel; + head.appendChild(name); + + if (alert.kind === 'below_threshold') { + const price = document.createElement('span'); + price.className = 'price-alert-price is-alert'; + price.textContent = `${Number(alert.priceBuzz).toLocaleString()} Buzz`; + if ( + typeof alert.listPriceBuzz === 'number' && + alert.listPriceBuzz > alert.priceBuzz + ) { + const was = document.createElement('span'); + was.className = 'price-alert-was'; + was.textContent = `${alert.listPriceBuzz.toLocaleString()} Buzz`; + price.appendChild(was); + } + head.appendChild(price); + } else { + const free = document.createElement('span'); + free.className = 'price-alert-price is-alert'; + free.textContent = translate('update.priceAlerts.freeNow', {}, 'Free now'); + head.appendChild(free); + } + item.appendChild(head); + + const meta = document.createElement('div'); + meta.className = 'price-alert-meta'; + const metaParts = [alert.modelType]; + if (alert.acceptsBlueBuzz) { + metaParts.push(translate('update.priceAlerts.blueBuzz', {}, 'Blue Buzz OK')); + } + if (alert.earlyAccessEndsAt) { + metaParts.push( + translate( + 'update.priceAlerts.earlyAccessUntil', + { date: formatShortDate(alert.earlyAccessEndsAt) }, + `Early access until ${formatShortDate(alert.earlyAccessEndsAt)}` + ) + ); + } + metaParts.push( + alert.isInLibrary + ? translate('update.priceAlerts.inLibrary', {}, 'In library') + : translate('update.priceAlerts.notInLibrary', {}, 'Not in library') + ); + const since = priceAlertTimestamp(alert); + if (since > 0) { + metaParts.push( + translate( + 'update.priceAlerts.droppedAgo', + { when: formatRelativeTime(since) }, + `dropped ${formatRelativeTime(since)}` + ) + ); + } + meta.textContent = metaParts.filter(Boolean).join(' · '); + item.appendChild(meta); + + const actions = document.createElement('div'); + actions.className = 'price-alert-actions'; + + if (alert.civitaiUrl) { + actions.appendChild( + this.buildPriceAlertAction( + 'civitai', + translate('update.priceAlerts.openCivitai', {}, 'Open on CivitAI'), + 'fa-external-link-alt', + () => window.open(alert.civitaiUrl, '_blank', 'noopener') + ) + ); + } + // Only possible for a model that is actually in the library: the modal + // reads the local metadata by file path. + if (alert.filePath) { + actions.appendChild( + this.buildPriceAlertAction( + 'open', + translate('update.priceAlerts.openLocal', {}, 'Open'), + 'fa-folder-open', + () => { + showModelModal( + { + model_name: alert.modelName || alert.versionName, + file_path: alert.filePath, + civitai: {}, + }, + alert.modelType + ); + } + ) + ); + } + item.appendChild(actions); + + return item; + } + + buildPriceAlertAction(name, label, icon, onClick) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'price-alert-action'; + button.dataset.priceAlertAction = name; + + const iconElement = document.createElement('i'); + iconElement.className = `fas ${icon}`; + button.appendChild(iconElement); + + const text = document.createElement('span'); + text.textContent = label; + button.appendChild(text); + + button.addEventListener('click', onClick); + return button; } switchNotificationTab(tabName, { markRead = false } = {}) { @@ -283,6 +678,14 @@ export class UpdateService { } } + if (tabName === 'priceAlerts') { + this.loadPriceAlerts().then(() => { + if (markRead) { + this.markPriceAlertsViewed(); + } + }); + } + this.updateTabBadges(); } @@ -343,10 +746,12 @@ export class UpdateService { updateTabBadges() { const updatesBadge = document.getElementById('updatesTabBadge'); const bannerBadge = document.getElementById('bannerTabBadge'); + const priceAlertsBadge = document.getElementById('priceAlertsTabBadge'); const hasUpdate = this.updateNotificationsEnabled && this.updateAvailable; const unreadBanners = typeof bannerService.getUnreadBannerCount === 'function' ? bannerService.getUnreadBannerCount() : 0; + const unreadAlerts = this.getUnreadPriceAlertCount(); if (updatesBadge) { updatesBadge.classList.toggle('visible', hasUpdate); @@ -354,6 +759,12 @@ export class UpdateService { updatesBadge.textContent = ''; } + if (priceAlertsBadge) { + priceAlertsBadge.textContent = unreadAlerts > 9 ? '9+' : unreadAlerts ? String(unreadAlerts) : ''; + priceAlertsBadge.classList.toggle('visible', unreadAlerts > 0); + priceAlertsBadge.classList.remove('is-dot'); + } + if (bannerBadge) { if (unreadBanners > 0) { bannerBadge.textContent = unreadBanners > 9 ? '9+' : unreadBanners.toString(); diff --git a/templates/components/context_menu.html b/templates/components/context_menu.html index 67e6d080..fe38f287 100644 --- a/templates/components/context_menu.html +++ b/templates/components/context_menu.html @@ -185,6 +185,9 @@
{{ t('globalContextMenu.checkModelUpdates.label') }}
+
+ {{ t('globalContextMenu.priceAlerts.label') }} +
{{ t('globalContextMenu.fetchMissingLicenses.label') }}
diff --git a/templates/components/controls.html b/templates/components/controls.html index b542371d..5499b7b3 100644 --- a/templates/components/controls.html +++ b/templates/components/controls.html @@ -122,6 +122,9 @@ +
{% endif %} diff --git a/templates/components/modals/update_modal.html b/templates/components/modals/update_modal.html index 63ba4904..d3bc5db1 100644 --- a/templates/components/modals/update_modal.html +++ b/templates/components/modals/update_modal.html @@ -15,6 +15,10 @@ {{ t('update.tabs.messages') }} +
@@ -94,6 +98,34 @@
+ diff --git a/tests/frontend/managers/updateServicePriceAlerts.test.js b/tests/frontend/managers/updateServicePriceAlerts.test.js new file mode 100644 index 00000000..e94b165b --- /dev/null +++ b/tests/frontend/managers/updateServicePriceAlerts.test.js @@ -0,0 +1,255 @@ +import { describe, beforeEach, afterEach, expect, it, vi } from 'vitest'; +import { UpdateService } from '../../../static/js/managers/UpdateService.js'; +import { state } from '../../../static/js/state/index.js'; +import { modalManager } from '../../../static/js/managers/ModalManager.js'; + +/** + * The price alerts panel is rendered into the notification bell modal, so the + * tests build the same element ids the template provides and drive the service + * directly. + */ +function buildPanelDom() { + document.body.innerHTML = ` +
+ + +
+
+ + + + + + +

+
    +
    + + + +
    + `; +} + +function alertPayload(alerts, overrides = {}) { + return { + success: true, + enabled: true, + thresholdBuzz: 500, + newestCheckedAt: 1791039694.5, + alerts, + ...overrides, + }; +} + +const BELOW_THRESHOLD_ALERT = { + modelId: 2981320, + modelType: 'checkpoint', + modelName: 'Glorious Art', + versionId: 3379626, + versionName: 'Alpha', + kind: 'below_threshold', + priceBuzz: 250, + listPriceBuzz: 500, + acceptsBlueBuzz: true, + priceAlertSince: 1791000000, + isInLibrary: false, + filePath: null, + civitaiUrl: 'https://civitai.com/models/2981320?modelVersionId=3379626', +}; + +const BECAME_FREE_ALERT = { + modelId: 1802980, + modelType: 'lora', + modelName: 'Eira Kishida', + versionId: 3262917, + versionName: 'v3', + kind: 'became_free', + priceBuzz: null, + priceAlertSince: null, + gateLapsedAt: '2026-09-28T00:00:00.000Z', + isInLibrary: true, + filePath: '/models/loras/eira.safetensors', + civitaiUrl: 'https://civitai.com/models/1802980?modelVersionId=3262917', +}; + +function createFetchResponse(payload, ok = true) { + return { json: vi.fn().mockResolvedValue(payload), ok, statusText: '' }; +} + +describe('UpdateService price alerts panel', () => { + let service; + + beforeEach(() => { + buildPanelDom(); + localStorage.clear(); + state.global = state.global || {}; + state.global.settings = state.global.settings || {}; + service = new UpdateService(); + service.setupNotificationCenter(); + }); + + afterEach(() => { + delete global.fetch; + document.body.innerHTML = ''; + localStorage.clear(); + }); + + it('loads the global endpoint and renders a below-threshold row', async () => { + global.fetch = vi + .fn() + .mockResolvedValue(createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT]))); + + await service.loadPriceAlerts({ force: true }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(String(global.fetch.mock.calls[0][0])).toContain('/api/lm/price-alerts'); + + const items = document.querySelectorAll('#priceAlertsList .price-alert-item'); + expect(items).toHaveLength(1); + expect(items[0].textContent).toContain('Glorious Art'); + expect(items[0].textContent).toContain('250 Buzz'); + // The stored list price is shown struck through. + expect(items[0].querySelector('.price-alert-was').textContent).toBe('500 Buzz'); + expect(items[0].textContent).toContain('Blue Buzz OK'); + expect(items[0].textContent).toContain('Not in library'); + // No local file -> no "Open" action, only CivitAI. + expect(items[0].querySelector('[data-price-alert-action="open"]')).toBeNull(); + expect( + items[0].querySelector('[data-price-alert-action="civitai"]') + ).not.toBeNull(); + expect(document.getElementById('priceAlertsDisabled').classList.contains('hidden')).toBe(true); + expect(document.getElementById('priceAlertsEmpty').classList.contains('hidden')).toBe(true); + }); + + it('separates became-free alerts into their own segment', async () => { + global.fetch = vi + .fn() + .mockResolvedValue( + createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT, BECAME_FREE_ALERT])) + ); + + await service.loadPriceAlerts({ force: true }); + + // Below-threshold is the default segment. + expect(document.querySelectorAll('#priceAlertsList .price-alert-item')).toHaveLength(1); + + service.priceAlertSegment = 'became_free'; + service.renderPriceAlerts(); + + const items = document.querySelectorAll('#priceAlertsList .price-alert-item'); + expect(items).toHaveLength(1); + expect(items[0].textContent).toContain('Free now'); + expect(items[0].textContent).toContain('In library'); + // In-library row offers both actions. + expect(items[0].querySelector('[data-price-alert-action="open"]')).not.toBeNull(); + expect(items[0].querySelector('[data-price-alert-action="civitai"]')).not.toBeNull(); + }); + + it('counts unread alerts and clears the count once viewed', async () => { + global.fetch = vi + .fn() + .mockResolvedValue( + createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT, BECAME_FREE_ALERT])) + ); + + await service.loadPriceAlerts({ force: true }); + + expect(service.getUnreadPriceAlertCount()).toBe(2); + const badge = document.getElementById('priceAlertsTabBadge'); + expect(badge.classList.contains('visible')).toBe(true); + expect(badge.textContent).toBe('2'); + + service.markPriceAlertsViewed(); + + expect(service.getUnreadPriceAlertCount()).toBe(0); + expect(badge.classList.contains('visible')).toBe(false); + + // A later alert is unread again. + service.priceAlerts = [ + { ...BELOW_THRESHOLD_ALERT, priceAlertSince: 1791000000 + 1000 }, + ]; + expect(service.refreshUnreadPriceAlertCount()).toBe(1); + }); + + it('explains itself while price tracking is off', async () => { + global.fetch = vi + .fn() + .mockResolvedValue( + createFetchResponse( + alertPayload([BECAME_FREE_ALERT], { enabled: false, thresholdBuzz: 0 }) + ) + ); + + await service.loadPriceAlerts({ force: true }); + + expect( + document.getElementById('priceAlertsDisabled').classList.contains('hidden') + ).toBe(false); + expect(document.getElementById('priceAlertsThreshold').textContent).toBe(''); + }); + + it('shows the empty state when the segment has no matches', async () => { + global.fetch = vi + .fn() + .mockResolvedValue(createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT]))); + + await service.loadPriceAlerts({ force: true }); + service.priceAlertSegment = 'became_free'; + service.renderPriceAlerts(); + + const empty = document.getElementById('priceAlertsEmpty'); + expect(empty.classList.contains('hidden')).toBe(false); + expect(document.querySelectorAll('#priceAlertsList .price-alert-item')).toHaveLength(0); + }); + + it('keeps the last known list when the request fails', async () => { + global.fetch = vi + .fn() + .mockResolvedValueOnce(createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT]))) + .mockResolvedValueOnce(createFetchResponse({ success: false, error: 'boom' }, false)); + + await service.loadPriceAlerts({ force: true }); + await service.loadPriceAlerts({ force: true }); + + expect(document.querySelectorAll('#priceAlertsList .price-alert-item')).toHaveLength(1); + const stale = document.getElementById('priceAlertsStale'); + expect(stale.classList.contains('hidden')).toBe(false); + }); + + it('switches to the tab without closing an already-open bell', () => { + global.fetch = vi.fn().mockResolvedValue(createFetchResponse(alertPayload([]))); + const toggleSpy = vi.spyOn(service, 'toggleUpdateModal'); + const getModalSpy = vi + .spyOn(modalManager, 'getModal') + .mockReturnValue({ isOpen: true }); + + service.openPriceAlertsTab(); + + expect(getModalSpy).toHaveBeenCalledWith('updateModal'); + // toggleUpdateModal() would have *closed* the open bell. + expect(toggleSpy).not.toHaveBeenCalled(); + expect(service.activeNotificationTab).toBe('priceAlerts'); + + getModalSpy.mockRestore(); + toggleSpy.mockRestore(); + }); + + it('opens the bell when it is closed', () => { + global.fetch = vi.fn().mockResolvedValue(createFetchResponse(alertPayload([]))); + const toggleSpy = vi + .spyOn(service, 'toggleUpdateModal') + .mockImplementation(() => {}); + const getModalSpy = vi + .spyOn(modalManager, 'getModal') + .mockReturnValue({ isOpen: false }); + + service.openPriceAlertsTab(); + + expect(toggleSpy).toHaveBeenCalled(); + expect(service.activeNotificationTab).toBe('priceAlerts'); + + getModalSpy.mockRestore(); + toggleSpy.mockRestore(); + }); +}); diff --git a/tests/routes/test_misc_routes.py b/tests/routes/test_misc_routes.py index 8453ed44..adae23d6 100644 --- a/tests/routes/test_misc_routes.py +++ b/tests/routes/test_misc_routes.py @@ -6,7 +6,7 @@ import subprocess import zipfile from types import SimpleNamespace from typing import Any -from unittest.mock import patch, MagicMock +from unittest.mock import AsyncMock, patch, MagicMock import pytest from aiohttp import web @@ -16,6 +16,8 @@ from py.routes.handlers import misc_handlers from py.routes.handlers.misc_handlers import ( BackupHandler, DoctorHandler, + MiscHandlerSet, + PriceAlertsHandler, FileSystemHandler, HealthCheckHandler, LoraCodeHandler, @@ -2865,3 +2867,220 @@ async def test_sidecar_migration_handler_relocate_root_requires_old_root(): assert response.status == 400 assert "old_root" in payload["error"] assert use_case.calls == [] + + +# --- Global price alerts panel endpoint ------------------------------------- + + +class _AnyHandler: + def __getattr__(self, _name): + return lambda request: None + + +def _stub_misc_handler_set(**overrides) -> MiscHandlerSet: + names = ( + "health", + "settings", + "usage_stats", + "lora_code", + "trained_words", + "model_examples", + "node_registry", + "model_library", + "metadata_archive", + "backup", + "filesystem", + "custom_words", + "wildcards", + "supporters", + "doctor", + "example_workflows", + "base_model", + "price_alerts", + "model_source_handler", + "agent_handler", + "download_routing", + "sidecar_migration", + ) + handlers = {name: _AnyHandler() for name in names} + handlers.update(overrides) + return MiscHandlerSet(**handlers) + + +def test_every_misc_route_definition_resolves_to_a_handler(): + """A route added to the table without a mapping entry 500s only on a live + server, so assert the whole table resolves here.""" + + mapping = _stub_misc_handler_set().to_route_mapping() + + assert [ + definition.handler_name + for definition in MISC_ROUTE_DEFINITIONS + if definition.handler_name not in mapping + ] == [] + + +def test_price_alerts_route_is_registered_once_app_wide(): + definitions = [ + definition + for definition in MISC_ROUTE_DEFINITIONS + if definition.handler_name == "get_price_alerts" + ] + + # Exactly one entry, and no {prefix}: the panel spans every model type. + assert len(definitions) == 1 + assert definitions[0].method == "GET" + assert definitions[0].path == "/api/lm/price-alerts" + + +def _price_alerts_adapter(update_service, scanners=None): + async def _unused_scanner(): + raise AssertionError("scanner should not be requested in this test") + + async def _get_scanner(): + return scanners + + return ServiceRegistryAdapter( + get_lora_scanner=_get_scanner, + get_checkpoint_scanner=_unused_scanner, + get_embedding_scanner=_unused_scanner, + get_downloaded_version_history_service=_unused_scanner, + get_model_update_service=AsyncMock(return_value=update_service), + ) + + +class _FakeUpdateService: + def __init__(self, alerts): + self.alerts = alerts + self.calls = [] + + async def get_price_alerts(self, model_type=None, *, threshold_buzz=None, limit=200): + self.calls.append((model_type, threshold_buzz, limit)) + return [dict(alert) for alert in self.alerts] + + def newest_price_checked_at(self): + return 1791039694.5 + + +@pytest.mark.asyncio +async def test_price_alerts_handler_returns_the_global_list(): + update_service = _FakeUpdateService( + [ + { + "modelId": 2981320, + "modelType": "checkpoint", + "versionId": 3379626, + "kind": "below_threshold", + "priceBuzz": 250, + } + ] + ) + settings = DummySettings( + { + "price_tracking_enabled": True, + "price_alert_threshold_buzz": 300, + "civitai_host": "civitai.com", + } + ) + handler = PriceAlertsHandler( + settings_service=settings, + service_registry=_price_alerts_adapter(update_service), + ) + + response = await handler.get_price_alerts( + FakeRequest(method="GET", query={"limit": "50"}) # pyright: ignore[reportArgumentType] + ) + payload = _json_payload(response) + + assert response.status == 200 + assert payload["success"] is True + assert payload["enabled"] is True + assert payload["thresholdBuzz"] == 300 + assert payload["newestCheckedAt"] == 1791039694.5 + assert payload["alerts"][0]["civitaiUrl"] == ( + "https://civitai.com/models/2981320?modelVersionId=3379626" + ) + # All model types in one call, with the threshold resolved from settings. + assert update_service.calls == [(None, 300, 50)] + + +@pytest.mark.asyncio +async def test_price_alerts_handler_decorates_local_context(): + update_service = _FakeUpdateService( + [ + { + "modelId": 1, + "modelType": "lora", + "versionId": 12, + "kind": "below_threshold", + "priceBuzz": 250, + } + ] + ) + cache = SimpleNamespace( + model_id_index={1: [{"model_name": "Glorious Art", "file_name": "glorious.safetensors"}]}, + version_index={12: {"file_path": "/models/loras/glorious.safetensors", "file_name": "glorious.safetensors"}}, + ) + + class _Scanner: + async def get_cached_data(self): + return cache + + async def _get_scanner(): + return _Scanner() + + adapter = ServiceRegistryAdapter( + get_lora_scanner=_get_scanner, + get_checkpoint_scanner=_get_scanner, + get_embedding_scanner=_get_scanner, + get_downloaded_version_history_service=_get_scanner, + get_model_update_service=AsyncMock(return_value=update_service), + ) + handler = PriceAlertsHandler( + settings_service=DummySettings({"civitai_host": "civitai.red"}), + service_registry=adapter, + ) + + response = await handler.get_price_alerts( + FakeRequest(method="GET") # pyright: ignore[reportArgumentType] + ) + alert = _json_payload(response)["alerts"][0] + + assert alert["modelName"] == "Glorious Art" + assert alert["filePath"] == "/models/loras/glorious.safetensors" + assert alert["civitaiUrl"].startswith("https://civitai.red/models/1") + + +@pytest.mark.asyncio +async def test_price_alerts_handler_survives_a_cold_scanner_cache(): + """Local context is best-effort: the panel must still render.""" + + update_service = _FakeUpdateService( + [{"modelId": 1, "modelType": "lora", "versionId": 12, "kind": "became_free"}] + ) + + async def _broken_scanner(): + raise RuntimeError("scanner not ready") + + adapter = ServiceRegistryAdapter( + get_lora_scanner=_broken_scanner, + get_checkpoint_scanner=_broken_scanner, + get_embedding_scanner=_broken_scanner, + get_downloaded_version_history_service=_broken_scanner, + get_model_update_service=AsyncMock(return_value=update_service), + ) + handler = PriceAlertsHandler( + settings_service=DummySettings(), service_registry=adapter + ) + + response = await handler.get_price_alerts( + FakeRequest(method="GET") # pyright: ignore[reportArgumentType] + ) + payload = _json_payload(response) + + assert response.status == 200 + assert payload["success"] is True + # Tracking is off in this fixture, so the panel explains itself. + assert payload["enabled"] is False + assert "modelName" not in payload["alerts"][0] + assert "filePath" not in payload["alerts"][0] diff --git a/tests/services/test_model_update_service.py b/tests/services/test_model_update_service.py index 728fd5da..733edca0 100644 --- a/tests/services/test_model_update_service.py +++ b/tests/services/test_model_update_service.py @@ -1723,3 +1723,164 @@ async def test_get_price_alerts_tolerates_bad_limit(tmp_path): service = ModelUpdateService(str(tmp_path / "updates.sqlite")) assert await service.get_price_alerts("lora", limit="not-a-number") == [] + + +# --- Price alerts panel semantics (read-time threshold, across types) --------- + + +def _gated_response_for(version_id: int, ends_at: str) -> dict: + return { + "modelVersions": [ + { + "id": version_id, + "baseModel": "Pony", + "availability": "Public", + "paidAccess": {"permanent": False, "endsAt": ends_at}, + "files": [], + "images": [], + } + ] + } + + +def _free_response_for(version_id: int) -> dict: + return { + "modelVersions": [ + { + "id": version_id, + "baseModel": "Pony", + "availability": "Public", + "files": [], + "images": [], + } + ] + } + + +@pytest.mark.asyncio +async def test_price_alerts_threshold_is_compared_at_read_time(tmp_path): + """Changing the threshold must change panel membership without a refresh.""" + + service = _price_service( + tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=300 + ) + scanner = DummyScanner(LOCAL_RAW_DATA) + await service.refresh_for_model_type( + "lora", scanner, PriceProvider(GATED_RESPONSE, prices=_prices(250)) + ) + + assert len(await service.get_price_alerts("lora")) == 1 + # The stored alert state still says "hit"... + stored = await service.get_record("lora", 1) + assert stored.versions[0].price_alert_state is True + # ...but a tighter threshold excludes it immediately. + assert await service.get_price_alerts("lora", threshold_buzz=100) == [] + assert len(await service.get_price_alerts("lora", threshold_buzz=300)) == 1 + + +@pytest.mark.asyncio +async def test_price_alerts_span_all_model_types(tmp_path): + """model_type=None is what the global panel uses: one list, all types.""" + + service = _price_service( + tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=1000 + ) + lora_scanner = DummyScanner([{"civitai": {"modelId": 1, "id": 11}}]) + checkpoint_scanner = DummyScanner([{"civitai": {"modelId": 2, "id": 21}}]) + + await service.refresh_for_model_type( + "lora", + lora_scanner, + PriceProvider(_gated_response_for(12, "2999-01-01T00:00:00.000Z"), prices=_prices(250)), + ) + await service.refresh_for_model_type( + "checkpoint", + checkpoint_scanner, + PriceProvider( + _gated_response_for(22, "2999-01-01T00:00:00.000Z"), + prices={22: {"price_buzz": 100}}, + ), + ) + + everything = await service.get_price_alerts() + assert {(a["modelType"], a["modelId"], a["versionId"]) for a in everything} == { + ("lora", 1, 12), + ("checkpoint", 2, 22), + } + # Cheapest first. + assert [a["priceBuzz"] for a in everything] == [100, 250] + + assert [a["modelType"] for a in await service.get_price_alerts("checkpoint")] == [ + "checkpoint" + ] + + +@pytest.mark.asyncio +async def test_price_alert_since_tracks_the_crossing_and_clears(tmp_path): + service = _price_service( + tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=300 + ) + scanner = DummyScanner(LOCAL_RAW_DATA) + + first = await service.refresh_for_model_type( + "lora", + scanner, + PriceProvider(_gated_response("2999-01-01T00:00:00.000Z"), prices=_prices(250)), + ) + since = first[1].versions[0].price_alert_since + assert since is not None + + # Still under the threshold: the original crossing time is preserved. + second = await service.refresh_for_model_type( + "lora", + scanner, + PriceProvider(_gated_response("2999-02-01T00:00:00.000Z"), prices=_prices(200)), + ) + assert second[1].versions[0].price_alert_since == since + stored = await service.get_record("lora", 1) + assert stored.versions[0].price_alert_since == since + + # Back above it: cleared, and the row leaves the panel. + third = await service.refresh_for_model_type( + "lora", + scanner, + PriceProvider(_gated_response("2999-03-01T00:00:00.000Z"), prices=_prices(900)), + ) + assert third[1].versions[0].price_alert_since is None + assert await service.get_price_alerts("lora") == [] + + +@pytest.mark.asyncio +async def test_price_alerts_report_became_free_without_price_tracking(tmp_path): + """Gate transitions need no price data, so the "became free" half of the panel + works even while price tracking is switched off.""" + + service = _price_service(tmp_path) # tracking off + scanner = DummyScanner(LOCAL_RAW_DATA) + + await service.refresh_for_model_type("lora", scanner, DummyProvider(GATED_RESPONSE)) + assert await service.get_price_alerts("lora") == [] + + await service.refresh_for_model_type("lora", scanner, DummyProvider(FREE_RESPONSE)) + alerts = await service.get_price_alerts("lora") + + assert [alert["kind"] for alert in alerts] == ["became_free"] + assert alerts[0]["priceBuzz"] is None + assert alerts[0]["gateLapsedAt"] is not None + assert alerts[0]["versionId"] == 12 + + +@pytest.mark.asyncio +async def test_newest_price_checked_at_reports_the_latest_fetch(tmp_path): + service = _price_service(tmp_path, price_tracking_enabled=True) + scanner = DummyScanner(LOCAL_RAW_DATA) + + assert service.newest_price_checked_at() is None + + await service.refresh_for_model_type( + "lora", scanner, PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD) + ) + newest = service.newest_price_checked_at() + + assert newest is not None + assert newest > 0 From ec5fef512b08be0573a7f383a1ca00c696c0115d Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sun, 4 Oct 2026 20:00:29 +0800 Subject: [PATCH 04/10] fix(update): read model-page prices from a host that answers End-to-end verification against the live site found the price capture broken for a whole class of users: the civitai page hosts are not interchangeable, and the user's civitai_host preference was silently fatal. With civitai_host=civitai.red the update DB held zero prices even with tracking enabled. - civitai.red refuses non-browser HTTP clients outright (Cloudflare challenge, 403 for any User-Agent, aiohttp and httpx alike), while civitai.com and civitai.green answer normally for anonymously visible models and 404 for mature ones. An earlier manual check with curl passed on TLS fingerprint luck, which is why this was missed. - get_model_prices now tries the configured host first, then the others, and takes the first parseable payload. The host that worked is remembered, and a host that refuses outright is parked for 15 minutes so a library full of mature models does not pay three requests each; a 404 is model-specific and does not park the host. Links keep using the configured host, which is where the user's own browser has clearance. - Mature models still have no price source anywhere, so that is now stated instead of silent: price_check_attempted_at separates "tried and unreadable" from "never looked", gated versions show a muted "Price unavailable" badge, and the alerts panel reports unavailableCount. - Failures are logged at warning level, once per host per TTL, with the per-host reason, instead of only at debug level. - The recorded alternatives (internal tRPC with the user's API key, or an extension-assisted fetch from the user's browser) and the strengthened upstream ask for a public price field are documented in the plan. --- docs/plans/paid-model-price-tracking.md | 52 ++++++ locales/de.json | 7 +- locales/en.json | 7 +- locales/es.json | 7 +- locales/fr.json | 7 +- locales/he.json | 7 +- locales/ja.json | 7 +- locales/ko.json | 7 +- locales/ru.json | 7 +- locales/zh-CN.json | 7 +- locales/zh-TW.json | 7 +- py/routes/handlers/misc_handlers.py | 4 + py/routes/handlers/model_handlers.py | 4 + py/services/civitai_client.py | 154 ++++++++++++++---- py/services/model_update_service.py | 109 ++++++++++--- py/utils/civitai_utils.py | 15 ++ .../js/components/shared/ModelVersionsTab.js | 14 ++ static/js/managers/UpdateService.js | 16 +- .../modelVersionsTab.downloadButton.test.js | 93 +++++++++++ .../managers/updateServicePriceAlerts.test.js | 15 ++ tests/routes/test_misc_routes.py | 7 +- tests/services/test_civitai_client.py | 102 ++++++++++++ tests/services/test_model_update_service.py | 71 ++++++++ 23 files changed, 653 insertions(+), 73 deletions(-) diff --git a/docs/plans/paid-model-price-tracking.md b/docs/plans/paid-model-price-tracking.md index 95c054c7..8555e6b6 100644 --- a/docs/plans/paid-model-price-tracking.md +++ b/docs/plans/paid-model-price-tracking.md @@ -600,3 +600,55 @@ Verification: `pytest` 3652 passed / 7 skipped, `npm run test:js` 1444 passed, p standalone server run that seeded the update DB and confirmed the payload shape, the `kind` split, the `civitaiUrl`, and that changing `price_alert_threshold_buzz` through `POST /api/lm/settings` changes panel membership immediately with no refresh. + +### 11.10 Known limitation: mature (NSFW) model prices, and the host fallback + +End-to-end verification against the live site found that the page fetch was broken for a whole class +of users: **the hosts are not interchangeable**, and the user's `civitai_host` preference was +silently fatal. + +Measured with the app's own HTTP stack (aiohttp) and with httpx, browser User-Agent in both cases: + +| Target | civitai.com | civitai.green | civitai.red | +| --- | --- | --- | --- | +| Anonymously visible model page | 200, prices parse | 200, prices parse (same bytes as .com) | **403 Cloudflare challenge** | +| Mature (NSFW) model page | 404 | 404 | **403 Cloudflare challenge** | + +* `civitai.red` refuses non-browser clients outright — User-Agent, `Accept-Language`, `Sec-Fetch-*` + and switching HTTP library all make no difference. (An earlier manual check with `curl` passed by + luck of TLS fingerprint, which is why this was missed: it was a false positive.) +* A real user on `civitai_host=civitai.red` therefore captured **zero** prices + (`price_checked_at = 0` in their update DB) even with tracking enabled. +* Mature models are hidden from anonymous visitors on `.com`/`.green` and only served by `.red`, so + **no host can currently read their price.** The public API cannot help: it trims `paidAccess` to + `{permanent, endsAt}` by design, and the public `mini/{id}` endpoint exposes only per-generation + `fees`. + +What P5a now does about it: + +* **Host fallback** (`CivitaiClient.get_model_prices`): the configured host is tried first, then the + others (`civitai_page_host_candidates`), first parseable payload wins. The host that worked is + remembered, and a host that refuses outright (403) is parked for 15 minutes — a host-wide failure + must not cost three requests per mature model in the library. A 404 is model-specific and does + **not** park the host. +* **Honest "unavailable" state**: `price_check_attempted_at` separates "we tried and could not read + a price" from "we never looked". Gated versions in that state show a muted `Price unavailable` + badge in the versions tab, and the panel reports `unavailableCount`. +* **Diagnostics**: a host refusal is warned once per host per TTL, and a model with no price source + logs the per-host reasons (404 vs challenge) instead of failing silently at debug level. + +Recorded options for mature models, deliberately **not** implemented: + +1. **Internal tRPC with the user's API key** (`modelVersion.getById` on `.red`): the route is a + `publicProcedure` with `requiredScope: ModelsRead`, and `isBearerAuth` satisfies + `acceptableOrigin`, so the user's own key would work and `.red`'s `/api` paths are not challenged. + Rejected as the default because the endpoint is undocumented and its own 401 message says to use + the public API. If ever wanted, it belongs behind an off-by-default setting. +2. **Extension-assisted fetch**: `lm-civitai-extension` runs inside the user's browser, so it has + both the Cloudflare clearance and the login session needed to read mature pages. This is the only + route that would work without an undocumented API, but it is a cross-component design of its own. + +**Strengthened upstream ask:** the public API should expose the price. The argument is no longer +"convenience" — the page route is demonstrably unreliable (one host challenges non-browser clients, +the other two hide mature models from anonymous visitors), so a supported field is the only way for +any third-party tool to show prices for the models where creators monetize most. diff --git a/locales/de.json b/locales/de.json index 8b65c215..c432c06d 100644 --- a/locales/de.json +++ b/locales/de.json @@ -2037,7 +2037,9 @@ "ignored": "Ignoriert", "ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert", "onSiteOnly": "Nur On-Site", - "onSiteOnlyTooltip": "Diese Version ist nur für die On-Site-Generierung auf CivitAI verfügbar" + "onSiteOnlyTooltip": "Diese Version ist nur für die On-Site-Generierung auf CivitAI verfügbar", + "priceUnavailable": "[TODO: Translate] Price unavailable", + "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" }, "actions": { "download": "Herunterladen", @@ -2408,7 +2410,8 @@ "notInLibrary": "[TODO: Translate] Not in library", "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", - "openLocal": "[TODO: Translate] Open" + "openLocal": "[TODO: Translate] Open", + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" } }, "support": { diff --git a/locales/en.json b/locales/en.json index e877ea94..b29554f0 100644 --- a/locales/en.json +++ b/locales/en.json @@ -2037,7 +2037,9 @@ "ignored": "Ignored", "ignoredTooltip": "Update notifications are disabled for this version", "onSiteOnly": "On-Site Only", - "onSiteOnlyTooltip": "This version is only available for on-site generation on CivitAI" + "onSiteOnlyTooltip": "This version is only available for on-site generation on CivitAI", + "priceUnavailable": "Price unavailable", + "priceUnavailableTooltip": "CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" }, "actions": { "download": "Download", @@ -2408,7 +2410,8 @@ "notInLibrary": "Not in library", "droppedAgo": "dropped {when}", "openCivitai": "Open on CivitAI", - "openLocal": "Open" + "openLocal": "Open", + "unavailable": "{count} paid version(s) have no readable price (mature models can only be read in a browser)" } }, "support": { diff --git a/locales/es.json b/locales/es.json index aea10e71..f7f12ee2 100644 --- a/locales/es.json +++ b/locales/es.json @@ -2037,7 +2037,9 @@ "ignored": "Ignorada", "ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión", "onSiteOnly": "Solo en Sitio", - "onSiteOnlyTooltip": "Esta versión solo está disponible para generación en el sitio de CivitAI" + "onSiteOnlyTooltip": "Esta versión solo está disponible para generación en el sitio de CivitAI", + "priceUnavailable": "[TODO: Translate] Price unavailable", + "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" }, "actions": { "download": "Descargar", @@ -2408,7 +2410,8 @@ "notInLibrary": "[TODO: Translate] Not in library", "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", - "openLocal": "[TODO: Translate] Open" + "openLocal": "[TODO: Translate] Open", + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" } }, "support": { diff --git a/locales/fr.json b/locales/fr.json index 81b44219..5f19be0a 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -2037,7 +2037,9 @@ "ignored": "Ignorée", "ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version", "onSiteOnly": "Uniquement sur Site", - "onSiteOnlyTooltip": "Cette version n'est disponible que pour la génération sur le site CivitAI" + "onSiteOnlyTooltip": "Cette version n'est disponible que pour la génération sur le site CivitAI", + "priceUnavailable": "[TODO: Translate] Price unavailable", + "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" }, "actions": { "download": "Télécharger", @@ -2408,7 +2410,8 @@ "notInLibrary": "[TODO: Translate] Not in library", "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", - "openLocal": "[TODO: Translate] Open" + "openLocal": "[TODO: Translate] Open", + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" } }, "support": { diff --git a/locales/he.json b/locales/he.json index fd9249ef..7001a5c6 100644 --- a/locales/he.json +++ b/locales/he.json @@ -2037,7 +2037,9 @@ "ignored": "התעלם", "ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו", "onSiteOnly": "רק באתר", - "onSiteOnlyTooltip": "גרסה זו זמינה רק ליצירה באתר CivitAI" + "onSiteOnlyTooltip": "גרסה זו זמינה רק ליצירה באתר CivitAI", + "priceUnavailable": "[TODO: Translate] Price unavailable", + "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" }, "actions": { "download": "הורדה", @@ -2408,7 +2410,8 @@ "notInLibrary": "[TODO: Translate] Not in library", "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", - "openLocal": "[TODO: Translate] Open" + "openLocal": "[TODO: Translate] Open", + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" } }, "support": { diff --git a/locales/ja.json b/locales/ja.json index f7c2e006..e537c576 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -2037,7 +2037,9 @@ "ignored": "無視中", "ignoredTooltip": "このバージョンの更新通知は無効です", "onSiteOnly": "サイト内のみ", - "onSiteOnlyTooltip": "このバージョンはCivitAIサイト内でのみ利用可能で、ダウンロードはできません" + "onSiteOnlyTooltip": "このバージョンはCivitAIサイト内でのみ利用可能で、ダウンロードはできません", + "priceUnavailable": "[TODO: Translate] Price unavailable", + "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" }, "actions": { "download": "ダウンロード", @@ -2408,7 +2410,8 @@ "notInLibrary": "[TODO: Translate] Not in library", "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", - "openLocal": "[TODO: Translate] Open" + "openLocal": "[TODO: Translate] Open", + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" } }, "support": { diff --git a/locales/ko.json b/locales/ko.json index a16157ad..d82f271b 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -2037,7 +2037,9 @@ "ignored": "무시됨", "ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다", "onSiteOnly": "사이트 내 전용", - "onSiteOnlyTooltip": "이 버전은 CivitAI 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다" + "onSiteOnlyTooltip": "이 버전은 CivitAI 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다", + "priceUnavailable": "[TODO: Translate] Price unavailable", + "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" }, "actions": { "download": "다운로드", @@ -2408,7 +2410,8 @@ "notInLibrary": "[TODO: Translate] Not in library", "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", - "openLocal": "[TODO: Translate] Open" + "openLocal": "[TODO: Translate] Open", + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" } }, "support": { diff --git a/locales/ru.json b/locales/ru.json index 83312489..986e9eba 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -2037,7 +2037,9 @@ "ignored": "Игнорируется", "ignoredTooltip": "Уведомления об обновлениях для этой версии отключены", "onSiteOnly": "Только на Сайте", - "onSiteOnlyTooltip": "Эта версия доступна только для генерации на сайте CivitAI" + "onSiteOnlyTooltip": "Эта версия доступна только для генерации на сайте CivitAI", + "priceUnavailable": "[TODO: Translate] Price unavailable", + "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" }, "actions": { "download": "Скачать", @@ -2408,7 +2410,8 @@ "notInLibrary": "[TODO: Translate] Not in library", "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", - "openLocal": "[TODO: Translate] Open" + "openLocal": "[TODO: Translate] Open", + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" } }, "support": { diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 0d68a8f5..9a854ad4 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -2037,7 +2037,9 @@ "ignored": "已忽略", "ignoredTooltip": "此版本已关闭更新通知", "onSiteOnly": "仅站内生成", - "onSiteOnlyTooltip": "此版本仅在 CivitAI 站内可用,无法下载" + "onSiteOnlyTooltip": "此版本仅在 CivitAI 站内可用,无法下载", + "priceUnavailable": "[TODO: Translate] Price unavailable", + "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" }, "actions": { "download": "下载", @@ -2408,7 +2410,8 @@ "notInLibrary": "[TODO: Translate] Not in library", "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", - "openLocal": "[TODO: Translate] Open" + "openLocal": "[TODO: Translate] Open", + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" } }, "support": { diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 9b650d42..0d3162fb 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -2037,7 +2037,9 @@ "ignored": "已忽略", "ignoredTooltip": "此版本已關閉更新通知", "onSiteOnly": "僅站內生成", - "onSiteOnlyTooltip": "此版本僅在 CivitAI 站內可用,無法下載" + "onSiteOnlyTooltip": "此版本僅在 CivitAI 站內可用,無法下載", + "priceUnavailable": "[TODO: Translate] Price unavailable", + "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" }, "actions": { "download": "下載", @@ -2408,7 +2410,8 @@ "notInLibrary": "[TODO: Translate] Not in library", "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", - "openLocal": "[TODO: Translate] Open" + "openLocal": "[TODO: Translate] Open", + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" } }, "support": { diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index 0044cc12..d49abbde 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -4384,6 +4384,7 @@ class PriceAlertsHandler: None, threshold_buzz=threshold, limit=limit ) newest_checked_at = update_service.newest_price_checked_at() + unavailable_count = update_service.count_unavailable_prices() except Exception as exc: logger.error("Failed to load price alerts: %s", exc, exc_info=True) return web.json_response( @@ -4406,6 +4407,9 @@ class PriceAlertsHandler: "enabled": bool(self._setting("price_tracking_enabled", False)), "thresholdBuzz": threshold, "newestCheckedAt": newest_checked_at, + # Gated versions whose price could not be read (mature models + # are served only by the challenged civitai.red host). + "unavailableCount": unavailable_count, "alerts": alerts, } ) diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index 2640a6e0..4d090821 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -3511,6 +3511,10 @@ class ModelUpdateHandler: "acceptsBlueBuzz": bool(getattr(version, "accepts_blue_buzz", False)), "priceSaleEndsAt": getattr(version, "price_sale_ends_at", None), "priceCheckedAt": getattr(version, "price_checked_at", None), + # Set when a price fetch was attempted (success or not): a gated + # version with no price and an attempt marker is "unavailable", + # which is the honest state for mature models. + "priceAttemptedAt": getattr(version, "price_check_attempted_at", None), "priceAlert": bool(getattr(version, "price_alert_state", False)), "filePath": context.get("file_path"), "fileName": context.get("file_name"), diff --git a/py/services/civitai_client.py b/py/services/civitai_client.py index f0ddffce..ed8a3c30 100644 --- a/py/services/civitai_client.py +++ b/py/services/civitai_client.py @@ -20,7 +20,11 @@ from .model_metadata_provider import ( ) from .downloader import get_downloader from .errors import RateLimitError, ResourceNotFoundError -from ..utils.civitai_utils import build_civitai_model_page_url, resolve_license_payload +from ..utils.civitai_utils import ( + build_civitai_model_page_url, + civitai_page_host_candidates, + resolve_license_payload, +) from ..utils.civitai_page_prices import parse_model_page_prices from ..utils.constants import MODEL_WEIGHT_FILE_TYPES, is_empty_placeholder_hash @@ -32,6 +36,24 @@ logger = logging.getLogger(__name__) _CREATOR_COUNT_CACHE_TTL_SECONDS = 600 _creator_model_count_cache: Dict[str, Tuple[float, Optional[int]]] = {} +# How long a page host stays on the skip list after refusing a request outright +# (Cloudflare's challenge, surfaced as 403 "Access forbidden"). Long enough to +# cover a whole update refresh, short enough to recover within a session. +_PAGE_HOST_BLOCK_TTL = 15 * 60 + + +def _is_host_level_refusal(message: str) -> bool: + """Whether a failed request means "this host refuses us" rather than "this + model is unavailable". + + ``downloader.make_request`` collapses statuses into prose, and 403 ("Access + forbidden") is the one a Cloudflare challenge produces. A 404 ("Resource not + found") is model-specific — mature pages are hidden from anonymous visitors — + so it must not put the whole host on the skip list. + """ + + return "forbidden" in message.lower() + class CivitaiClient: _instance = None @@ -67,6 +89,10 @@ class CivitaiClient: str, Tuple[Optional[Dict[str, Any]], Optional[str]] ] = OrderedDict() self._MAX_CACHE_ENTRIES = 500 + # Model-page host bookkeeping: which host last worked, and which ones are + # currently refusing us (see get_model_prices). + self._page_host_preference: Optional[str] = None + self._page_host_blocked: Dict[str, float] = {} def _build_image_info_url(self, image_id: str) -> str: return f"{self.base_url}/images?imageId={image_id}&nsfw=X&withMeta=true" @@ -396,6 +422,19 @@ class CivitaiClient: empty dict when the page loads but lists no gated version, or None when the page could not be read or understood — callers keep any stored price. + Several hosts are tried in order, because the hosts are not equivalent: + + * ``civitai.red`` serves mature model pages that ``civitai.com`` hides from + anonymous visitors, but it is behind a Cloudflare challenge that refuses + non-browser clients outright (403 for any User-Agent). + * ``civitai.com`` / ``civitai.green`` answer normally for anonymously + visible models, and 404 for the mature ones. + + So the user's ``civitai_host`` preference is a starting point, not the only + option. Mature models whose page cannot be read from any host stay + priceless — see the known limitation in + ``docs/plans/paid-model-price-tracking.md``. + This is a public anonymous page fetch: no API key and no internal endpoint, so a failure here must never fail the update check itself. """ @@ -405,39 +444,92 @@ class CivitaiClient: except (TypeError, ValueError): return None - url = build_civitai_model_page_url(normalized_id, host=self._page_host()) - if not url: - return None + candidates = self._page_host_candidates() + failures: List[str] = [] - try: - success, result = await self._make_request( - "GET", - url, - use_auth=False, - custom_headers={"Accept": "text/html"}, - ) - except RateLimitError: - # The shared rate-limit gate already recorded it; skip this model. - raise - except Exception as exc: # pragma: no cover - defensive - logger.debug("Failed to fetch model page for %s: %s", model_id, exc) - return None + for host in candidates: + url = build_civitai_model_page_url(normalized_id, host=host) + if not url: + continue - if not success or not isinstance(result, str): - logger.debug( - "No model page payload for %s (success=%s, type=%s)", - model_id, - success, - type(result).__name__, - ) - return None + try: + success, result = await self._make_request( + "GET", + url, + use_auth=False, + custom_headers={"Accept": "text/html"}, + ) + except RateLimitError: + # The shared rate-limit gate already recorded it; skip this model. + raise + except Exception as exc: # pragma: no cover - defensive + failures.append(f"{host}: {exc}") + continue - prices = parse_model_page_prices(result) - if prices is None: - logger.debug( - "Model page for %s carried no usable price payload", model_id - ) - return prices + if not success or not isinstance(result, str): + message = result if isinstance(result, str) else type(result).__name__ + failures.append(f"{host}: {message}") + if isinstance(result, str) and _is_host_level_refusal(result): + # A refusal like Cloudflare's "Access forbidden" applies to the + # host, not to this model, so stop paying for it for a while. + self._block_page_host(host) + continue + + prices = parse_model_page_prices(result) + if prices is None: + failures.append(f"{host}: no usable price payload") + continue + + self._remember_page_host(host) + return prices + + logger.warning( + "No price source for model %s; tried %s. Mature models are only served " + "by civitai.red, which challenges non-browser clients.", + model_id, + "; ".join(failures) or "no candidate hosts", + ) + return None + + def _page_host_candidates(self) -> List[str]: + """Ordered hosts to try: the last one that worked, then the preference.""" + + preferred = self._page_host() + ordered = list(civitai_page_host_candidates(preferred)) + if self._page_host_preference and self._page_host_preference in ordered: + ordered.remove(self._page_host_preference) + ordered.insert(0, self._page_host_preference) + + now = time.time() + usable = [ + host + for host in ordered + if now - self._page_host_blocked.get(host, 0.0) >= _PAGE_HOST_BLOCK_TTL + ] + # Never return an empty list: a blocked host is still better than no attempt + # once the preference and the memo disagree. + return usable or ordered + + def _remember_page_host(self, host: str) -> None: + if self._page_host_preference != host: + logger.info("CivitAI model pages are being read from %s", host) + self._page_host_preference = host + self._page_host_blocked.pop(host, None) + + def _block_page_host(self, host: str) -> None: + if host in self._page_host_blocked: + return + # Log once per host per TTL: the failure is host-wide, so repeating it for + # every mature model in the library would be pure noise. + logger.warning( + "CivitAI model pages on %s refused the request (likely a Cloudflare " + "challenge); skipping that host for %d minutes", + host, + _PAGE_HOST_BLOCK_TTL // 60, + ) + self._page_host_blocked[host] = time.time() + if self._page_host_preference == host: + self._page_host_preference = None def _page_host(self) -> Optional[str]: """Resolve the page host from the ``civitai_host`` setting.""" diff --git a/py/services/model_update_service.py b/py/services/model_update_service.py index 28ee8d32..10fdce4e 100644 --- a/py/services/model_update_service.py +++ b/py/services/model_update_service.py @@ -128,6 +128,10 @@ class ModelVersionRecord: # price rises back above the threshold. Gives the panel "dropped X ago" and # the unread count something to compare against. price_alert_since: Optional[float] = None + # When a price fetch was last *attempted* (success or failure). Distinguishes + # "never tried" from "tried and no price is readable", which is what lets the + # UI say "price unavailable" for mature models instead of showing nothing. + price_check_attempted_at: Optional[float] = None @dataclass @@ -379,6 +383,7 @@ class ModelUpdateService: price_checked_at REAL, price_alert_state INTEGER NOT NULL DEFAULT 0, price_alert_since REAL, + price_check_attempted_at REAL, PRIMARY KEY (model_id, version_id), FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE ); @@ -666,6 +671,10 @@ class ModelUpdateService: "ALTER TABLE model_update_versions " "ADD COLUMN price_alert_since REAL" ), + "price_check_attempted_at": ( + "ALTER TABLE model_update_versions " + "ADD COLUMN price_check_attempted_at REAL" + ), } for column, statement in migrations.items(): @@ -779,6 +788,7 @@ class ModelUpdateService: price_checked_at REAL, price_alert_state INTEGER NOT NULL DEFAULT 0, price_alert_since REAL, + price_check_attempted_at REAL, PRIMARY KEY (model_id, version_id), FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE ) @@ -810,6 +820,7 @@ class ModelUpdateService: "price_checked_at", "price_alert_state", "price_alert_since", + "price_check_attempted_at", ] defaults = { "sort_index": "0", @@ -834,6 +845,7 @@ class ModelUpdateService: "price_checked_at": "NULL", "price_alert_state": "0", "price_alert_since": "NULL", + "price_check_attempted_at": "NULL", } select_parts = [] @@ -1350,6 +1362,39 @@ class ModelUpdateService: return None return float(row["newest"]) + def count_unavailable_prices(self, model_type: Optional[str] = None) -> int: + """Gated versions whose price we tried to read and could not. + + Mostly mature models: their pages are served only by ``civitai.red``, which + refuses non-browser clients, while ``civitai.com`` hides them from + anonymous visitors. Reported so the UI can be honest instead of silent. + """ + + params: List[Any] = [] + type_filter = "" + if model_type: + type_filter = "AND s.model_type = ?" + params.append(model_type) + + with self._connect() as conn: + row = conn.execute( + f""" + SELECT COUNT(*) AS unavailable + FROM model_update_versions v + JOIN model_update_status s ON s.model_id = v.model_id + WHERE v.should_ignore = 0 + AND s.should_ignore_model = 0 + {type_filter} + AND v.price_check_attempted_at IS NOT NULL + AND v.price_buzz IS NULL + AND (v.paid_access IS NOT NULL OR v.is_paid = 1 OR v.is_early_access = 1) + """, + tuple(params), + ).fetchone() + if row is None: + return 0 + return int(row["unavailable"]) + async def _refresh_single_model( self, model_type: str, @@ -2079,6 +2124,7 @@ class ModelUpdateService: "price_checked_at": None, "price_alert_state": False, "price_alert_since": None, + "price_check_attempted_at": None, } if remote_version.price_checked_at is not None: @@ -2095,6 +2141,7 @@ class ModelUpdateService: "price_checked_at": source.price_checked_at, "price_alert_state": source.price_alert_state, "price_alert_since": source.price_alert_since, + "price_check_attempted_at": source.price_check_attempted_at, } def _price_tracking_enabled(self) -> bool: @@ -2186,42 +2233,60 @@ class ModelUpdateService: Never raises for a provider problem: price tracking is a convenience, and an unreadable page (or a provider that has no prices at all) must leave the update check exactly as it was. + + A failed attempt is still recorded (``price_check_attempted_at``) so the UI + can distinguish "we could not read a price" from "we never looked" — that + is the honest state for mature models, whose pages are served only by + civitai.red, which refuses non-browser clients. """ getter = getattr(metadata_provider, "get_model_prices", None) if not callable(getter): return list(versions) + attempted_at = time.time() try: prices = await getter(model_id) except RateLimitError: raise except Exception as exc: # pragma: no cover - defensive logger.debug("Price fetch failed for model %s: %s", model_id, exc) - return list(versions) + prices = None - if not isinstance(prices, Mapping) or not prices: - return list(versions) + if not isinstance(prices, Mapping): + prices = {} - checked_at = time.time() enriched: List[ModelVersionRecord] = [] for version in versions: + if not self._has_structural_gate(version): + enriched.append(version) + continue + fields = prices.get(version.version_id) - if not isinstance(fields, Mapping): - # Not priced (or not understood): leave the stored values alone so - # the next refresh retries instead of recording a blank price. - enriched.append(version) - continue - recognized = { - key: value - for key, value in fields.items() - if key in _PRICE_FIELD_NAMES - } + recognized = ( + { + key: value + for key, value in fields.items() + if key in _PRICE_FIELD_NAMES + } + if isinstance(fields, Mapping) + else {} + ) if not recognized: - enriched.append(version) + # Keep the stored price (there may be none) but remember the try, so + # the UI can say "unavailable" instead of showing nothing at all. + enriched.append( + replace(version, price_check_attempted_at=attempted_at) + ) continue + enriched.append( - replace(version, price_checked_at=checked_at, **recognized) + replace( + version, + price_checked_at=attempted_at, + price_check_attempted_at=attempted_at, + **recognized, + ) ) return enriched @@ -2468,7 +2533,7 @@ class ModelUpdateService: is_early_access, usage_control, paid_access, is_paid, file_count, gate_lapsed_at, price_buzz, list_price_buzz, generation_price_buzz, accepts_blue_buzz, price_sale_ends_at, price_checked_at, price_alert_state, - price_alert_since + price_alert_since, price_check_attempted_at FROM model_update_versions WHERE model_id IN ({placeholders}) ORDER BY model_id ASC, sort_index ASC, version_id ASC @@ -2517,6 +2582,11 @@ class ModelUpdateService: if row["price_alert_since"] is not None else None ), + price_check_attempted_at=( + float(row["price_check_attempted_at"]) + if row["price_check_attempted_at"] is not None + else None + ), ) ) @@ -2582,8 +2652,8 @@ class ModelUpdateService: is_early_access, usage_control, paid_access, is_paid, file_count, gate_lapsed_at, price_buzz, list_price_buzz, generation_price_buzz, accepts_blue_buzz, price_sale_ends_at, price_checked_at, price_alert_state, - price_alert_since - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + price_alert_since, price_check_attempted_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( version.version_id, @@ -2611,6 +2681,7 @@ class ModelUpdateService: version.price_checked_at, 1 if version.price_alert_state else 0, version.price_alert_since, + version.price_check_attempted_at, ), ) conn.commit() diff --git a/py/utils/civitai_utils.py b/py/utils/civitai_utils.py index 9466d0dd..ceacdc77 100644 --- a/py/utils/civitai_utils.py +++ b/py/utils/civitai_utils.py @@ -41,6 +41,21 @@ def normalize_civitai_page_host(hostname: str | None) -> str: return DEFAULT_CIVITAI_PAGE_HOST +def civitai_page_host_candidates(hostname: str | None = None) -> tuple[str, ...]: + """Ordered page hosts to try, the preferred one first. + + The hosts are not interchangeable: ``civitai.red`` serves mature model pages + that ``civitai.com`` hides from anonymous visitors, but it also sits behind a + Cloudflare challenge that rejects non-browser HTTP clients outright. Trying the + others when the preferred host refuses a request is what keeps a user's + ``civitai_host`` preference from disabling page reads entirely. + """ + + preferred = normalize_civitai_page_host(hostname) + rest = sorted(_SUPPORTED_CIVITAI_PAGE_HOSTS - {preferred}) + return (preferred, *rest) + + def build_civitai_model_page_url( model_id: str | int | None, version_id: str | int | None = None, diff --git a/static/js/components/shared/ModelVersionsTab.js b/static/js/components/shared/ModelVersionsTab.js index 2dea9b33..6a5fa695 100644 --- a/static/js/components/shared/ModelVersionsTab.js +++ b/static/js/components/shared/ModelVersionsTab.js @@ -609,6 +609,20 @@ function renderRow(version, options) { ) : buildPriceTooltip(version, paidBadgeLabel), })); + } else if (isGated(version) && version.priceAttemptedAt) { + // We looked and could not read a price. Staying silent would read as + // "free", which is the one thing this version is not. + badges.push(buildBadge( + translate('modals.model.versions.badges.priceUnavailable', {}, 'Price unavailable'), + 'muted', + { + title: translate( + 'modals.model.versions.badges.priceUnavailableTooltip', + {}, + 'CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser' + ), + } + )); } // A version that used to be gated and no longer is. `gateLapsedAt` is diff --git a/static/js/managers/UpdateService.js b/static/js/managers/UpdateService.js index 12c77c0c..d82ece99 100644 --- a/static/js/managers/UpdateService.js +++ b/static/js/managers/UpdateService.js @@ -28,7 +28,8 @@ async function fetchPriceAlerts(limit = 200) { alerts: Array.isArray(payload.alerts) ? payload.alerts : [], enabled: payload.enabled === true, thresholdBuzz: payload.thresholdBuzz ?? 0, - newestCheckedAt: payload.newestCheckedAt ?? null + newestCheckedAt: payload.newestCheckedAt ?? null, + unavailableCount: payload.unavailableCount ?? 0 }; } @@ -112,6 +113,7 @@ export class UpdateService { this.priceAlertsEnabled = false; this.priceAlertsThreshold = 0; this.priceAlertsNewestCheckedAt = null; + this.priceAlertsUnavailableCount = 0; this.priceAlertsLoading = false; this.priceAlertSegment = 'below_threshold'; this.unreadPriceAlertCount = 0; @@ -404,6 +406,7 @@ export class UpdateService { this.priceAlertsEnabled = payload.enabled; this.priceAlertsThreshold = payload.thresholdBuzz; this.priceAlertsNewestCheckedAt = payload.newestCheckedAt; + this.priceAlertsUnavailableCount = payload.unavailableCount || 0; this.refreshUnreadPriceAlertCount(); this.renderPriceAlerts(); return this.priceAlerts; @@ -467,7 +470,7 @@ export class UpdateService { } if (stale) { const checkedAt = this.priceAlertsNewestCheckedAt; - const staleText = error + let staleText = error ? translate( 'update.priceAlerts.loadFailed', {}, @@ -480,6 +483,15 @@ export class UpdateService { `Prices last checked ${formatRelativeTime(checkedAt)}` ) : ''; + const unavailable = this.priceAlertsUnavailableCount; + if (unavailable > 0) { + const unavailableText = translate( + 'update.priceAlerts.unavailable', + { count: unavailable }, + `${unavailable} paid version(s) have no readable price (mature models can only be read in a browser)` + ); + staleText = staleText ? `${staleText} · ${unavailableText}` : unavailableText; + } stale.textContent = staleText; stale.classList.toggle('hidden', !staleText); } diff --git a/tests/frontend/components/modelVersionsTab.downloadButton.test.js b/tests/frontend/components/modelVersionsTab.downloadButton.test.js index 5510ebac..a4ffd5fe 100644 --- a/tests/frontend/components/modelVersionsTab.downloadButton.test.js +++ b/tests/frontend/components/modelVersionsTab.downloadButton.test.js @@ -283,3 +283,96 @@ describe('ModelVersionsTab download button visibility', () => { expect(openFileSelectionForVersion).not.toHaveBeenCalled(); }); }); + +describe('ModelVersionsTab price badges', () => { + let getModelApiClient; + let fetchModelUpdateVersions; + + beforeEach(async () => { + vi.resetModules(); + document.body.innerHTML = ` +
    +
    +
    +
    +
    + `; + ({ getModelApiClient } = await import(API_FACTORY_MODULE)); + fetchModelUpdateVersions = vi.fn(); + getModelApiClient.mockReturnValue({ + fetchModelUpdateVersions, + fetchModelRoots: vi.fn(), + setModelUpdateIgnore: vi.fn(), + setVersionUpdateIgnore: vi.fn(), + deleteModel: vi.fn(), + }); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + function rowFor(versionId) { + return document.querySelector(`.model-version-row[data-version-id="${versionId}"]`); + } + + it('shows the price for a gated version whose price was captured', async () => { + fetchModelUpdateVersions.mockResolvedValue(buildRecord([ + { + versionId: 20, + name: 'Alpha', + isInLibrary: false, + shouldIgnore: false, + isPaid: true, + paidAccess: { permanent: true, endsAt: null }, + priceBuzz: 5000, + listPriceBuzz: 5000, + priceCheckedAt: 1791039694.5, + priceAttemptedAt: 1791039694.5, + }, + ])); + + await renderVersions(); + + expect(rowFor(20).textContent).toContain('5,000 Buzz'); + }); + + it('marks a gated version with no readable price as unavailable', async () => { + // The mature-model case: we looked, no host would serve the page. + fetchModelUpdateVersions.mockResolvedValue(buildRecord([ + { + versionId: 21, + name: 'Beta', + isInLibrary: false, + shouldIgnore: false, + isPaid: true, + paidAccess: { permanent: true, endsAt: null }, + priceBuzz: null, + priceAttemptedAt: 1791039694.5, + }, + ])); + + await renderVersions(); + + expect(rowFor(21).textContent).toContain('Price unavailable'); + }); + + it('stays quiet when the price was never looked up', async () => { + fetchModelUpdateVersions.mockResolvedValue(buildRecord([ + { + versionId: 22, + name: 'Gamma', + isInLibrary: false, + shouldIgnore: false, + isPaid: true, + paidAccess: { permanent: true, endsAt: null }, + priceBuzz: null, + priceAttemptedAt: null, + }, + ])); + + await renderVersions(); + + expect(rowFor(22).textContent).not.toContain('Price unavailable'); + }); +}); diff --git a/tests/frontend/managers/updateServicePriceAlerts.test.js b/tests/frontend/managers/updateServicePriceAlerts.test.js index e94b165b..a772dd82 100644 --- a/tests/frontend/managers/updateServicePriceAlerts.test.js +++ b/tests/frontend/managers/updateServicePriceAlerts.test.js @@ -203,6 +203,21 @@ describe('UpdateService price alerts panel', () => { expect(document.querySelectorAll('#priceAlertsList .price-alert-item')).toHaveLength(0); }); + it('reports how many gated versions have no readable price', async () => { + global.fetch = vi.fn().mockResolvedValue( + createFetchResponse( + alertPayload([BELOW_THRESHOLD_ALERT], { unavailableCount: 3 }) + ) + ); + + await service.loadPriceAlerts({ force: true }); + + expect(service.priceAlertsUnavailableCount).toBe(3); + const note = document.getElementById('priceAlertsStale'); + expect(note.classList.contains('hidden')).toBe(false); + expect(note.textContent).toContain('3 paid version(s) have no readable price'); + }); + it('keeps the last known list when the request fails', async () => { global.fetch = vi .fn() diff --git a/tests/routes/test_misc_routes.py b/tests/routes/test_misc_routes.py index adae23d6..8719cf23 100644 --- a/tests/routes/test_misc_routes.py +++ b/tests/routes/test_misc_routes.py @@ -2950,9 +2950,10 @@ def _price_alerts_adapter(update_service, scanners=None): class _FakeUpdateService: - def __init__(self, alerts): + def __init__(self, alerts, *, unavailable_count=0): self.alerts = alerts self.calls = [] + self.unavailable_count = unavailable_count async def get_price_alerts(self, model_type=None, *, threshold_buzz=None, limit=200): self.calls.append((model_type, threshold_buzz, limit)) @@ -2961,6 +2962,9 @@ class _FakeUpdateService: def newest_price_checked_at(self): return 1791039694.5 + def count_unavailable_prices(self, model_type=None): + return self.unavailable_count + @pytest.mark.asyncio async def test_price_alerts_handler_returns_the_global_list(): @@ -2997,6 +3001,7 @@ async def test_price_alerts_handler_returns_the_global_list(): assert payload["enabled"] is True assert payload["thresholdBuzz"] == 300 assert payload["newestCheckedAt"] == 1791039694.5 + assert payload["unavailableCount"] == 0 assert payload["alerts"][0]["civitaiUrl"] == ( "https://civitai.com/models/2981320?modelVersionId=3379626" ) diff --git a/tests/services/test_civitai_client.py b/tests/services/test_civitai_client.py index a0aa3af1..a023ee23 100644 --- a/tests/services/test_civitai_client.py +++ b/tests/services/test_civitai_client.py @@ -1,4 +1,5 @@ import copy +import pathlib from unittest.mock import AsyncMock import pytest @@ -921,3 +922,104 @@ async def test_get_model_prices_propagates_rate_limit(downloader): with pytest.raises(RateLimitError): await client.get_model_prices(7) + + +# --- Model page host fallback ------------------------------------------------- + +_PAGE_FIXTURE = ( + pathlib.Path(__file__).resolve().parents[1] + / "utils" + / "fixtures" + / "civitai_model_page_paid.html" +) + + +def _page_html() -> str: + return _PAGE_FIXTURE.read_text(encoding="utf-8") + + +async def test_get_model_prices_falls_back_when_the_preferred_host_refuses( + downloader, monkeypatch +): + """civitai.red refuses non-browser clients (Cloudflare); the price must still + be readable from a host that answers. This is the user-visible bug: with + civitai_host=civitai.red every fetch used to fail.""" + + client = await CivitaiClient.get_instance() + monkeypatch.setattr(client, "_page_host", lambda: "civitai.red") + seen = [] + + async def fake_make_request(method, url, use_auth=True, **kwargs): + seen.append(url) + if "civitai.red" in url: + return False, "Access forbidden" + return True, _page_html() + + downloader.make_request = fake_make_request + + result = await client.get_model_prices(4242) + + assert result is not None + assert result[1001]["price_buzz"] == 5000 + assert seen[0].startswith("https://civitai.red/") + assert any("civitai.com" in url for url in seen) + # The working host is remembered and the refusing one is parked. + assert client._page_host_preference == "civitai.com" + assert "civitai.red" in client._page_host_blocked + + +async def test_get_model_prices_reuses_the_working_host(downloader, monkeypatch): + client = await CivitaiClient.get_instance() + monkeypatch.setattr(client, "_page_host", lambda: "civitai.red") + calls = [] + + async def fake_make_request(method, url, use_auth=True, **kwargs): + calls.append(url) + if "civitai.red" in url: + return False, "Access forbidden" + return True, _page_html() + + downloader.make_request = fake_make_request + + await client.get_model_prices(4242) + calls.clear() + await client.get_model_prices(4242) + + # Second time around the parked host is not retried. + assert calls and all("civitai.red" not in url for url in calls) + + +async def test_get_model_prices_returns_none_when_no_host_can_serve(downloader, monkeypatch): + """Mature models: 404 on civitai.com/green, challenge on civitai.red.""" + + client = await CivitaiClient.get_instance() + monkeypatch.setattr(client, "_page_host", lambda: "civitai.com") + + async def fake_make_request(method, url, use_auth=True, **kwargs): + if "civitai.red" in url: + return False, "Access forbidden" + return False, "Resource not found" + + downloader.make_request = fake_make_request + + assert await client.get_model_prices(2981320) is None + + +async def test_get_model_prices_does_not_park_a_host_on_404(downloader, monkeypatch): + """A 404 is model-specific (mature content hidden anonymously), not a reason + to stop using the host for other models.""" + + client = await CivitaiClient.get_instance() + monkeypatch.setattr(client, "_page_host", lambda: "civitai.com") + + async def fake_make_request(method, url, use_auth=True, **kwargs): + if "civitai.com" in url: + return False, "Resource not found" + return False, "Access forbidden" + + downloader.make_request = fake_make_request + + await client.get_model_prices(1) + + assert "civitai.com" not in client._page_host_blocked + assert "civitai.red" in client._page_host_blocked diff --git a/tests/services/test_model_update_service.py b/tests/services/test_model_update_service.py index 733edca0..33d15d57 100644 --- a/tests/services/test_model_update_service.py +++ b/tests/services/test_model_update_service.py @@ -1884,3 +1884,74 @@ async def test_newest_price_checked_at_reports_the_latest_fetch(tmp_path): assert newest is not None assert newest > 0 + + +@pytest.mark.asyncio +async def test_failed_price_attempt_is_recorded_as_unavailable(tmp_path): + """A gated version we tried to price and could not must be distinguishable + from one we never looked at — that is the honest "unavailable" state for + mature models, whose pages no host will serve anonymously.""" + + service = _price_service(tmp_path, price_tracking_enabled=True) + scanner = DummyScanner(LOCAL_RAW_DATA) + failing = PriceProvider(GATED_RESPONSE, prices=None) + + await service.refresh_for_model_type("lora", scanner, failing) + record = await service.get_record("lora", 1) + version = next(v for v in record.versions if v.version_id == 12) + + assert failing.price_calls == 1 + assert version.price_buzz is None + assert version.price_checked_at is None + assert version.price_check_attempted_at is not None + assert service.count_unavailable_prices("lora") == 1 + assert service.count_unavailable_prices("checkpoint") == 0 + # Nothing to alert on, and no price alert state. + assert await service.get_price_alerts("lora") == [] + assert version.price_alert_state is False + + +@pytest.mark.asyncio +async def test_successful_price_attempt_sets_both_markers(tmp_path): + service = _price_service(tmp_path, price_tracking_enabled=True) + scanner = DummyScanner(LOCAL_RAW_DATA) + + await service.refresh_for_model_type( + "lora", scanner, PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD) + ) + record = await service.get_record("lora", 1) + version = next(v for v in record.versions if v.version_id == 12) + + assert version.price_buzz == 250 + assert version.price_checked_at is not None + assert version.price_check_attempted_at is not None + assert service.count_unavailable_prices("lora") == 0 + + +@pytest.mark.asyncio +async def test_no_price_attempt_is_recorded_while_tracking_is_off(tmp_path): + service = _price_service(tmp_path) # tracking off + scanner = DummyScanner(LOCAL_RAW_DATA) + + await service.refresh_for_model_type("lora", scanner, DummyProvider(GATED_RESPONSE)) + record = await service.get_record("lora", 1) + + assert record.versions[0].price_check_attempted_at is None + assert service.count_unavailable_prices("lora") == 0 + + +@pytest.mark.asyncio +async def test_unavailable_marker_clears_when_the_version_becomes_free(tmp_path): + service = _price_service(tmp_path, price_tracking_enabled=True) + scanner = DummyScanner(LOCAL_RAW_DATA) + + await service.refresh_for_model_type( + "lora", scanner, PriceProvider(GATED_RESPONSE, prices=None) + ) + assert service.count_unavailable_prices("lora") == 1 + + await service.refresh_for_model_type("lora", scanner, DummyProvider(FREE_RESPONSE)) + record = await service.get_record("lora", 1) + + assert record.versions[0].price_check_attempted_at is None + assert service.count_unavailable_prices("lora") == 0 From 425d90a912eb0f97948ba109c71c27087bb85fd3 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sun, 4 Oct 2026 20:20:50 +0800 Subject: [PATCH 05/10] fix(update): decouple price capture from the metadata TTL Found in a real instance: after enabling price tracking, a normal "Check updates" captured exactly one price out of 718 models, so the alerts panel looked broken while the log said the refresh completed. Price capture only ran when the version list was re-fetched, so it inherited the metadata TTL: with 24 h metadata and 24 h price TTLs, only the handful of models whose metadata happened to be stale that round were ever priced. - the cached record already carries the gate, so the price pass now runs off whichever version list is available (freshly fetched or stored) and applies the result without touching last_checked_at, so a price-only pass cannot silently extend the metadata TTL - a failed attempt now satisfies the price TTL, so a mature model whose page no host will serve is not retried on every single update check - an explicitly forced check re-prices within the TTL Verified by copying a real instance's update DB into a sandbox and running a non-forced check: bulk metadata fetches 0 (version lists entirely from cache) while priced versions went 1 -> 20 and the panel listed 19 alerts. --- docs/plans/paid-model-price-tracking.md | 19 +++++ py/services/model_update_service.py | 51 ++++++++++--- tests/services/test_model_update_service.py | 82 +++++++++++++++++++++ 3 files changed, 143 insertions(+), 9 deletions(-) diff --git a/docs/plans/paid-model-price-tracking.md b/docs/plans/paid-model-price-tracking.md index 8555e6b6..17f393f2 100644 --- a/docs/plans/paid-model-price-tracking.md +++ b/docs/plans/paid-model-price-tracking.md @@ -652,3 +652,22 @@ Recorded options for mature models, deliberately **not** implemented: "convenience" — the page route is demonstrably unreliable (one host challenges non-browser clients, the other two hide mature models from anonymous visitors), so a supported field is the only way for any third-party tool to show prices for the models where creators monetize most. + +### 11.11 Price capture is independent of the metadata TTL + +Found in a real instance: after enabling price tracking, a normal "Check updates" captured exactly +**one** price out of 718 models. The price capture only ran when the *version list* was re-fetched +(`refresh_succeeded`), so it inherited the metadata TTL: with 24 h metadata and 24 h price TTLs, only +the 6 models whose metadata happened to be stale were ever priced. + +The cached record already carries the gate, so the price pass now runs off whichever version list is +available — freshly fetched or stored — and applies the result without touching `last_checked_at`, so +a price-only pass cannot silently extend the metadata TTL. Two related semantics: + +* A **failed** attempt (`price_check_attempted_at`) satisfies the price TTL, so a mature model whose + page no host will serve is not retried on every single update check. +* An explicitly **forced** check re-prices within the TTL (`_should_fetch_prices(..., force=True)`). + +Verified by copying a real instance's update DB into a sandbox and running a non-forced check: +`bulk metadata fetches: 0` (version lists came entirely from cache) while priced versions went +**1 → 20** and the panel listed 19 alerts. diff --git a/py/services/model_update_service.py b/py/services/model_update_service.py index 10fdce4e..81811a20 100644 --- a/py/services/model_update_service.py +++ b/py/services/model_update_service.py @@ -1472,16 +1472,33 @@ class ModelUpdateService: # Optional price capture. Runs after the version list is known so only the # models that actually carry a gate pay for a second (page) request, and # stays outside the lock along with the other network I/O. + # + # Deliberately independent of the metadata TTL: the cached record already + # carries the gate, so enabling price tracking (or letting the *price* TTL + # lapse) must not wait for the version list to go stale. Otherwise a user + # who turns the feature on prices only the handful of models that happened + # to need a metadata refresh that round. + priced_versions: Optional[List[ModelVersionRecord]] = None + price_candidates: Optional[Sequence[ModelVersionRecord]] = None + if refresh_succeeded and isinstance(fetched_versions, list) and fetched_versions: + price_candidates = fetched_versions + elif existing is not None and existing.versions: + price_candidates = existing.versions + if ( - refresh_succeeded - and isinstance(fetched_versions, list) - and fetched_versions + price_candidates and metadata_provider is not None - and self._should_fetch_prices(fetched_versions, existing) - ): - fetched_versions = await self._apply_model_prices( - metadata_provider, model_id, fetched_versions + and self._should_fetch_prices( + price_candidates, existing, force=force_refresh ) + ): + price_candidates = await self._apply_model_prices( + metadata_provider, model_id, price_candidates + ) + if refresh_succeeded and isinstance(fetched_versions, list): + fetched_versions = list(price_candidates) + else: + priced_versions = list(price_candidates) if fallback_attempted: if refresh_succeeded and isinstance(fetched_versions, list): @@ -1547,6 +1564,11 @@ class ModelUpdateService: local_base_models=local_base_models, ) else: + if priced_versions is not None and existing is not None: + # Metadata came from the cache, but the prices did not: keep + # them without touching last_checked_at, so the metadata TTL is + # not silently extended by a price-only pass. + existing = replace(existing, versions=priced_versions) record = self._merge_with_local_versions( existing, normalized_local, @@ -2187,6 +2209,8 @@ class ModelUpdateService: self, remote_versions: Sequence[ModelVersionRecord], existing: Optional[ModelUpdateRecord], + *, + force: bool = False, ) -> bool: """Whether this model needs a price fetch this round. @@ -2194,10 +2218,18 @@ class ModelUpdateService: nothing. A stored price is refreshed once its TTL lapses, and immediately when the gate itself changed (a new end date or sale window is a reason to believe the price moved). + + A *failed* attempt counts as an attempt: without that, a mature model whose + page no host will serve would be retried on every single update check. + ``force`` (the user asked explicitly) overrides the TTL. """ if not self._price_tracking_enabled(): return False + if force: + return any( + self._has_structural_gate(version) for version in remote_versions + ) existing_map = ( {version.version_id: version for version in existing.versions} @@ -2213,9 +2245,10 @@ class ModelUpdateService: stored = existing_map.get(remote_version.version_id) if stored is None: return True - if stored.price_checked_at is None: + anchor = stored.price_checked_at or stored.price_check_attempted_at + if anchor is None: return True - if (now - stored.price_checked_at) >= ttl: + if (now - anchor) >= ttl: return True if (stored.paid_access or None) != (remote_version.paid_access or None): return True diff --git a/tests/services/test_model_update_service.py b/tests/services/test_model_update_service.py index 33d15d57..6842be57 100644 --- a/tests/services/test_model_update_service.py +++ b/tests/services/test_model_update_service.py @@ -1373,6 +1373,9 @@ class FakeSettings: def get(self, key, default=None): return self._values.get(key, default) + def set(self, key, value): + self._values[key] = value + class PriceProvider(DummyProvider): """DummyProvider that can also serve prices.""" @@ -1955,3 +1958,82 @@ async def test_unavailable_marker_clears_when_the_version_becomes_free(tmp_path) assert record.versions[0].price_check_attempted_at is None assert service.count_unavailable_prices("lora") == 0 + + +def _long_ttl_service(tmp_path, **settings): + """A service whose metadata TTL does not lapse during the test.""" + + return ModelUpdateService( + str(tmp_path / "updates.sqlite"), + ttl_seconds=86400, + settings_manager=FakeSettings(settings), + ) + + +@pytest.mark.asyncio +async def test_prices_are_fetched_even_when_the_version_list_is_fresh(tmp_path): + """Enabling price tracking must not wait for the metadata TTL. + + The cached record already carries the gate, so a fresh version list is no + reason to skip the price: otherwise turning the feature on prices only the + handful of models that happened to need a metadata refresh that round. + """ + + service = _long_ttl_service(tmp_path, price_tracking_enabled=False) + scanner = DummyScanner(LOCAL_RAW_DATA) + provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD) + + await service.refresh_for_model_type("lora", scanner, provider) + assert provider.price_calls == 0 + metadata_calls = provider.calls + checked_at_before = (await service.get_record("lora", 1)).last_checked_at + + service._settings.set("price_tracking_enabled", True) + await service.refresh_for_model_type("lora", scanner, provider) + + record = await service.get_record("lora", 1) + version = next(v for v in record.versions if v.version_id == 12) + + # The version list came from the cache this round ... + assert provider.calls == metadata_calls + # ... and the price was captured anyway. + assert provider.price_calls == 1 + assert version.price_buzz == 250 + assert version.price_checked_at is not None + assert version.price_check_attempted_at is not None + # A price-only pass must not extend the metadata TTL. + assert record.last_checked_at == checked_at_before + + +@pytest.mark.asyncio +async def test_failed_price_attempt_is_not_retried_within_the_ttl(tmp_path): + """A mature model whose page no host will serve must not cost two requests + on every single update check.""" + + service = _long_ttl_service(tmp_path, price_tracking_enabled=True) + scanner = DummyScanner(LOCAL_RAW_DATA) + provider = PriceProvider(GATED_RESPONSE, prices=None) + + await service.refresh_for_model_type("lora", scanner, provider) + assert provider.price_calls == 1 + + await service.refresh_for_model_type("lora", scanner, provider) + + assert provider.price_calls == 1 + assert service.count_unavailable_prices("lora") == 1 + + +@pytest.mark.asyncio +async def test_forced_refresh_reprices_within_the_ttl(tmp_path): + service = _long_ttl_service(tmp_path, price_tracking_enabled=True) + scanner = DummyScanner(LOCAL_RAW_DATA) + provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD) + + await service.refresh_for_model_type("lora", scanner, provider) + assert provider.price_calls == 1 + + await service.refresh_for_model_type( + "lora", scanner, provider, force_refresh=True + ) + + assert provider.price_calls == 2 From 156d2e5eb9c2ad76812a788b59f3594d7e405592 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sun, 4 Oct 2026 20:28:57 +0800 Subject: [PATCH 06/10] fix(ui): explain a 0 Buzz threshold in the price alerts panel The default threshold is 0 ("only tell me when a version becomes free") and the settings copy says so, but the panel did not: a real instance with 52 priced paid versions and an untouched threshold showed "Nothing is under your price threshold right now" with only a small "Alert threshold: 0 Buzz" in the corner, which reads as a broken feature. - the payload now carries pricedCount, so the empty state can say how many paid versions already have a known price - when the threshold is 0 and prices are known, the empty state says so and points at Settings - Library instead of implying there is nothing to show - the read-time threshold comparison means setting one takes effect immediately; measured on a copy of that instance: 0 Buzz -> 0 alerts, 100 -> 44, 500 -> 48, 5000 -> 51 --- docs/plans/paid-model-price-tracking.md | 12 +++++ locales/de.json | 3 +- locales/en.json | 3 +- locales/es.json | 3 +- locales/fr.json | 3 +- locales/he.json | 3 +- locales/ja.json | 3 +- locales/ko.json | 3 +- locales/ru.json | 3 +- locales/zh-CN.json | 3 +- locales/zh-TW.json | 3 +- py/routes/handlers/misc_handlers.py | 4 ++ py/services/model_update_service.py | 30 +++++++++++++ static/js/managers/UpdateService.js | 44 ++++++++++++++----- .../managers/updateServicePriceAlerts.test.js | 30 +++++++++++++ tests/routes/test_misc_routes.py | 7 ++- tests/services/test_model_update_service.py | 14 ++++++ 17 files changed, 148 insertions(+), 23 deletions(-) diff --git a/docs/plans/paid-model-price-tracking.md b/docs/plans/paid-model-price-tracking.md index 17f393f2..b8d91b7e 100644 --- a/docs/plans/paid-model-price-tracking.md +++ b/docs/plans/paid-model-price-tracking.md @@ -671,3 +671,15 @@ a price-only pass cannot silently extend the metadata TTL. Two related semantics Verified by copying a real instance's update DB into a sandbox and running a non-forced check: `bulk metadata fetches: 0` (version lists came entirely from cache) while priced versions went **1 → 20** and the panel listed 19 alerts. + +### 11.12 The empty state has to explain a 0 Buzz threshold + +The default threshold is 0 ("only tell me when a version becomes free"), and the settings copy says +so. The panel did not: a real instance with **52 priced paid versions** and an untouched threshold +showed "Nothing is under your price threshold right now" plus a small "Alert threshold: 0 Buzz" in +the corner, which reads as "the feature is broken". + +The empty state is now threshold-aware, using a new `pricedCount` in the payload: when the threshold +is 0 and prices are known, it says how many paid versions have a price and where to set a threshold. +The read-time threshold comparison means changing it takes effect immediately — no re-check needed +(measured on a copy of that instance's DB: 0 Buzz -> 0 alerts, 100 -> 44, 500 -> 48, 5000 -> 51). diff --git a/locales/de.json b/locales/de.json index c432c06d..caf22b45 100644 --- a/locales/de.json +++ b/locales/de.json @@ -2411,7 +2411,8 @@ "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", + "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/en.json b/locales/en.json index b29554f0..0d34f544 100644 --- a/locales/en.json +++ b/locales/en.json @@ -2411,7 +2411,8 @@ "droppedAgo": "dropped {when}", "openCivitai": "Open on CivitAI", "openLocal": "Open", - "unavailable": "{count} paid version(s) have no readable price (mature models can only be read in a browser)" + "unavailable": "{count} paid version(s) have no readable price (mature models can only be read in a browser)", + "emptyZeroThreshold": "{count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/es.json b/locales/es.json index f7f12ee2..b75fc19c 100644 --- a/locales/es.json +++ b/locales/es.json @@ -2411,7 +2411,8 @@ "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", + "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/fr.json b/locales/fr.json index 5f19be0a..a9699424 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -2411,7 +2411,8 @@ "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", + "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/he.json b/locales/he.json index 7001a5c6..93a6ef6e 100644 --- a/locales/he.json +++ b/locales/he.json @@ -2411,7 +2411,8 @@ "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", + "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/ja.json b/locales/ja.json index e537c576..b5e0f170 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -2411,7 +2411,8 @@ "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", + "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/ko.json b/locales/ko.json index d82f271b..6914e74f 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -2411,7 +2411,8 @@ "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", + "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/ru.json b/locales/ru.json index 986e9eba..b7272b03 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -2411,7 +2411,8 @@ "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", + "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 9a854ad4..16a0fc46 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -2411,7 +2411,8 @@ "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", + "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 0d3162fb..8767b248 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -2411,7 +2411,8 @@ "droppedAgo": "[TODO: Translate] dropped {when}", "openCivitai": "[TODO: Translate] Open on CivitAI", "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)" + "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", + "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index d49abbde..9fa82a62 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -4385,6 +4385,7 @@ class PriceAlertsHandler: ) newest_checked_at = update_service.newest_price_checked_at() unavailable_count = update_service.count_unavailable_prices() + priced_count = update_service.count_priced_versions() except Exception as exc: logger.error("Failed to load price alerts: %s", exc, exc_info=True) return web.json_response( @@ -4410,6 +4411,9 @@ class PriceAlertsHandler: # Gated versions whose price could not be read (mature models # are served only by the challenged civitai.red host). "unavailableCount": unavailable_count, + # Lets the empty state explain a 0 Buzz threshold instead of + # claiming there is simply nothing to show. + "pricedCount": priced_count, "alerts": alerts, } ) diff --git a/py/services/model_update_service.py b/py/services/model_update_service.py index 81811a20..2e904535 100644 --- a/py/services/model_update_service.py +++ b/py/services/model_update_service.py @@ -1362,6 +1362,36 @@ class ModelUpdateService: return None return float(row["newest"]) + def count_priced_versions(self, model_type: Optional[str] = None) -> int: + """Versions with a known price, for the panel's empty state. + + Without it, a user whose threshold is 0 sees "nothing is under your price + threshold" while dozens of paid versions already have a price. + """ + + params: List[Any] = [] + type_filter = "" + if model_type: + type_filter = "AND s.model_type = ?" + params.append(model_type) + + with self._connect() as conn: + row = conn.execute( + f""" + SELECT COUNT(*) AS priced + FROM model_update_versions v + JOIN model_update_status s ON s.model_id = v.model_id + WHERE v.should_ignore = 0 + AND s.should_ignore_model = 0 + {type_filter} + AND v.price_buzz IS NOT NULL + """, + tuple(params), + ).fetchone() + if row is None: + return 0 + return int(row["priced"]) + def count_unavailable_prices(self, model_type: Optional[str] = None) -> int: """Gated versions whose price we tried to read and could not. diff --git a/static/js/managers/UpdateService.js b/static/js/managers/UpdateService.js index d82ece99..f6b83b54 100644 --- a/static/js/managers/UpdateService.js +++ b/static/js/managers/UpdateService.js @@ -29,7 +29,8 @@ async function fetchPriceAlerts(limit = 200) { enabled: payload.enabled === true, thresholdBuzz: payload.thresholdBuzz ?? 0, newestCheckedAt: payload.newestCheckedAt ?? null, - unavailableCount: payload.unavailableCount ?? 0 + unavailableCount: payload.unavailableCount ?? 0, + pricedCount: payload.pricedCount ?? 0 }; } @@ -114,6 +115,7 @@ export class UpdateService { this.priceAlertsThreshold = 0; this.priceAlertsNewestCheckedAt = null; this.priceAlertsUnavailableCount = 0; + this.priceAlertsPricedCount = 0; this.priceAlertsLoading = false; this.priceAlertSegment = 'below_threshold'; this.unreadPriceAlertCount = 0; @@ -407,6 +409,7 @@ export class UpdateService { this.priceAlertsThreshold = payload.thresholdBuzz; this.priceAlertsNewestCheckedAt = payload.newestCheckedAt; this.priceAlertsUnavailableCount = payload.unavailableCount || 0; + this.priceAlertsPricedCount = payload.pricedCount || 0; this.refreshUnreadPriceAlertCount(); this.renderPriceAlerts(); return this.priceAlerts; @@ -512,17 +515,7 @@ export class UpdateService { list.innerHTML = ''; if (!alerts.length) { - empty.textContent = this.priceAlertSegment === 'became_free' - ? translate( - 'update.priceAlerts.emptyFree', - {}, - 'No version has become free yet.' - ) - : translate( - 'update.priceAlerts.empty', - {}, - 'Nothing is under your price threshold right now.' - ); + empty.textContent = this.buildPriceAlertsEmptyText(); empty.classList.remove('hidden'); return; } @@ -533,6 +526,33 @@ export class UpdateService { }); } + /** + * The empty state has to explain a 0 Buzz threshold: "nothing is under your + * threshold" is technically true but useless when dozens of paid versions + * already have a known price. + */ + buildPriceAlertsEmptyText() { + if (this.priceAlertSegment === 'became_free') { + return translate( + 'update.priceAlerts.emptyFree', + {}, + 'No version has become free yet.' + ); + } + if (!this.priceAlertsThreshold && this.priceAlertsPricedCount > 0) { + return translate( + 'update.priceAlerts.emptyZeroThreshold', + { count: this.priceAlertsPricedCount }, + `${this.priceAlertsPricedCount} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops.` + ); + } + return translate( + 'update.priceAlerts.empty', + {}, + 'Nothing is under your price threshold right now.' + ); + } + buildPriceAlertItem(alert) { const item = document.createElement('li'); item.className = 'price-alert-item'; diff --git a/tests/frontend/managers/updateServicePriceAlerts.test.js b/tests/frontend/managers/updateServicePriceAlerts.test.js index a772dd82..fee8dd36 100644 --- a/tests/frontend/managers/updateServicePriceAlerts.test.js +++ b/tests/frontend/managers/updateServicePriceAlerts.test.js @@ -218,6 +218,36 @@ describe('UpdateService price alerts panel', () => { expect(note.textContent).toContain('3 paid version(s) have no readable price'); }); + it('explains a 0 Buzz threshold instead of claiming there is nothing', async () => { + // The exact confusion a real user hit: 52 priced versions, threshold 0, + // and an empty panel saying "nothing is under your threshold". + global.fetch = vi.fn().mockResolvedValue( + createFetchResponse( + alertPayload([], { thresholdBuzz: 0, pricedCount: 52 }) + ) + ); + + await service.loadPriceAlerts({ force: true }); + + const empty = document.getElementById('priceAlertsEmpty'); + expect(empty.classList.contains('hidden')).toBe(false); + expect(empty.textContent).toContain('52 paid version(s) have a known price'); + expect(empty.textContent).toContain('threshold is 0 Buzz'); + }); + + it('keeps the plain empty message when a real threshold is set', async () => { + global.fetch = vi.fn().mockResolvedValue( + createFetchResponse( + alertPayload([], { thresholdBuzz: 500, pricedCount: 52 }) + ) + ); + + await service.loadPriceAlerts({ force: true }); + + const empty = document.getElementById('priceAlertsEmpty'); + expect(empty.textContent).toBe('Nothing is under your price threshold right now.'); + }); + it('keeps the last known list when the request fails', async () => { global.fetch = vi .fn() diff --git a/tests/routes/test_misc_routes.py b/tests/routes/test_misc_routes.py index 8719cf23..50d4178a 100644 --- a/tests/routes/test_misc_routes.py +++ b/tests/routes/test_misc_routes.py @@ -2950,10 +2950,11 @@ def _price_alerts_adapter(update_service, scanners=None): class _FakeUpdateService: - def __init__(self, alerts, *, unavailable_count=0): + def __init__(self, alerts, *, unavailable_count=0, priced_count=0): self.alerts = alerts self.calls = [] self.unavailable_count = unavailable_count + self.priced_count = priced_count async def get_price_alerts(self, model_type=None, *, threshold_buzz=None, limit=200): self.calls.append((model_type, threshold_buzz, limit)) @@ -2965,6 +2966,9 @@ class _FakeUpdateService: def count_unavailable_prices(self, model_type=None): return self.unavailable_count + def count_priced_versions(self, model_type=None): + return self.priced_count + @pytest.mark.asyncio async def test_price_alerts_handler_returns_the_global_list(): @@ -3002,6 +3006,7 @@ async def test_price_alerts_handler_returns_the_global_list(): assert payload["thresholdBuzz"] == 300 assert payload["newestCheckedAt"] == 1791039694.5 assert payload["unavailableCount"] == 0 + assert payload["pricedCount"] == 0 assert payload["alerts"][0]["civitaiUrl"] == ( "https://civitai.com/models/2981320?modelVersionId=3379626" ) diff --git a/tests/services/test_model_update_service.py b/tests/services/test_model_update_service.py index 6842be57..37e30b3f 100644 --- a/tests/services/test_model_update_service.py +++ b/tests/services/test_model_update_service.py @@ -2023,6 +2023,20 @@ async def test_failed_price_attempt_is_not_retried_within_the_ttl(tmp_path): assert service.count_unavailable_prices("lora") == 1 +@pytest.mark.asyncio +async def test_priced_count_reports_known_prices(tmp_path): + service = _price_service(tmp_path, price_tracking_enabled=True) + scanner = DummyScanner(LOCAL_RAW_DATA) + provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD) + + assert service.count_priced_versions("lora") == 0 + + await service.refresh_for_model_type("lora", scanner, provider) + + assert service.count_priced_versions("lora") == 1 + assert service.count_priced_versions("checkpoint") == 0 + + @pytest.mark.asyncio async def test_forced_refresh_reprices_within_the_ttl(tmp_path): service = _long_ttl_service(tmp_path, price_tracking_enabled=True) From e42d649df020d1ccacba7bd417840fae016cc4e8 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Mon, 5 Oct 2026 08:54:08 +0800 Subject: [PATCH 07/10] refactor(update)!: make obtainability a property of updates, not a surface The owner could not tell from the UI what "Buzz Price Tracking" enabled, what the "Price alert threshold" number meant, or what "Price Alerts" was alerting about. That was not a copy problem: the implementation exposed our mechanism (a page scrape) and our SQL predicates as the user's concepts. Two concrete defects came from the same root: - the alert population included versions the user already owns (neither the event generator nor the query filtered on is_in_library; in the owner's library 28 of 52 gated versions were already downloaded, so most "alerts" were about files already on disk, which cannot become cheaper *for them*); - a threshold-filtered state list lived in a notification surface, so an empty panel had three indistinguishable causes and read as a broken feature. The information model is now the version plus ownership: cost is shown only where a decision exists. Owned -> nothing. Not owned and free -> nothing. Not owned and gated -> the price when it is known, `Paid` without a number when it is not, and early access keeps its countdown because "free on " decides between waiting and paying. The numeric threshold has no place in that model: every decision is categorical (wait / pay / skip), so the setting, the comparison and the whole alert-state machine are gone. - both alert-state columns are removed from the schema rather than left dead; a database created by an unreleased build has them dropped on open (native ALTER TABLE ... DROP COLUMN, guarded), which is a no-op for everyone else - gate events are emitted only for versions the user does not have, and the price-drop event goes with the threshold it belonged to - both alert endpoints, PriceAlertsHandler and the service-registry adapter field it needed are removed: events already reach the UI through the refresh response - the bell tab, panel, CSS, both entry points, the unread watermark and their locales are removed; the setting keeps only the enable flag and the refresh interval and is framed as plumbing - "Price unavailable" is replaced by `Paid`: the gate is certain from the public API, only the number is best-effort, and that is our plumbing, not the user's problem Verified against a copy of the owner's real database: 52 gated versions -> 28 owned (now silent) + 24 the feature is actually about; the drop migration ran and both removed endpoints 404. --- docs/plans/paid-model-price-tracking.md | 129 +++++ locales/de.json | 45 +- locales/en.json | 57 +-- locales/es.json | 45 +- locales/fr.json | 45 +- locales/he.json | 45 +- locales/ja.json | 45 +- locales/ko.json | 45 +- locales/ru.json | 45 +- locales/zh-CN.json | 45 +- locales/zh-TW.json | 45 +- py/routes/handlers/misc_handlers.py | 160 ------- py/routes/handlers/model_handlers.py | 46 -- py/routes/misc_route_registrar.py | 1 - py/routes/misc_routes.py | 6 - py/routes/model_route_registrar.py | 3 - py/services/model_update_service.py | 325 ++----------- py/services/settings_manager.py | 3 +- static/css/components/modal/update-modal.css | 164 ------- .../ContextMenu/GlobalContextMenu.js | 17 - static/js/components/controls/PageControls.js | 12 - .../js/components/shared/ModelVersionsTab.js | 81 ++-- static/js/managers/SettingsManager.js | 5 - static/js/managers/UpdateService.js | 443 ------------------ static/js/state/index.js | 1 - static/js/utils/updateCheckHelpers.js | 16 +- templates/components/context_menu.html | 3 - templates/components/controls.html | 3 - .../components/modals/settings/library.html | 22 - templates/components/modals/update_modal.html | 32 -- .../modelVersionsTab.downloadButton.test.js | 108 +++-- .../managers/updateServicePriceAlerts.test.js | 300 ------------ tests/routes/test_misc_routes.py | 179 +------ tests/routes/test_model_update_handler.py | 50 +- tests/services/test_model_update_service.py | 326 ++++--------- 35 files changed, 403 insertions(+), 2494 deletions(-) delete mode 100644 tests/frontend/managers/updateServicePriceAlerts.test.js diff --git a/docs/plans/paid-model-price-tracking.md b/docs/plans/paid-model-price-tracking.md index b8d91b7e..a378efbb 100644 --- a/docs/plans/paid-model-price-tracking.md +++ b/docs/plans/paid-model-price-tracking.md @@ -683,3 +683,132 @@ The empty state is now threshold-aware, using a new `pricedCount` in the payload is 0 and prices are known, it says how many paid versions have a price and where to set a threshold. The read-time threshold comparison means changing it takes effect immediately — no re-check needed (measured on a copy of that instance's DB: 0 Buzz -> 0 alerts, 100 -> 44, 500 -> 48, 5000 -> 51). + +## 12. Redesign (locked): obtainability of versions you do not own + +### 12.1 Why the P5a model was wrong + +The owner could not tell, from the UI alone, what "Buzz Price Tracking" enables, what the +"Price alert threshold" number means, or what "Price Alerts" is alerting about. That is not a copy +problem: the implementation exposed **our mechanism** (a page scrape) and **our SQL predicates** +(`price_buzz <= ?`, `gate_lapsed_at IS NOT NULL`) as the user's concepts. Two concrete defects came +out of the same root: + +* **The alert population included versions the user already owns.** Neither the event generator nor + the panel query filtered on `is_in_library` (the payload even carried `isInLibrary`). In the + owner's library **28 of 52 gated versions were already downloaded** — more than half of the + "alerts" were about things already on disk. A version you own cannot become cheaper *for you*. +* **A filtered list in a notification surface.** "Price Alerts" was a state list whose contents were + entirely decided by a number stored in Settings, so an empty panel had three indistinguishable + causes (tracking off / threshold 0 / genuinely nothing), and it read as a broken feature. + +Two measurements shaped the correction: + +* **Permanent prices dominate** (87 permanent vs 3 timed gates in a 600-model sample), so "wait for a + price drop" is a low-frequency proposition, while "wait for early access to end" is high-frequency + and deterministic. +* **Prices cluster at the floor** (44 of the owner's 52 priced versions are 100 Buzz), so + "price <= N" carries little information; the real distinction is *free / soon free / paid*. + +Also corrected during the discussion: the **generation fee** is the cost of generating *on the +CivitAI site*. A locally downloaded model generated in ComfyUI has no such fee, so for this tool it +is not a price axis at all (42 of the owner's 52 gated versions carry one — displaying it would +mislead on 80% of paid rows). It is not displayed today and must stay that way. + +### 12.2 The corrected information model + +The unit is the **version**, and the boundary is **ownership**: information exists only where a +decision exists. + +| State of a version **not in the library** | The user's decision | What is shown | +| --- | --- | --- | +| Downloadable for free | download or not | nothing special (the download action already says it) | +| Early access, free on D, buyable for N | **wait until D / pay N now** | `N Buzz - free on D` | +| Early access, free on D, not buyable | wait | `free on D` | +| Permanently paid, price known (N) | buy or skip | `N Buzz` | +| Permanently paid, price unknown | buy or skip, price unknown | `Paid` (no number) | +| **In the library** | none | **nothing** | + +The numeric threshold has no place in this table: every decision is categorical (wait / pay / skip), +not numeric. + +### 12.3 Locked decisions + +* **D1 - No independent surface.** Obtainability is an attribute of the existing update surfaces: + the model card's update badge, the grid's "models with updates" filter, the post-check toast, and + the model modal's version list. No new bell tab. +* **D2 - No numeric threshold anywhere.** The setting, the comparison, and the tests around it go. +* **D3 - Only versions that are not in the library** produce events or badges (`is_in_library = 0`), + enforced in both the event generator and any query. +* **D4 - Owned versions show no download price** (and carry no alert state). +* **D5 - The generation fee is never displayed.** +* **D6 - Models outside the library are out of scope.** Their decision moment is the download/browse + flow; that is a recorded follow-up, not a background feed (the update service only knows local + models, and there is no follow mechanism to build on). +* **D7 - The plumbing stays**: page fetch, host fallback, attempt markers, both TTLs, storage, + parser, `gate_lapsed_at`, and the price columns. + +### 12.4 What happens to what shipped in P5a + +| Piece | Fate | +| --- | --- | +| `GET /api/lm/price-alerts`, `GET /api/lm/{prefix}/updates/price-alerts`, `PriceAlertsHandler` | **removed** - no surface consumes them; events reach the UI through the refresh response | +| `get_price_alerts`, `newest_price_checked_at`, `count_priced_versions`, `count_unavailable_prices` | **removed** (threshold comparison and the panel's counters) | +| `price_alert_threshold_buzz` (settings, `_price_alert_threshold_buzz`) | **removed** | +| `price_alert_since` | **stop writing**; the column stays (SQLite drops need a table rebuild) and is marked deprecated | +| `price_alert_state` | **kept** - it is the one-shot edge for the "became free" toast | +| Bell tab, panel markup, `.price-alerts*` CSS, `UpdateService` panel code, watermark, the two entry points (controls dropdown, global context menu), their locales | **removed** | +| Version badges | **reworked** to the 12.2 table, ownership-scoped | +| "Price unavailable" badge | **replaced** by `Paid` (the gate is known from the public API; only the number is best-effort, and that is our plumbing, not the user's problem) | +| `priceAttemptedAt` in the version payload | **removed** from the payload (kept internally for the price-TTL retry policy) | +| The "became free" toast | **kept**, reworded, ownership-scoped | + +### 12.5 Tasks + +**P6a - the core correction (no new surface).** +Backend: ownership-scope the event generator; remove the threshold, the alert query, the counters, +the two routes and the handler; stop writing `price_alert_since`. Frontend: rework the version +badges to the 12.2 table; delete the panel, tab, entry points, watermark and their locales; reword +the toast. Tests: replace the panel/alert suites with a badge-state matrix and ownership-scoping +tests; assert the removed routes are gone. + +**P6b - obtainability in the update surfaces.** +Per-model obtainability summary in the model list payload (next to `hasUpdate`) so the card badge can +say `Update - 500 Buzz` / `Update - free on Oct 15`; toast copy that counts the free ones +("3 models have updates, 1 is now free"). + +**P6c - deferred (recorded, not planned).** A price-drop event (rare by measurement); obtainability +in the download/browse flow for models outside the library (D6). + +### 12.6 Verification + +Unit tests for the state matrix and the ownership filter; a sandbox run against a **copy** of the +owner's real update DB, where the population must shrink from 52 gated versions to the **24 that are +not in the library**, with the owned ones showing nothing. + +### 12.7 What shipped (P6a) + +* The two alert-state columns are **gone from the schema** (not merely unused): the dataclass, + `_SCHEMA`, the additive migration table, the primary-key rebuild's column list and defaults, the + bulk `SELECT`, the row mapping and the `INSERT`. A database created by an unreleased build still + has them, so `_apply_migrations` drops them with a native `ALTER TABLE ... DROP COLUMN` (SQLite + 3.35+, guarded) and logs it; for everyone else it is a no-op. +* `price_alert_threshold_buzz`, `_price_alert_threshold_buzz`, `_evaluate_price_alert`, the + threshold-crossing event, `get_price_alerts`, `newest_price_checked_at`, + `count_priced_versions`, `count_unavailable_prices`, both alert routes, `PriceAlertsHandler`, its + handler-set slot, its route mapping entry and the service-registry adapter field it needed: all + removed. +* Gate events (and only gate events) are emitted, and only for versions the user does not have - + `isInLibrary` is now the filter rather than a field in the payload. +* Version badges follow the 12.2 table: cost information only for versions not in the library, the + price replaces the redundant `Paid` badge when it is known, `Paid` appears without a number when + it is not, and `Price unavailable` is gone (the gate is certain; only the number is best-effort). +* The bell tab, panel markup, panel CSS, the controls-dropdown and global-context-menu entries, the + unread watermark and their locales are removed; the toast keeps its gate transitions and drops the + price-drop line. The setting is now framed as plumbing ("Show download prices for paid versions") + and keeps only the enable flag and the refresh interval. + +Verified: `pytest` 3653 passed / 7 skipped, `npm run test:js` 1444 passed. Against a copy of the +owner's real database the population is 52 gated versions -> **28 owned (now silent) + 24 that the +feature is actually about**; the drop migration logged twice and left no `price_alert_*` column, and +both removed endpoints return 404. diff --git a/locales/de.json b/locales/de.json index caf22b45..a762479e 100644 --- a/locales/de.json +++ b/locales/de.json @@ -204,7 +204,6 @@ "error": "Fehler beim Prüfen auf {type}-Updates: {message}", "gateEvents": { "becameFree": "[TODO: Translate] {count} version(s) became free", - "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", "newGate": "[TODO: Translate] {count} version(s) are now paid" } }, @@ -234,9 +233,6 @@ }, "groupByModel": { "label": "Nach Modell gruppieren" - }, - "priceAlerts": { - "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -767,8 +763,6 @@ "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", "enabled": "[TODO: Translate] Track Buzz prices for paid versions", "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "threshold": "[TODO: Translate] Price alert threshold (Buzz)", - "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", "refreshHours": "[TODO: Translate] Price refresh interval (hours)", "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" }, @@ -945,9 +939,7 @@ "action": "Updates", "menuLabel": "Weitere Update-Optionen anzeigen", "check": "Updates prüfen", - "checkTooltip": "Die Aktualisierungssuche kann einige Zeit dauern.", - "priceAlerts": "[TODO: Translate] Price alerts", - "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" + "checkTooltip": "Die Aktualisierungssuche kann einige Zeit dauern." } }, "bulkOperations": { @@ -2031,15 +2023,13 @@ "paidTooltip": "Diese Version erfordert eine Zahlung zum Herunterladen", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", "freeNow": "[TODO: Translate] Free Now", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "Ignoriert", "ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert", "onSiteOnly": "Nur On-Site", "onSiteOnlyTooltip": "Diese Version ist nur für die On-Site-Generierung auf CivitAI verfügbar", - "priceUnavailable": "[TODO: Translate] Price unavailable", - "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" + "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" }, "actions": { "download": "Herunterladen", @@ -2328,8 +2318,7 @@ "notificationsTitle": "Benachrichtigungszentrum", "tabs": { "updates": "Aktualisierungen", - "messages": "Mitteilungen", - "priceAlerts": "[TODO: Translate] Price Alerts" + "messages": "Mitteilungen" }, "updateAvailable": "Update verfügbar", "noChangelogAvailable": "Kein detailliertes Changelog verfügbar. Weitere Informationen auf GitHub.", @@ -2385,34 +2374,6 @@ "shown": "{time} angezeigt", "dismissed": "{time} geschlossen", "active": "Aktiv" - }, - "priceAlerts": { - "disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.", - "openSettings": "[TODO: Translate] Open settings", - "segmentsLabel": "[TODO: Translate] Alert kind", - "segments": { - "belowThreshold": "[TODO: Translate] Under threshold", - "becameFree": "[TODO: Translate] Became free" - }, - "thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz", - "empty": "[TODO: Translate] Nothing is under your price threshold right now.", - "emptyFree": "[TODO: Translate] No version has become free yet.", - "loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.", - "lastChecked": "[TODO: Translate] Prices last checked {when}", - "justNow": "[TODO: Translate] just now", - "minutesAgo": "[TODO: Translate] {count} min ago", - "hoursAgo": "[TODO: Translate] {count} h ago", - "daysAgo": "[TODO: Translate] {count} d ago", - "freeNow": "[TODO: Translate] Free now", - "blueBuzz": "[TODO: Translate] Blue Buzz OK", - "earlyAccessUntil": "[TODO: Translate] Early access until {date}", - "inLibrary": "[TODO: Translate] In library", - "notInLibrary": "[TODO: Translate] Not in library", - "droppedAgo": "[TODO: Translate] dropped {when}", - "openCivitai": "[TODO: Translate] Open on CivitAI", - "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", - "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/en.json b/locales/en.json index 0d34f544..6bf9c749 100644 --- a/locales/en.json +++ b/locales/en.json @@ -203,8 +203,7 @@ "none": "All {type}s are up to date", "error": "Failed to check for {type} updates: {message}", "gateEvents": { - "becameFree": "{count} version(s) became free", - "priceDrop": "{count} version(s) dropped below your price threshold", + "becameFree": "{count} version(s) you do not have became free", "newGate": "{count} version(s) are now paid" } }, @@ -234,9 +233,6 @@ }, "groupByModel": { "label": "Group by Model" - }, - "priceAlerts": { - "label": "Price alerts" } }, "header": { @@ -763,14 +759,12 @@ "help": "When enabled, models with only paid updates will not show 'Update available' badge" }, "priceTracking": { - "label": "Buzz Price Tracking", - "help": "CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", - "enabled": "Track Buzz prices for paid versions", - "enabledHelp": "Show the price on paid and early access versions, and alert when a price drops below your threshold", - "threshold": "Price alert threshold (Buzz)", - "thresholdHelp": "Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", + "label": "Buzz Download Prices", + "help": "CivitAI does not publish download prices in its public API, so they are read from the model page - one extra request per paid model, refreshed on the interval below. Prices are shown on versions you do not have yet.", + "enabled": "Show download prices for paid versions", + "enabledHelp": "Reads the download price from the model page so paid and early access versions show what they cost, and when early access ends", "refreshHours": "Price refresh interval (hours)", - "refreshHoursHelp": "How long a stored price stays fresh before it is fetched again" + "refreshHoursHelp": "How long a stored price stays fresh before it is read again" }, "licenseIcons": { "useNewStyle": "Use updated license icons", @@ -945,9 +939,7 @@ "action": "Updates", "menuLabel": "Show update options", "check": "Check updates", - "checkTooltip": "Checking updates may take a while.", - "priceAlerts": "Price alerts", - "priceAlertsTooltip": "Buzz price alerts for paid versions" + "checkTooltip": "Checking updates may take a while." } }, "bulkOperations": { @@ -2031,15 +2023,13 @@ "paidTooltip": "This version requires payment to download", "priceSaleTooltip": "On sale, normally {list} Buzz", "priceBlueBuzzTooltip": "Can also be paid with Blue Buzz", - "priceAlertTooltip": "{price} - below your price alert threshold", "freeNow": "Free Now", "freeNowTooltip": "This version no longer requires payment", "ignored": "Ignored", "ignoredTooltip": "Update notifications are disabled for this version", "onSiteOnly": "On-Site Only", "onSiteOnlyTooltip": "This version is only available for on-site generation on CivitAI", - "priceUnavailable": "Price unavailable", - "priceUnavailableTooltip": "CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" + "earlyAccessTooltipUntil": "Early access until {date}, free to download after that" }, "actions": { "download": "Download", @@ -2328,8 +2318,7 @@ "notificationsTitle": "Notifications", "tabs": { "updates": "Updates", - "messages": "Messages", - "priceAlerts": "Price Alerts" + "messages": "Messages" }, "updateAvailable": "Update Available", "noChangelogAvailable": "No detailed changelog available. Check GitHub for more information.", @@ -2385,34 +2374,6 @@ "shown": "Shown {time}", "dismissed": "Dismissed {time}", "active": "Active" - }, - "priceAlerts": { - "disabled": "CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.", - "openSettings": "Open settings", - "segmentsLabel": "Alert kind", - "segments": { - "belowThreshold": "Under threshold", - "becameFree": "Became free" - }, - "thresholdLabel": "Alert threshold: {buzz} Buzz", - "empty": "Nothing is under your price threshold right now.", - "emptyFree": "No version has become free yet.", - "loadFailed": "Could not refresh the alerts; showing the last known list.", - "lastChecked": "Prices last checked {when}", - "justNow": "just now", - "minutesAgo": "{count} min ago", - "hoursAgo": "{count} h ago", - "daysAgo": "{count} d ago", - "freeNow": "Free now", - "blueBuzz": "Blue Buzz OK", - "earlyAccessUntil": "Early access until {date}", - "inLibrary": "In library", - "notInLibrary": "Not in library", - "droppedAgo": "dropped {when}", - "openCivitai": "Open on CivitAI", - "openLocal": "Open", - "unavailable": "{count} paid version(s) have no readable price (mature models can only be read in a browser)", - "emptyZeroThreshold": "{count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/es.json b/locales/es.json index b75fc19c..2349ec7b 100644 --- a/locales/es.json +++ b/locales/es.json @@ -204,7 +204,6 @@ "error": "Error al buscar actualizaciones de {type}: {message}", "gateEvents": { "becameFree": "[TODO: Translate] {count} version(s) became free", - "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", "newGate": "[TODO: Translate] {count} version(s) are now paid" } }, @@ -234,9 +233,6 @@ }, "groupByModel": { "label": "Agrupar por modelo" - }, - "priceAlerts": { - "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -767,8 +763,6 @@ "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", "enabled": "[TODO: Translate] Track Buzz prices for paid versions", "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "threshold": "[TODO: Translate] Price alert threshold (Buzz)", - "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", "refreshHours": "[TODO: Translate] Price refresh interval (hours)", "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" }, @@ -945,9 +939,7 @@ "action": "Actualizaciones", "menuLabel": "Mostrar opciones de actualización", "check": "Buscar actualizaciones", - "checkTooltip": "Comprobar actualizaciones puede tardar.", - "priceAlerts": "[TODO: Translate] Price alerts", - "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" + "checkTooltip": "Comprobar actualizaciones puede tardar." } }, "bulkOperations": { @@ -2031,15 +2023,13 @@ "paidTooltip": "Esta versión requiere pago para descargarse", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", "freeNow": "[TODO: Translate] Free Now", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "Ignorada", "ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión", "onSiteOnly": "Solo en Sitio", "onSiteOnlyTooltip": "Esta versión solo está disponible para generación en el sitio de CivitAI", - "priceUnavailable": "[TODO: Translate] Price unavailable", - "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" + "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" }, "actions": { "download": "Descargar", @@ -2328,8 +2318,7 @@ "notificationsTitle": "Centro de notificaciones", "tabs": { "updates": "Actualizaciones", - "messages": "Mensajes", - "priceAlerts": "[TODO: Translate] Price Alerts" + "messages": "Mensajes" }, "updateAvailable": "Actualización disponible", "noChangelogAvailable": "No hay registro de cambios detallado disponible. Revisa GitHub para más información.", @@ -2385,34 +2374,6 @@ "shown": "Mostrado {time}", "dismissed": "Descartado {time}", "active": "Activo" - }, - "priceAlerts": { - "disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.", - "openSettings": "[TODO: Translate] Open settings", - "segmentsLabel": "[TODO: Translate] Alert kind", - "segments": { - "belowThreshold": "[TODO: Translate] Under threshold", - "becameFree": "[TODO: Translate] Became free" - }, - "thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz", - "empty": "[TODO: Translate] Nothing is under your price threshold right now.", - "emptyFree": "[TODO: Translate] No version has become free yet.", - "loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.", - "lastChecked": "[TODO: Translate] Prices last checked {when}", - "justNow": "[TODO: Translate] just now", - "minutesAgo": "[TODO: Translate] {count} min ago", - "hoursAgo": "[TODO: Translate] {count} h ago", - "daysAgo": "[TODO: Translate] {count} d ago", - "freeNow": "[TODO: Translate] Free now", - "blueBuzz": "[TODO: Translate] Blue Buzz OK", - "earlyAccessUntil": "[TODO: Translate] Early access until {date}", - "inLibrary": "[TODO: Translate] In library", - "notInLibrary": "[TODO: Translate] Not in library", - "droppedAgo": "[TODO: Translate] dropped {when}", - "openCivitai": "[TODO: Translate] Open on CivitAI", - "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", - "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/fr.json b/locales/fr.json index a9699424..f016f7fa 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -204,7 +204,6 @@ "error": "Échec de la vérification des mises à jour pour {type} : {message}", "gateEvents": { "becameFree": "[TODO: Translate] {count} version(s) became free", - "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", "newGate": "[TODO: Translate] {count} version(s) are now paid" } }, @@ -234,9 +233,6 @@ }, "groupByModel": { "label": "Grouper par modèle" - }, - "priceAlerts": { - "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -767,8 +763,6 @@ "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", "enabled": "[TODO: Translate] Track Buzz prices for paid versions", "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "threshold": "[TODO: Translate] Price alert threshold (Buzz)", - "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", "refreshHours": "[TODO: Translate] Price refresh interval (hours)", "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" }, @@ -945,9 +939,7 @@ "action": "Mises à jour", "menuLabel": "Afficher les options de mise à jour", "check": "Rechercher des mises à jour", - "checkTooltip": "La vérification peut prendre du temps.", - "priceAlerts": "[TODO: Translate] Price alerts", - "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" + "checkTooltip": "La vérification peut prendre du temps." } }, "bulkOperations": { @@ -2031,15 +2023,13 @@ "paidTooltip": "Cette version nécessite un paiement pour être téléchargée", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", "freeNow": "[TODO: Translate] Free Now", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "Ignorée", "ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version", "onSiteOnly": "Uniquement sur Site", "onSiteOnlyTooltip": "Cette version n'est disponible que pour la génération sur le site CivitAI", - "priceUnavailable": "[TODO: Translate] Price unavailable", - "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" + "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" }, "actions": { "download": "Télécharger", @@ -2328,8 +2318,7 @@ "notificationsTitle": "Notifications", "tabs": { "updates": "Mises à jour", - "messages": "Messages", - "priceAlerts": "[TODO: Translate] Price Alerts" + "messages": "Messages" }, "updateAvailable": "Mise à jour disponible", "noChangelogAvailable": "Aucun journal des modifications détaillé disponible. Consultez GitHub pour plus d'informations.", @@ -2385,34 +2374,6 @@ "shown": "Affiché {time}", "dismissed": "Ignoré {time}", "active": "Actif" - }, - "priceAlerts": { - "disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.", - "openSettings": "[TODO: Translate] Open settings", - "segmentsLabel": "[TODO: Translate] Alert kind", - "segments": { - "belowThreshold": "[TODO: Translate] Under threshold", - "becameFree": "[TODO: Translate] Became free" - }, - "thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz", - "empty": "[TODO: Translate] Nothing is under your price threshold right now.", - "emptyFree": "[TODO: Translate] No version has become free yet.", - "loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.", - "lastChecked": "[TODO: Translate] Prices last checked {when}", - "justNow": "[TODO: Translate] just now", - "minutesAgo": "[TODO: Translate] {count} min ago", - "hoursAgo": "[TODO: Translate] {count} h ago", - "daysAgo": "[TODO: Translate] {count} d ago", - "freeNow": "[TODO: Translate] Free now", - "blueBuzz": "[TODO: Translate] Blue Buzz OK", - "earlyAccessUntil": "[TODO: Translate] Early access until {date}", - "inLibrary": "[TODO: Translate] In library", - "notInLibrary": "[TODO: Translate] Not in library", - "droppedAgo": "[TODO: Translate] dropped {when}", - "openCivitai": "[TODO: Translate] Open on CivitAI", - "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", - "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/he.json b/locales/he.json index 93a6ef6e..2d602130 100644 --- a/locales/he.json +++ b/locales/he.json @@ -204,7 +204,6 @@ "error": "נכשל בבדיקת העדכונים עבור {type}: {message}", "gateEvents": { "becameFree": "[TODO: Translate] {count} version(s) became free", - "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", "newGate": "[TODO: Translate] {count} version(s) are now paid" } }, @@ -234,9 +233,6 @@ }, "groupByModel": { "label": "קיבוץ לפי מודל" - }, - "priceAlerts": { - "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -767,8 +763,6 @@ "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", "enabled": "[TODO: Translate] Track Buzz prices for paid versions", "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "threshold": "[TODO: Translate] Price alert threshold (Buzz)", - "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", "refreshHours": "[TODO: Translate] Price refresh interval (hours)", "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" }, @@ -945,9 +939,7 @@ "action": "עדכונים", "menuLabel": "הצגת אפשרויות עדכון", "check": "בדוק עדכונים", - "checkTooltip": "בדיקת עדכונים עלולה לקחת זמן.", - "priceAlerts": "[TODO: Translate] Price alerts", - "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" + "checkTooltip": "בדיקת עדכונים עלולה לקחת זמן." } }, "bulkOperations": { @@ -2031,15 +2023,13 @@ "paidTooltip": "גרסה זו דורשת תשלום כדי להוריד", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", "freeNow": "[TODO: Translate] Free Now", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "התעלם", "ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו", "onSiteOnly": "רק באתר", "onSiteOnlyTooltip": "גרסה זו זמינה רק ליצירה באתר CivitAI", - "priceUnavailable": "[TODO: Translate] Price unavailable", - "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" + "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" }, "actions": { "download": "הורדה", @@ -2328,8 +2318,7 @@ "notificationsTitle": "מרכז התראות", "tabs": { "updates": "עדכונים", - "messages": "הודעות", - "priceAlerts": "[TODO: Translate] Price Alerts" + "messages": "הודעות" }, "updateAvailable": "עדכון זמין", "noChangelogAvailable": "אין יומן שינויים מפורט זמין. בדוק ב-GitHub למידע נוסף.", @@ -2385,34 +2374,6 @@ "shown": "הוצג {time}", "dismissed": "הוסר {time}", "active": "פעיל" - }, - "priceAlerts": { - "disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.", - "openSettings": "[TODO: Translate] Open settings", - "segmentsLabel": "[TODO: Translate] Alert kind", - "segments": { - "belowThreshold": "[TODO: Translate] Under threshold", - "becameFree": "[TODO: Translate] Became free" - }, - "thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz", - "empty": "[TODO: Translate] Nothing is under your price threshold right now.", - "emptyFree": "[TODO: Translate] No version has become free yet.", - "loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.", - "lastChecked": "[TODO: Translate] Prices last checked {when}", - "justNow": "[TODO: Translate] just now", - "minutesAgo": "[TODO: Translate] {count} min ago", - "hoursAgo": "[TODO: Translate] {count} h ago", - "daysAgo": "[TODO: Translate] {count} d ago", - "freeNow": "[TODO: Translate] Free now", - "blueBuzz": "[TODO: Translate] Blue Buzz OK", - "earlyAccessUntil": "[TODO: Translate] Early access until {date}", - "inLibrary": "[TODO: Translate] In library", - "notInLibrary": "[TODO: Translate] Not in library", - "droppedAgo": "[TODO: Translate] dropped {when}", - "openCivitai": "[TODO: Translate] Open on CivitAI", - "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", - "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/ja.json b/locales/ja.json index b5e0f170..aef5fe03 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -204,7 +204,6 @@ "error": "{type} のアップデート確認に失敗しました: {message}", "gateEvents": { "becameFree": "[TODO: Translate] {count} version(s) became free", - "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", "newGate": "[TODO: Translate] {count} version(s) are now paid" } }, @@ -234,9 +233,6 @@ }, "groupByModel": { "label": "モデルでグループ化" - }, - "priceAlerts": { - "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -767,8 +763,6 @@ "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", "enabled": "[TODO: Translate] Track Buzz prices for paid versions", "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "threshold": "[TODO: Translate] Price alert threshold (Buzz)", - "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", "refreshHours": "[TODO: Translate] Price refresh interval (hours)", "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" }, @@ -945,9 +939,7 @@ "action": "アップデート", "menuLabel": "更新オプションを表示", "check": "アップデートを確認", - "checkTooltip": "確認には時間がかかる場合があります。", - "priceAlerts": "[TODO: Translate] Price alerts", - "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" + "checkTooltip": "確認には時間がかかる場合があります。" } }, "bulkOperations": { @@ -2031,15 +2023,13 @@ "paidTooltip": "このバージョンのダウンロードには支払いが必要です", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", "freeNow": "[TODO: Translate] Free Now", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "無視中", "ignoredTooltip": "このバージョンの更新通知は無効です", "onSiteOnly": "サイト内のみ", "onSiteOnlyTooltip": "このバージョンはCivitAIサイト内でのみ利用可能で、ダウンロードはできません", - "priceUnavailable": "[TODO: Translate] Price unavailable", - "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" + "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" }, "actions": { "download": "ダウンロード", @@ -2328,8 +2318,7 @@ "notificationsTitle": "通知センター", "tabs": { "updates": "更新", - "messages": "メッセージ", - "priceAlerts": "[TODO: Translate] Price Alerts" + "messages": "メッセージ" }, "updateAvailable": "更新が利用可能", "noChangelogAvailable": "詳細な変更ログは利用できません。詳細はGitHubでご確認ください。", @@ -2385,34 +2374,6 @@ "shown": "{time} に表示", "dismissed": "{time} に非表示", "active": "アクティブ" - }, - "priceAlerts": { - "disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.", - "openSettings": "[TODO: Translate] Open settings", - "segmentsLabel": "[TODO: Translate] Alert kind", - "segments": { - "belowThreshold": "[TODO: Translate] Under threshold", - "becameFree": "[TODO: Translate] Became free" - }, - "thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz", - "empty": "[TODO: Translate] Nothing is under your price threshold right now.", - "emptyFree": "[TODO: Translate] No version has become free yet.", - "loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.", - "lastChecked": "[TODO: Translate] Prices last checked {when}", - "justNow": "[TODO: Translate] just now", - "minutesAgo": "[TODO: Translate] {count} min ago", - "hoursAgo": "[TODO: Translate] {count} h ago", - "daysAgo": "[TODO: Translate] {count} d ago", - "freeNow": "[TODO: Translate] Free now", - "blueBuzz": "[TODO: Translate] Blue Buzz OK", - "earlyAccessUntil": "[TODO: Translate] Early access until {date}", - "inLibrary": "[TODO: Translate] In library", - "notInLibrary": "[TODO: Translate] Not in library", - "droppedAgo": "[TODO: Translate] dropped {when}", - "openCivitai": "[TODO: Translate] Open on CivitAI", - "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", - "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/ko.json b/locales/ko.json index 6914e74f..cf074d32 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -204,7 +204,6 @@ "error": "{type} 업데이트 확인 실패: {message}", "gateEvents": { "becameFree": "[TODO: Translate] {count} version(s) became free", - "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", "newGate": "[TODO: Translate] {count} version(s) are now paid" } }, @@ -234,9 +233,6 @@ }, "groupByModel": { "label": "모델별 그룹화" - }, - "priceAlerts": { - "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -767,8 +763,6 @@ "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", "enabled": "[TODO: Translate] Track Buzz prices for paid versions", "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "threshold": "[TODO: Translate] Price alert threshold (Buzz)", - "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", "refreshHours": "[TODO: Translate] Price refresh interval (hours)", "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" }, @@ -945,9 +939,7 @@ "action": "업데이트", "menuLabel": "업데이트 옵션 표시", "check": "업데이트 확인", - "checkTooltip": "업데이트 확인에는 시간이 걸릴 수 있습니다.", - "priceAlerts": "[TODO: Translate] Price alerts", - "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" + "checkTooltip": "업데이트 확인에는 시간이 걸릴 수 있습니다." } }, "bulkOperations": { @@ -2031,15 +2023,13 @@ "paidTooltip": "이 버전은 다운로드하려면 결제가 필요합니다", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", "freeNow": "[TODO: Translate] Free Now", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "무시됨", "ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다", "onSiteOnly": "사이트 내 전용", "onSiteOnlyTooltip": "이 버전은 CivitAI 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다", - "priceUnavailable": "[TODO: Translate] Price unavailable", - "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" + "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" }, "actions": { "download": "다운로드", @@ -2328,8 +2318,7 @@ "notificationsTitle": "알림 센터", "tabs": { "updates": "업데이트", - "messages": "메시지", - "priceAlerts": "[TODO: Translate] Price Alerts" + "messages": "메시지" }, "updateAvailable": "업데이트 사용 가능", "noChangelogAvailable": "상세한 변경 로그가 없습니다. 더 많은 정보는 GitHub를 확인하세요.", @@ -2385,34 +2374,6 @@ "shown": "{time}에 표시", "dismissed": "{time}에 닫힘", "active": "활성" - }, - "priceAlerts": { - "disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.", - "openSettings": "[TODO: Translate] Open settings", - "segmentsLabel": "[TODO: Translate] Alert kind", - "segments": { - "belowThreshold": "[TODO: Translate] Under threshold", - "becameFree": "[TODO: Translate] Became free" - }, - "thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz", - "empty": "[TODO: Translate] Nothing is under your price threshold right now.", - "emptyFree": "[TODO: Translate] No version has become free yet.", - "loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.", - "lastChecked": "[TODO: Translate] Prices last checked {when}", - "justNow": "[TODO: Translate] just now", - "minutesAgo": "[TODO: Translate] {count} min ago", - "hoursAgo": "[TODO: Translate] {count} h ago", - "daysAgo": "[TODO: Translate] {count} d ago", - "freeNow": "[TODO: Translate] Free now", - "blueBuzz": "[TODO: Translate] Blue Buzz OK", - "earlyAccessUntil": "[TODO: Translate] Early access until {date}", - "inLibrary": "[TODO: Translate] In library", - "notInLibrary": "[TODO: Translate] Not in library", - "droppedAgo": "[TODO: Translate] dropped {when}", - "openCivitai": "[TODO: Translate] Open on CivitAI", - "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", - "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/ru.json b/locales/ru.json index b7272b03..9137eb27 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -204,7 +204,6 @@ "error": "Не удалось проверить обновления для {type}: {message}", "gateEvents": { "becameFree": "[TODO: Translate] {count} version(s) became free", - "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", "newGate": "[TODO: Translate] {count} version(s) are now paid" } }, @@ -234,9 +233,6 @@ }, "groupByModel": { "label": "Группировать по модели" - }, - "priceAlerts": { - "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -767,8 +763,6 @@ "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", "enabled": "[TODO: Translate] Track Buzz prices for paid versions", "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "threshold": "[TODO: Translate] Price alert threshold (Buzz)", - "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", "refreshHours": "[TODO: Translate] Price refresh interval (hours)", "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" }, @@ -945,9 +939,7 @@ "action": "Обновления", "menuLabel": "Показать параметры обновления", "check": "Проверить обновления", - "checkTooltip": "Проверка может занять время.", - "priceAlerts": "[TODO: Translate] Price alerts", - "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" + "checkTooltip": "Проверка может занять время." } }, "bulkOperations": { @@ -2031,15 +2023,13 @@ "paidTooltip": "Скачивание этой версии платное", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", "freeNow": "[TODO: Translate] Free Now", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "Игнорируется", "ignoredTooltip": "Уведомления об обновлениях для этой версии отключены", "onSiteOnly": "Только на Сайте", "onSiteOnlyTooltip": "Эта версия доступна только для генерации на сайте CivitAI", - "priceUnavailable": "[TODO: Translate] Price unavailable", - "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" + "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" }, "actions": { "download": "Скачать", @@ -2328,8 +2318,7 @@ "notificationsTitle": "Центр уведомлений", "tabs": { "updates": "Обновления", - "messages": "Сообщения", - "priceAlerts": "[TODO: Translate] Price Alerts" + "messages": "Сообщения" }, "updateAvailable": "Доступно обновление", "noChangelogAvailable": "Подробный список изменений недоступен. Проверьте GitHub для получения дополнительной информации.", @@ -2385,34 +2374,6 @@ "shown": "Показано {time}", "dismissed": "Закрыто {time}", "active": "Активно" - }, - "priceAlerts": { - "disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.", - "openSettings": "[TODO: Translate] Open settings", - "segmentsLabel": "[TODO: Translate] Alert kind", - "segments": { - "belowThreshold": "[TODO: Translate] Under threshold", - "becameFree": "[TODO: Translate] Became free" - }, - "thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz", - "empty": "[TODO: Translate] Nothing is under your price threshold right now.", - "emptyFree": "[TODO: Translate] No version has become free yet.", - "loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.", - "lastChecked": "[TODO: Translate] Prices last checked {when}", - "justNow": "[TODO: Translate] just now", - "minutesAgo": "[TODO: Translate] {count} min ago", - "hoursAgo": "[TODO: Translate] {count} h ago", - "daysAgo": "[TODO: Translate] {count} d ago", - "freeNow": "[TODO: Translate] Free now", - "blueBuzz": "[TODO: Translate] Blue Buzz OK", - "earlyAccessUntil": "[TODO: Translate] Early access until {date}", - "inLibrary": "[TODO: Translate] In library", - "notInLibrary": "[TODO: Translate] Not in library", - "droppedAgo": "[TODO: Translate] dropped {when}", - "openCivitai": "[TODO: Translate] Open on CivitAI", - "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", - "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 16a0fc46..0037373f 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -204,7 +204,6 @@ "error": "检查 {type} 更新失败:{message}", "gateEvents": { "becameFree": "[TODO: Translate] {count} version(s) became free", - "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", "newGate": "[TODO: Translate] {count} version(s) are now paid" } }, @@ -234,9 +233,6 @@ }, "groupByModel": { "label": "按模型分组" - }, - "priceAlerts": { - "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -767,8 +763,6 @@ "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", "enabled": "[TODO: Translate] Track Buzz prices for paid versions", "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "threshold": "[TODO: Translate] Price alert threshold (Buzz)", - "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", "refreshHours": "[TODO: Translate] Price refresh interval (hours)", "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" }, @@ -945,9 +939,7 @@ "action": "更新", "menuLabel": "显示更新选项", "check": "检查更新", - "checkTooltip": "检查更新可能耗时。", - "priceAlerts": "[TODO: Translate] Price alerts", - "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" + "checkTooltip": "检查更新可能耗时。" } }, "bulkOperations": { @@ -2031,15 +2023,13 @@ "paidTooltip": "此版本需要付费后才能下载", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", "freeNow": "[TODO: Translate] Free Now", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "已忽略", "ignoredTooltip": "此版本已关闭更新通知", "onSiteOnly": "仅站内生成", "onSiteOnlyTooltip": "此版本仅在 CivitAI 站内可用,无法下载", - "priceUnavailable": "[TODO: Translate] Price unavailable", - "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" + "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" }, "actions": { "download": "下载", @@ -2328,8 +2318,7 @@ "notificationsTitle": "通知中心", "tabs": { "updates": "更新", - "messages": "消息", - "priceAlerts": "[TODO: Translate] Price Alerts" + "messages": "消息" }, "updateAvailable": "更新可用", "noChangelogAvailable": "没有详细的更新日志可用。请查看 GitHub 以获取更多信息。", @@ -2385,34 +2374,6 @@ "shown": "{time} 显示", "dismissed": "{time} 关闭", "active": "仍在显示" - }, - "priceAlerts": { - "disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.", - "openSettings": "[TODO: Translate] Open settings", - "segmentsLabel": "[TODO: Translate] Alert kind", - "segments": { - "belowThreshold": "[TODO: Translate] Under threshold", - "becameFree": "[TODO: Translate] Became free" - }, - "thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz", - "empty": "[TODO: Translate] Nothing is under your price threshold right now.", - "emptyFree": "[TODO: Translate] No version has become free yet.", - "loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.", - "lastChecked": "[TODO: Translate] Prices last checked {when}", - "justNow": "[TODO: Translate] just now", - "minutesAgo": "[TODO: Translate] {count} min ago", - "hoursAgo": "[TODO: Translate] {count} h ago", - "daysAgo": "[TODO: Translate] {count} d ago", - "freeNow": "[TODO: Translate] Free now", - "blueBuzz": "[TODO: Translate] Blue Buzz OK", - "earlyAccessUntil": "[TODO: Translate] Early access until {date}", - "inLibrary": "[TODO: Translate] In library", - "notInLibrary": "[TODO: Translate] Not in library", - "droppedAgo": "[TODO: Translate] dropped {when}", - "openCivitai": "[TODO: Translate] Open on CivitAI", - "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", - "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 8767b248..a9fb564c 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -204,7 +204,6 @@ "error": "檢查 {type} 更新失敗:{message}", "gateEvents": { "becameFree": "[TODO: Translate] {count} version(s) became free", - "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", "newGate": "[TODO: Translate] {count} version(s) are now paid" } }, @@ -234,9 +233,6 @@ }, "groupByModel": { "label": "按模型分組" - }, - "priceAlerts": { - "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -767,8 +763,6 @@ "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", "enabled": "[TODO: Translate] Track Buzz prices for paid versions", "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "threshold": "[TODO: Translate] Price alert threshold (Buzz)", - "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", "refreshHours": "[TODO: Translate] Price refresh interval (hours)", "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" }, @@ -945,9 +939,7 @@ "action": "更新", "menuLabel": "顯示更新選項", "check": "檢查更新", - "checkTooltip": "檢查更新可能耗時。", - "priceAlerts": "[TODO: Translate] Price alerts", - "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" + "checkTooltip": "檢查更新可能耗時。" } }, "bulkOperations": { @@ -2031,15 +2023,13 @@ "paidTooltip": "此版本需要付費才能下載", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", "freeNow": "[TODO: Translate] Free Now", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "已忽略", "ignoredTooltip": "此版本已關閉更新通知", "onSiteOnly": "僅站內生成", "onSiteOnlyTooltip": "此版本僅在 CivitAI 站內可用,無法下載", - "priceUnavailable": "[TODO: Translate] Price unavailable", - "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" + "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" }, "actions": { "download": "下載", @@ -2328,8 +2318,7 @@ "notificationsTitle": "通知中心", "tabs": { "updates": "更新", - "messages": "訊息", - "priceAlerts": "[TODO: Translate] Price Alerts" + "messages": "訊息" }, "updateAvailable": "有新版本可用", "noChangelogAvailable": "無詳細更新日誌。請至 GitHub 查看更多資訊。", @@ -2385,34 +2374,6 @@ "shown": "{time} 顯示", "dismissed": "{time} 關閉", "active": "仍在顯示" - }, - "priceAlerts": { - "disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.", - "openSettings": "[TODO: Translate] Open settings", - "segmentsLabel": "[TODO: Translate] Alert kind", - "segments": { - "belowThreshold": "[TODO: Translate] Under threshold", - "becameFree": "[TODO: Translate] Became free" - }, - "thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz", - "empty": "[TODO: Translate] Nothing is under your price threshold right now.", - "emptyFree": "[TODO: Translate] No version has become free yet.", - "loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.", - "lastChecked": "[TODO: Translate] Prices last checked {when}", - "justNow": "[TODO: Translate] just now", - "minutesAgo": "[TODO: Translate] {count} min ago", - "hoursAgo": "[TODO: Translate] {count} h ago", - "daysAgo": "[TODO: Translate] {count} d ago", - "freeNow": "[TODO: Translate] Free now", - "blueBuzz": "[TODO: Translate] Blue Buzz OK", - "earlyAccessUntil": "[TODO: Translate] Early access until {date}", - "inLibrary": "[TODO: Translate] In library", - "notInLibrary": "[TODO: Translate] Not in library", - "droppedAgo": "[TODO: Translate] dropped {when}", - "openCivitai": "[TODO: Translate] Open on CivitAI", - "openLocal": "[TODO: Translate] Open", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", - "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index 9fa82a62..459a6221 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -2242,11 +2242,6 @@ class ServiceRegistryAdapter: get_downloaded_version_history_service: Callable[[], Awaitable[Any]] get_backup_service: Callable[[], Awaitable[Any]] = _noop_backup_service get_other_scanner: Callable[[], Awaitable[Any]] = ServiceRegistry.get_other_scanner - # Shared across model types (the update DB is per library), which is what lets - # the global price-alerts endpoint cover every type in one query. - get_model_update_service: Callable[[], Awaitable[Any]] = ( - ServiceRegistry.get_model_update_service - ) class ModelLibraryHandler: @@ -4319,157 +4314,6 @@ class SidecarMigrationHandler: return web.json_response({"success": False, "error": str(exc)}, status=500) -class PriceAlertsHandler: - """Data for the global Buzz price-alerts panel. - - App-wide on purpose: the update DB is one file per library shared by every - model type, and ``ServiceRegistry.get_model_update_service()`` hands out one - instance, so the whole panel is a single query. Registered once (see - ``MISC_ROUTE_DEFINITIONS``) rather than once per model type. - """ - - # Model type -> adapter attribute, for the best-effort local name/path lookup. - _SCANNER_ATTRS = { - "lora": "get_lora_scanner", - "checkpoint": "get_checkpoint_scanner", - "embedding": "get_embedding_scanner", - "other": "get_other_scanner", - } - - def __init__( - self, - *, - settings_service, - service_registry: ServiceRegistryAdapter, - ) -> None: - self._settings = settings_service - self._service_registry = service_registry - - def _setting(self, key: str, default: Any) -> Any: - if self._settings is None: - return default - try: - value = self._settings.get(key, default) - except Exception: - return default - return default if value is None else value - - async def get_price_alerts(self, request: web.Request) -> web.Response: - """Versions under the alert threshold, plus the ones that became free. - - GET with an optional ``limit`` so the companion extension can call it too. - """ - - try: - limit = int(request.query.get("limit") or 200) - except (TypeError, ValueError): - limit = 200 - - threshold = 0 - try: - threshold = max(0, int(self._setting("price_alert_threshold_buzz", 0))) - except (TypeError, ValueError): - threshold = 0 - - try: - update_service = await self._service_registry.get_model_update_service() - except Exception as exc: # pragma: no cover - defensive - logger.error("Price alerts unavailable: %s", exc, exc_info=True) - return web.json_response( - {"success": False, "error": "Update service unavailable"}, status=503 - ) - - try: - alerts = await update_service.get_price_alerts( - None, threshold_buzz=threshold, limit=limit - ) - newest_checked_at = update_service.newest_price_checked_at() - unavailable_count = update_service.count_unavailable_prices() - priced_count = update_service.count_priced_versions() - except Exception as exc: - logger.error("Failed to load price alerts: %s", exc, exc_info=True) - return web.json_response( - {"success": False, "error": str(exc)}, status=500 - ) - - await self._decorate_local_context(alerts) - - civitai_host = self._setting("civitai_host", "civitai.com") - for alert in alerts: - alert["civitaiUrl"] = build_civitai_model_page_url( - alert.get("modelId"), - alert.get("versionId"), - host=civitai_host, - ) - - return web.json_response( - { - "success": True, - "enabled": bool(self._setting("price_tracking_enabled", False)), - "thresholdBuzz": threshold, - "newestCheckedAt": newest_checked_at, - # Gated versions whose price could not be read (mature models - # are served only by the challenged civitai.red host). - "unavailableCount": unavailable_count, - # Lets the empty state explain a 0 Buzz threshold instead of - # claiming there is simply nothing to show. - "pricedCount": priced_count, - "alerts": alerts, - } - ) - - async def _decorate_local_context(self, alerts: Sequence[Dict[str, Any]]) -> None: - """Attach ``modelName`` / ``filePath`` / ``fileName`` when the model is local. - - Best-effort by design: the panel must render even when a scanner cache is - cold or a type is unavailable, so every failure just leaves the fields as - None and the UI hides the affordances that need them. - """ - - by_type: Dict[str, set] = {} - for alert in alerts: - model_type = alert.get("modelType") - model_id = alert.get("modelId") - if not model_type or model_id is None: - continue - by_type.setdefault(model_type, set()).add(model_id) - - for model_type, model_ids in by_type.items(): - attr = self._SCANNER_ATTRS.get(model_type) - scanner_getter = getattr(self._service_registry, attr, None) if attr else None - if scanner_getter is None: - continue - try: - scanner = await scanner_getter() - cache = await scanner.get_cached_data() - except Exception as exc: - logger.debug( - "Skipping local context for %s price alerts: %s", model_type, exc - ) - continue - - model_index = getattr(cache, "model_id_index", None) or {} - version_index = getattr(cache, "version_index", None) or {} - for alert in alerts: - if alert.get("modelType") != model_type: - continue - if alert.get("modelId") not in model_ids: - continue - - entries = model_index.get(alert.get("modelId")) or [] - if entries and isinstance(entries[0], Mapping): - first = entries[0] - alert["modelName"] = first.get("model_name") or first.get( - "file_name" - ) - - version_entry = version_index.get(alert.get("versionId")) - if isinstance(version_entry, Mapping): - alert["filePath"] = version_entry.get("file_path") - alert["fileName"] = version_entry.get("file_name") - alert.setdefault("modelName", version_entry.get("model_name")) - - class MiscHandlerSet: """Aggregate handlers into a lookup compatible with the registrar.""" @@ -4493,7 +4337,6 @@ class MiscHandlerSet: doctor: DoctorHandler, example_workflows: ExampleWorkflowsHandler, base_model: BaseModelHandlerSet, - price_alerts: PriceAlertsHandler, model_source_handler: Any = None, agent_handler: Any = None, download_routing: Any = None, @@ -4516,7 +4359,6 @@ class MiscHandlerSet: self.doctor = doctor self.example_workflows = example_workflows self.base_model = base_model - self.price_alerts = price_alerts self.model_source_handler = model_source_handler self.agent_handler = agent_handler self.download_routing = download_routing @@ -4530,7 +4372,6 @@ class MiscHandlerSet: "get_init_status": self.health.get_init_status, "get_settings": self.settings.get_settings, "update_settings": self.settings.update_settings, - "get_price_alerts": self.price_alerts.get_price_alerts, "get_doctor_diagnostics": self.doctor.get_doctor_diagnostics, "repair_doctor_cache": self.doctor.repair_doctor_cache, "resolve_doctor_filename_conflicts": self.doctor.resolve_filename_conflicts, @@ -4607,5 +4448,4 @@ def build_service_registry_adapter() -> ServiceRegistryAdapter: get_other_scanner=ServiceRegistry.get_other_scanner, get_downloaded_version_history_service=ServiceRegistry.get_downloaded_version_history_service, get_backup_service=ServiceRegistry.get_backup_service, - get_model_update_service=ServiceRegistry.get_model_update_service, ) diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index 4d090821..f7c74c58 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -3070,46 +3070,6 @@ class ModelUpdateHandler: {"success": True, "record": self._serialize_record(record)} ) - async def get_price_alerts(self, request: web.Request) -> web.Response: - """List gated versions whose stored price crosses the alert threshold. - - Backed by the persisted alert state, so it matches the badges; the - threshold (and whether price tracking runs at all) comes from settings. - """ - - try: - limit = int(request.query.get("limit") or 200) - except (TypeError, ValueError): - limit = 200 - - alerts = await self._update_service.get_price_alerts( - self._service.model_type, limit=limit - ) - threshold = 0 - enabled = False - if self._settings is not None: - try: - threshold = int( - self._settings.get("price_alert_threshold_buzz", 0) or 0 - ) - except (TypeError, ValueError): - threshold = 0 - try: - enabled = bool( - self._settings.get("price_tracking_enabled", False) - ) - except Exception: - enabled = False - - return web.json_response( - { - "success": True, - "enabled": enabled, - "thresholdBuzz": threshold, - "alerts": alerts, - } - ) - async def get_model_versions(self, request: web.Request) -> web.Response: model_id = self._normalize_model_id(request.match_info.get("model_id")) if model_id is None: @@ -3511,11 +3471,6 @@ class ModelUpdateHandler: "acceptsBlueBuzz": bool(getattr(version, "accepts_blue_buzz", False)), "priceSaleEndsAt": getattr(version, "price_sale_ends_at", None), "priceCheckedAt": getattr(version, "price_checked_at", None), - # Set when a price fetch was attempted (success or not): a gated - # version with no price and an attempt marker is "unavailable", - # which is the honest state for mature models. - "priceAttemptedAt": getattr(version, "price_check_attempted_at", None), - "priceAlert": bool(getattr(version, "price_alert_state", False)), "filePath": context.get("file_path"), "fileName": context.get("file_name"), # Weight-file variant count (None when unknown); lets the UI hide @@ -3678,6 +3633,5 @@ class ModelHandlerSet: "set_version_update_ignore": self.updates.set_version_update_ignore, "get_model_update_status": self.updates.get_model_update_status, "get_model_versions": self.updates.get_model_versions, - "get_price_alerts": self.updates.get_price_alerts, "cancel_task": self.query.cancel_task, } diff --git a/py/routes/misc_route_registrar.py b/py/routes/misc_route_registrar.py index b479391f..73e5311d 100644 --- a/py/routes/misc_route_registrar.py +++ b/py/routes/misc_route_registrar.py @@ -24,7 +24,6 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition("POST", "/api/lm/settings", "update_settings"), # App-wide and registered once: the alerts panel spans every model type, and # the update DB is shared, so there is nothing per-type about it. - RouteDefinition("GET", "/api/lm/price-alerts", "get_price_alerts"), RouteDefinition("GET", "/api/lm/llm/models", "get_llm_models"), RouteDefinition("GET", "/api/lm/llm/provider-models", "get_provider_models"), RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"), diff --git a/py/routes/misc_routes.py b/py/routes/misc_routes.py index bc1444e3..03b980a0 100644 --- a/py/routes/misc_routes.py +++ b/py/routes/misc_routes.py @@ -31,7 +31,6 @@ from .handlers.misc_handlers import ( ModelLibraryHandler, NodeRegistry, NodeRegistryHandler, - PriceAlertsHandler, SettingsHandler, SidecarMigrationHandler, SupportersHandler, @@ -141,10 +140,6 @@ class MiscRoutes: doctor = DoctorHandler(settings_service=self._settings) example_workflows = ExampleWorkflowsHandler() base_model = BaseModelHandlerSet() - price_alerts = PriceAlertsHandler( - settings_service=self._settings, - service_registry=self._service_registry_adapter, - ) model_source_handler = ModelSourceHandler() agent_handler = AgentHandler() download_routing = DownloadRoutingHandler() @@ -168,7 +163,6 @@ class MiscRoutes: doctor=doctor, example_workflows=example_workflows, base_model=base_model, - price_alerts=price_alerts, model_source_handler=model_source_handler, agent_handler=agent_handler, download_routing=download_routing, diff --git a/py/routes/model_route_registrar.py b/py/routes/model_route_registrar.py index d62812be..066b3fa3 100644 --- a/py/routes/model_route_registrar.py +++ b/py/routes/model_route_registrar.py @@ -110,9 +110,6 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition( "GET", "/api/lm/{prefix}/updates/versions/{model_id}", "get_model_versions" ), - RouteDefinition( - "GET", "/api/lm/{prefix}/updates/price-alerts", "get_price_alerts" - ), RouteDefinition("POST", "/api/lm/download-model", "download_model"), RouteDefinition("GET", "/api/lm/download-model-get", "download_model_get"), RouteDefinition("GET", "/api/lm/cancel-download-get", "cancel_download_get"), diff --git a/py/services/model_update_service.py b/py/services/model_update_service.py index 2e904535..d90ed9ab 100644 --- a/py/services/model_update_service.py +++ b/py/services/model_update_service.py @@ -121,13 +121,6 @@ class ModelVersionRecord: accepts_blue_buzz: bool = False price_sale_ends_at: Optional[str] = None price_checked_at: Optional[float] = None - # Last computed "price alert threshold hit" state, used to detect the edge - # (0 -> 1) that fires a one-shot price_drop event. - price_alert_state: bool = False - # When the alert state last flipped to True (POSIX seconds); cleared when the - # price rises back above the threshold. Gives the panel "dropped X ago" and - # the unread count something to compare against. - price_alert_since: Optional[float] = None # When a price fetch was last *attempted* (success or failure). Distinguishes # "never tried" from "tried and no price is readable", which is what lets the # UI say "price unavailable" for mature models instead of showing nothing. @@ -145,9 +138,9 @@ class ModelUpdateRecord: should_ignore_model: bool # Gate-state and price transitions observed during the refresh that produced # this record. Derived, never persisted: a record read back from SQLite has an - # empty list. Each entry is {"versionId", "kind", "versionName", - # "isInLibrary", "priceBuzz"?} with kind in - # {"new_gate", "became_free", "price_drop"}. + # empty list. Each entry is {"versionId", "kind", "versionName", "isInLibrary"} + # with kind in {"new_gate", "became_free"}. Only versions the user does not + # have produce events. events: List[Dict[str, Any]] = field(default_factory=list) @property @@ -381,8 +374,6 @@ class ModelUpdateService: accepts_blue_buzz INTEGER NOT NULL DEFAULT 0, price_sale_ends_at TEXT, price_checked_at REAL, - price_alert_state INTEGER NOT NULL DEFAULT 0, - price_alert_since REAL, price_check_attempted_at REAL, PRIMARY KEY (model_id, version_id), FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE @@ -663,14 +654,6 @@ class ModelUpdateService: "ALTER TABLE model_update_versions " "ADD COLUMN price_checked_at REAL" ), - "price_alert_state": ( - "ALTER TABLE model_update_versions " - "ADD COLUMN price_alert_state INTEGER NOT NULL DEFAULT 0" - ), - "price_alert_since": ( - "ALTER TABLE model_update_versions " - "ADD COLUMN price_alert_since REAL" - ), "price_check_attempted_at": ( "ALTER TABLE model_update_versions " "ADD COLUMN price_check_attempted_at REAL" @@ -681,6 +664,21 @@ class ModelUpdateService: if column not in version_columns: conn.execute(statement) + # Columns this feature carried while it was still in development. They are + # gone from the schema, so only a database created by an unreleased build + # can still have them; for everyone else this is a no-op. `DROP COLUMN` + # needs SQLite 3.35+, hence the guard. + for obsolete in ("price_alert_state", "price_alert_since"): + if obsolete not in version_columns: + continue + try: + conn.execute( + f"ALTER TABLE model_update_versions DROP COLUMN {obsolete}" + ) + logger.info("Dropped obsolete column %s", obsolete) + except sqlite3.OperationalError as exc: # pragma: no cover - old SQLite + logger.debug("Could not drop obsolete column %s: %s", obsolete, exc) + # Refresh column metadata after applying additive migrations. version_columns = self._get_table_columns(conn, "model_update_versions") @@ -786,8 +784,6 @@ class ModelUpdateService: accepts_blue_buzz INTEGER NOT NULL DEFAULT 0, price_sale_ends_at TEXT, price_checked_at REAL, - price_alert_state INTEGER NOT NULL DEFAULT 0, - price_alert_since REAL, price_check_attempted_at REAL, PRIMARY KEY (model_id, version_id), FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE @@ -818,8 +814,6 @@ class ModelUpdateService: "accepts_blue_buzz", "price_sale_ends_at", "price_checked_at", - "price_alert_state", - "price_alert_since", "price_check_attempted_at", ] defaults = { @@ -843,8 +837,6 @@ class ModelUpdateService: "accepts_blue_buzz": "0", "price_sale_ends_at": "NULL", "price_checked_at": "NULL", - "price_alert_state": "0", - "price_alert_since": "NULL", "price_check_attempted_at": "NULL", } @@ -1226,205 +1218,6 @@ class ModelUpdateService: async with self._lock: return self._get_records_bulk(model_type, normalized_ids) - async def get_price_alerts( - self, - model_type: Optional[str] = None, - *, - threshold_buzz: Optional[int] = None, - limit: int = 200, - ) -> List[Dict[str, Any]]: - """Return the versions worth alerting on, for one type or for all of them. - - The threshold is compared **at read time** rather than reading the persisted - ``price_alert_state``: otherwise editing the threshold in the panel would - not take effect until the next refresh. ``price_alert_state`` stays the - source of the one-shot toast edge. - - Two kinds are returned in one list: - - * ``below_threshold`` — gated versions whose effective price is at or below - the threshold (only possible when price capture has run); - * ``became_free`` — versions whose gate lapsed, which needs no price data at - all, so they are reported even while price tracking is switched off. - - ``model_type=None`` covers every type, which is what the global alerts panel - wants; the update DB is shared across types, so that is still one query. - """ - - try: - normalized_limit = max(1, min(int(limit), 1000)) - except (TypeError, ValueError): - normalized_limit = 200 - - if threshold_buzz is None: - threshold = self._price_alert_threshold_buzz() - else: - try: - threshold = max(0, int(threshold_buzz)) - except (TypeError, ValueError): - threshold = self._price_alert_threshold_buzz() - - params: List[Any] = [] - type_filter = "" - if model_type: - type_filter = "AND s.model_type = ?" - params.append(model_type) - params.append(threshold) - params.append(normalized_limit) - - with self._connect() as conn: - rows = conn.execute( - f""" - SELECT v.model_id, - v.version_id, - v.name, - v.price_buzz, - v.list_price_buzz, - v.accepts_blue_buzz, - v.price_sale_ends_at, - v.price_checked_at, - v.price_alert_since, - v.gate_lapsed_at, - v.is_in_library, - v.early_access_ends_at, - v.paid_access, - v.is_paid, - v.is_early_access, - s.model_type - FROM model_update_versions v - JOIN model_update_status s ON s.model_id = v.model_id - WHERE v.should_ignore = 0 - AND s.should_ignore_model = 0 - {type_filter} - AND ( - (v.price_buzz IS NOT NULL AND v.price_buzz <= ?) - OR (v.gate_lapsed_at IS NOT NULL - AND v.paid_access IS NULL - AND v.is_paid = 0 - AND v.is_early_access = 0) - ) - ORDER BY (v.price_buzz IS NULL) ASC, - v.price_buzz ASC, - v.gate_lapsed_at DESC, - v.model_id ASC, - v.version_id ASC - LIMIT ? - """, - tuple(params), - ).fetchall() - - alerts: List[Dict[str, Any]] = [] - for row in rows: - price_buzz = _normalize_int(row["price_buzz"]) - below_threshold = price_buzz is not None and price_buzz <= threshold - alerts.append( - { - "modelId": int(row["model_id"]), - "modelType": row["model_type"], - "versionId": int(row["version_id"]), - "versionName": row["name"], - "kind": "below_threshold" if below_threshold else "became_free", - "priceBuzz": price_buzz if below_threshold else None, - "listPriceBuzz": ( - _normalize_int(row["list_price_buzz"]) - if below_threshold - else None - ), - "acceptsBlueBuzz": bool(row["accepts_blue_buzz"]), - "priceSaleEndsAt": row["price_sale_ends_at"], - "priceCheckedAt": ( - float(row["price_checked_at"]) - if row["price_checked_at"] is not None - else None - ), - "priceAlertSince": ( - float(row["price_alert_since"]) - if row["price_alert_since"] is not None - else None - ), - "gateLapsedAt": row["gate_lapsed_at"], - "isInLibrary": bool(row["is_in_library"]), - "earlyAccessEndsAt": row["early_access_ends_at"], - "isPaid": bool(row["is_paid"]), - "isEarlyAccess": bool(row["is_early_access"]), - } - ) - return alerts - - def newest_price_checked_at(self) -> Optional[float]: - """Newest successful price fetch across the tracked versions.""" - - with self._connect() as conn: - row = conn.execute( - "SELECT MAX(price_checked_at) AS newest FROM model_update_versions" - ).fetchone() - if row is None or row["newest"] is None: - return None - return float(row["newest"]) - - def count_priced_versions(self, model_type: Optional[str] = None) -> int: - """Versions with a known price, for the panel's empty state. - - Without it, a user whose threshold is 0 sees "nothing is under your price - threshold" while dozens of paid versions already have a price. - """ - - params: List[Any] = [] - type_filter = "" - if model_type: - type_filter = "AND s.model_type = ?" - params.append(model_type) - - with self._connect() as conn: - row = conn.execute( - f""" - SELECT COUNT(*) AS priced - FROM model_update_versions v - JOIN model_update_status s ON s.model_id = v.model_id - WHERE v.should_ignore = 0 - AND s.should_ignore_model = 0 - {type_filter} - AND v.price_buzz IS NOT NULL - """, - tuple(params), - ).fetchone() - if row is None: - return 0 - return int(row["priced"]) - - def count_unavailable_prices(self, model_type: Optional[str] = None) -> int: - """Gated versions whose price we tried to read and could not. - - Mostly mature models: their pages are served only by ``civitai.red``, which - refuses non-browser clients, while ``civitai.com`` hides them from - anonymous visitors. Reported so the UI can be honest instead of silent. - """ - - params: List[Any] = [] - type_filter = "" - if model_type: - type_filter = "AND s.model_type = ?" - params.append(model_type) - - with self._connect() as conn: - row = conn.execute( - f""" - SELECT COUNT(*) AS unavailable - FROM model_update_versions v - JOIN model_update_status s ON s.model_id = v.model_id - WHERE v.should_ignore = 0 - AND s.should_ignore_model = 0 - {type_filter} - AND v.price_check_attempted_at IS NOT NULL - AND v.price_buzz IS NULL - AND (v.paid_access IS NOT NULL OR v.is_paid = 1 OR v.is_early_access = 1) - """, - tuple(params), - ).fetchone() - if row is None: - return 0 - return int(row["unavailable"]) - async def _refresh_single_model( self, model_type: str, @@ -2027,23 +1820,15 @@ class ModelUpdateService: price_fields = self._price_fields_for( remote_version, existing_version, is_gated=is_gated ) - price_fields["price_alert_state"] = self._evaluate_price_alert( - price_fields.get("price_buzz") - ) - # `price_alert_since` records when the alert last started, so the panel - # can say "dropped 3 days ago" and count what is new since the user - # last looked. A still-standing alert keeps its original timestamp; a - # price back above the threshold clears it. - if not price_fields["price_alert_state"]: - price_fields["price_alert_since"] = None - elif existing_version is not None and existing_version.price_alert_state: - price_fields["price_alert_since"] = existing_version.price_alert_since - else: - price_fields["price_alert_since"] = timestamp + # Only the edge fires, and only for a version the user does not have: + # a version already on disk cannot become cheaper *for them*, and a + # version that stays gated must not re-announce itself every refresh. + in_library = version_id in effective_local_set if ( existing_version is not None and not should_ignore + and not in_library and was_gated != is_gated ): events.append( @@ -2051,25 +1836,7 @@ class ModelUpdateService: "versionId": version_id, "kind": "new_gate" if is_gated else "became_free", "versionName": remote_version.name, - "isInLibrary": version_id in effective_local_set, - } - ) - - # Only the edge fires: a version that keeps sitting under the threshold - # must not re-announce itself on every refresh. - if ( - existing_version is not None - and not should_ignore - and price_fields["price_alert_state"] - and not existing_version.price_alert_state - ): - events.append( - { - "versionId": version_id, - "kind": "price_drop", - "versionName": remote_version.name, - "isInLibrary": version_id in effective_local_set, - "priceBuzz": price_fields.get("price_buzz"), + "isInLibrary": False, } ) @@ -2174,8 +1941,6 @@ class ModelUpdateService: "accepts_blue_buzz": False, "price_sale_ends_at": None, "price_checked_at": None, - "price_alert_state": False, - "price_alert_since": None, "price_check_attempted_at": None, } @@ -2191,8 +1956,6 @@ class ModelUpdateService: "accepts_blue_buzz": source.accepts_blue_buzz, "price_sale_ends_at": source.price_sale_ends_at, "price_checked_at": source.price_checked_at, - "price_alert_state": source.price_alert_state, - "price_alert_since": source.price_alert_since, "price_check_attempted_at": source.price_check_attempted_at, } @@ -2204,28 +1967,6 @@ class ModelUpdateService: except Exception: return False - def _price_alert_threshold_buzz(self) -> int: - """Buzz threshold for a price alert; 0 means "free only".""" - - try: - value = int(self._settings.get("price_alert_threshold_buzz", 0) or 0) - except (TypeError, ValueError): - return 0 - return max(0, value) - - def _evaluate_price_alert(self, price_buzz: Optional[int]) -> bool: - """Whether a version's price crosses the user's alert threshold. - - Free versions never carry a price, so a threshold of 0 alerts only via the - ``became_free`` event — which is exactly the "or free" half of the request. - """ - - if not self._price_tracking_enabled(): - return False - if price_buzz is None: - return False - return price_buzz <= self._price_alert_threshold_buzz() - def _price_check_ttl_seconds(self) -> float: try: hours = float(self._settings.get("price_check_ttl_hours", 24)) @@ -2595,8 +2336,8 @@ class ModelUpdateService: size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at, is_early_access, usage_control, paid_access, is_paid, file_count, gate_lapsed_at, price_buzz, list_price_buzz, generation_price_buzz, - accepts_blue_buzz, price_sale_ends_at, price_checked_at, price_alert_state, - price_alert_since, price_check_attempted_at + accepts_blue_buzz, price_sale_ends_at, price_checked_at, + price_check_attempted_at FROM model_update_versions WHERE model_id IN ({placeholders}) ORDER BY model_id ASC, sort_index ASC, version_id ASC @@ -2639,12 +2380,6 @@ class ModelUpdateService: if row["price_checked_at"] is not None else None ), - price_alert_state=bool(row["price_alert_state"]), - price_alert_since=( - float(row["price_alert_since"]) - if row["price_alert_since"] is not None - else None - ), price_check_attempted_at=( float(row["price_check_attempted_at"]) if row["price_check_attempted_at"] is not None @@ -2714,9 +2449,9 @@ class ModelUpdateService: size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at, is_early_access, usage_control, paid_access, is_paid, file_count, gate_lapsed_at, price_buzz, list_price_buzz, generation_price_buzz, - accepts_blue_buzz, price_sale_ends_at, price_checked_at, price_alert_state, - price_alert_since, price_check_attempted_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + accepts_blue_buzz, price_sale_ends_at, price_checked_at, + price_check_attempted_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( version.version_id, @@ -2742,8 +2477,6 @@ class ModelUpdateService: 1 if version.accepts_blue_buzz else 0, version.price_sale_ends_at, version.price_checked_at, - 1 if version.price_alert_state else 0, - version.price_alert_since, version.price_check_attempted_at, ), ) diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index bc665810..35f0a5ea 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -131,8 +131,9 @@ DEFAULT_SETTINGS: Dict[str, Any] = { "version_grouping": "same_base", # Buzz price tracking for paid/early-access versions. Opt-in because reading a # price costs one extra (public) model-page request per gated model. + # Plumbing switch: reading a price costs one extra request per paid model, so + # it stays opt-in. Prices decorate the version list; nothing alerts on a number. "price_tracking_enabled": False, - "price_alert_threshold_buzz": 0, # 0 = alert on "became free" only "price_check_ttl_hours": 24, "auto_organize_exclusions": [], "metadata_refresh_skip_paths": [], diff --git a/static/css/components/modal/update-modal.css b/static/css/components/modal/update-modal.css index 37a05196..bcc0b572 100644 --- a/static/css/components/modal/update-modal.css +++ b/static/css/components/modal/update-modal.css @@ -652,167 +652,3 @@ margin-top: 5px; } } - -/* --- Buzz price alerts panel --------------------------------------------- */ - -.price-alerts { - display: flex; - flex-direction: column; - gap: var(--space-2); -} - -.price-alerts-disabled { - border: 1px solid var(--lora-border); - border-radius: var(--border-radius-sm); - background: var(--lora-surface); - padding: var(--space-2); - display: flex; - flex-direction: column; - gap: var(--space-2); - align-items: flex-start; -} - -.price-alerts-disabled-text { - margin: 0; - color: var(--text-color); - opacity: 0.85; - font-size: 0.9rem; -} - -.price-alerts-settings-btn { - display: inline-flex; - align-items: center; - gap: var(--space-1); - padding: 0.4rem 0.7rem; - border: 1px solid var(--lora-border); - border-radius: var(--border-radius-sm); - background: var(--card-bg, #fff); - color: var(--text-color); - cursor: pointer; -} - -.price-alerts-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-2); - flex-wrap: wrap; -} - -.price-alerts-segments { - display: inline-flex; - gap: var(--space-1); -} - -.price-alerts-segment { - padding: 0.35rem 0.65rem; - border: 1px solid var(--lora-border); - border-radius: var(--border-radius-sm); - background: var(--lora-surface); - color: var(--text-color); - cursor: pointer; - font-size: 0.85rem; -} - -.price-alerts-segment.active { - background: var(--lora-accent, #845ef7); - border-color: var(--lora-accent, #845ef7); - color: #fff; -} - -.price-alerts-threshold { - font-size: 0.85rem; - opacity: 0.8; -} - -.price-alerts-note { - margin: 0; - font-size: 0.8rem; - opacity: 0.75; -} - -.price-alerts-empty { - margin: var(--space-2) 0 0; - opacity: 0.7; - font-size: 0.9rem; -} - -.price-alerts-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: var(--space-2); - max-height: 46vh; - overflow-y: auto; -} - -.price-alert-item { - border: 1px solid var(--lora-border); - border-radius: var(--border-radius-sm); - padding: var(--space-2); - background: var(--card-bg, #fff); - display: flex; - flex-direction: column; - gap: 0.35rem; -} - -.price-alert-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-2); - flex-wrap: wrap; -} - -.price-alert-model { - font-weight: 600; -} - -.price-alert-price { - font-variant-numeric: tabular-nums; - font-weight: 600; -} - -.price-alert-price.is-alert { - color: #2b8a3e; -} - -[data-theme="dark"] .price-alert-price.is-alert { - color: #69db7c; -} - -.price-alert-was { - text-decoration: line-through; - opacity: 0.6; - font-weight: 400; - margin-left: 0.35rem; -} - -.price-alert-meta { - font-size: 0.8rem; - opacity: 0.75; - display: flex; - flex-wrap: wrap; - gap: 0.5rem; -} - -.price-alert-actions { - display: flex; - gap: var(--space-1); - flex-wrap: wrap; -} - -.price-alert-action { - display: inline-flex; - align-items: center; - gap: 0.3rem; - padding: 0.25rem 0.55rem; - border: 1px solid var(--lora-border); - border-radius: var(--border-radius-sm); - background: var(--lora-surface); - color: var(--text-color); - cursor: pointer; - font-size: 0.8rem; -} diff --git a/static/js/components/ContextMenu/GlobalContextMenu.js b/static/js/components/ContextMenu/GlobalContextMenu.js index c4b037ce..36e5fad4 100644 --- a/static/js/components/ContextMenu/GlobalContextMenu.js +++ b/static/js/components/ContextMenu/GlobalContextMenu.js @@ -4,7 +4,6 @@ import { translate } from '../../utils/i18nHelpers.js'; import { state } from '../../state/index.js'; import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js'; import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js'; -import { openPriceAlertsPanel, updateService } from '../../managers/UpdateService.js'; import { rematchModalManager } from '../../managers/RematchModalManager.js'; import { showRematchSummary } from '../RematchSummaryModal.js'; @@ -28,7 +27,6 @@ export class GlobalContextMenu extends BaseContextMenu { const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]'); const rematchRecipesItem = this.menu.querySelector('[data-action="rematch-recipes"]'); const groupByModelItem = this.menu.querySelector('[data-action="toggle-group-by-model"]'); - const priceAlertsItem = this.menu.querySelector('[data-action="price-alerts"]'); const groupByModelCheck = groupByModelItem?.querySelector('.check-indicator'); // Update check indicator for group-by-model @@ -37,16 +35,6 @@ export class GlobalContextMenu extends BaseContextMenu { groupByModelCheck.style.display = isEnabled ? 'block' : 'none'; } - // Append the unread count when it is already known (loaded once on init, - // and only when price tracking is on), so the label stays a plain label - // for everyone else. - const priceAlertsLabel = priceAlertsItem?.querySelector('#globalPriceAlertsLabel'); - if (priceAlertsLabel) { - const unread = updateService?.getUnreadPriceAlertCount?.() || 0; - const base = translate('globalContextMenu.priceAlerts.label', {}, 'Price alerts'); - priceAlertsLabel.textContent = unread > 0 ? `${base} (${unread})` : base; - } - if (isRecipesPage) { modelUpdateItem?.classList.add('hidden'); licenseRefreshItem?.classList.add('hidden'); @@ -54,7 +42,6 @@ export class GlobalContextMenu extends BaseContextMenu { cleanupExamplesItem?.classList.add('hidden'); excludedModelsItem?.classList.add('hidden'); groupByModelItem?.classList.add('hidden'); - priceAlertsItem?.classList.add('hidden'); rematchRecipesItem?.classList.remove('hidden'); } else { modelUpdateItem?.classList.remove('hidden'); @@ -63,7 +50,6 @@ export class GlobalContextMenu extends BaseContextMenu { cleanupExamplesItem?.classList.remove('hidden'); excludedModelsItem?.classList.remove('hidden'); groupByModelItem?.classList.remove('hidden'); - priceAlertsItem?.classList.remove('hidden'); rematchRecipesItem?.classList.add('hidden'); } @@ -103,9 +89,6 @@ export class GlobalContextMenu extends BaseContextMenu { console.error('Failed to check model updates:', error); }); break; - case 'price-alerts': - openPriceAlertsPanel(); - break; case 'fetch-missing-licenses': this.fetchMissingLicenses(menuItem).catch((error) => { console.error('Failed to refresh missing license metadata:', error); diff --git a/static/js/components/controls/PageControls.js b/static/js/components/controls/PageControls.js index 4161a710..294651dd 100644 --- a/static/js/components/controls/PageControls.js +++ b/static/js/components/controls/PageControls.js @@ -4,7 +4,6 @@ import { getStorageItem, setStorageItem, removeStorageItem, getSessionItem, setS import { showToast, openCivitaiByMetadata, isTypingContext } from '../../utils/uiHelpers.js'; import { eventManager } from '../../utils/EventManager.js'; import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js'; -import { openPriceAlertsPanel } from '../../managers/UpdateService.js'; import { sidebarManager } from '../SidebarManager.js'; import { initSortDropdown, applySortToSelect, randomizeSortValue } from './SortDropdown.js'; @@ -257,17 +256,6 @@ export class PageControls { }); } - // Opens the notification bell on the price alerts tab (same action as the - // global context menu entry). - const priceAlertsOption = document.getElementById('priceAlertsMenuItem'); - if (priceAlertsOption) { - priceAlertsOption.addEventListener('click', (e) => { - e.stopPropagation(); - document.querySelector('.dropdown-group.active')?.classList.remove('active'); - openPriceAlertsPanel(); - }); - } - // Close dropdowns when clicking outside document.addEventListener('click', (e) => { if (!e.target.closest('.dropdown-group')) { diff --git a/static/js/components/shared/ModelVersionsTab.js b/static/js/components/shared/ModelVersionsTab.js index 6a5fa695..2d7f3664 100644 --- a/static/js/components/shared/ModelVersionsTab.js +++ b/static/js/components/shared/ModelVersionsTab.js @@ -569,65 +569,58 @@ function renderRow(version, options) { })); } - if (isEarlyAccess) { + // Obtainability, only where there is a decision to make: a version already in + // the library cannot become cheaper *for this user*, so it gets no cost badge. + // The download price is the only price axis that matters here — the generation + // fee is what CivitAI charges to generate on its own site, which a local model + // never incurs. + const priceLabel = formatBuzzPrice(version.priceBuzz); + if (!version.isInLibrary && isEarlyAccess) { + // Early access is the one state with a real countdown: "free on " + // decides between waiting and paying. + const eaTime = formatEarlyAccessTime(version.earlyAccessEndsAt); badges.push(buildBadge(earlyAccessBadgeLabel, 'early-access', { title: buildPriceTooltip( version, - translate( - 'modals.model.versions.badges.earlyAccessTooltip', - {}, - 'This version currently requires Civitai early access' - ) + eaTime + ? translate( + 'modals.model.versions.badges.earlyAccessTooltipUntil', + { date: eaTime }, + `Early access until ${eaTime}` + ) + : translate( + 'modals.model.versions.badges.earlyAccessTooltip', + {}, + 'This version currently requires Civitai early access' + ) ), })); } - if (isPaidPermanent(version)) { - badges.push(buildBadge(paidBadgeLabel, 'paid', { - title: buildPriceTooltip( - version, - translate( + if (!version.isInLibrary && isGated(version)) { + if (priceLabel) { + // The number already says "paid", so a separate Paid badge would be + // noise next to it. + badges.push(buildBadge(priceLabel, isEarlyAccess ? 'early-access' : 'paid', { + title: buildPriceTooltip(version, paidBadgeLabel), + })); + } else if (!isEarlyAccess) { + // Paid, but no readable price: the gate is certain (it comes from the + // public API), only the number is best-effort. Saying nothing here + // would read as "free", which is the one thing this version is not. + badges.push(buildBadge(paidBadgeLabel, 'paid', { + title: translate( 'modals.model.versions.badges.paidTooltip', {}, 'This version requires payment to download' - ) - ), - })); - } - - // Known price for a gated version. The price is only captured when the - // optional price tracking is enabled, so this stays hidden otherwise. - const priceLabel = isGated(version) ? formatBuzzPrice(version.priceBuzz) : ''; - if (priceLabel) { - const alertsEnabled = !!version.priceAlert; - badges.push(buildBadge(priceLabel, alertsEnabled ? 'price-alert' : 'paid', { - title: alertsEnabled - ? translate( - 'modals.model.versions.badges.priceAlertTooltip', - { price: priceLabel }, - `${priceLabel} - below your price alert threshold` - ) - : buildPriceTooltip(version, paidBadgeLabel), - })); - } else if (isGated(version) && version.priceAttemptedAt) { - // We looked and could not read a price. Staying silent would read as - // "free", which is the one thing this version is not. - badges.push(buildBadge( - translate('modals.model.versions.badges.priceUnavailable', {}, 'Price unavailable'), - 'muted', - { - title: translate( - 'modals.model.versions.badges.priceUnavailableTooltip', - {}, - 'CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser' ), - } - )); + })); + } } // A version that used to be gated and no longer is. `gateLapsedAt` is // persisted, so the marker survives long after the refresh that saw it. - if (!isGated(version) && version.gateLapsedAt) { + if (!version.isInLibrary && !isGated(version) && version.gateLapsedAt) { const freeBadgeLabel = translate('modals.model.versions.badges.freeNow', {}, 'Free Now'); badges.push(buildBadge(freeBadgeLabel, 'success', { title: translate( diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index 24508dc9..58c6fce6 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -1169,11 +1169,6 @@ export class SettingsManager { priceTrackingEnabledCheckbox.checked = state.global.settings.price_tracking_enabled || false; } - const priceAlertThresholdInput = document.getElementById('priceAlertThresholdBuzz'); - if (priceAlertThresholdInput) { - priceAlertThresholdInput.value = state.global.settings.price_alert_threshold_buzz ?? 0; - } - const priceCheckTtlInput = document.getElementById('priceCheckTtlHours'); if (priceCheckTtlInput) { priceCheckTtlInput.value = state.global.settings.price_check_ttl_hours ?? 24; diff --git a/static/js/managers/UpdateService.js b/static/js/managers/UpdateService.js index f6b83b54..1ddcdade 100644 --- a/static/js/managers/UpdateService.js +++ b/static/js/managers/UpdateService.js @@ -9,79 +9,6 @@ import { import { state } from '../state/index.js'; import { bannerService } from './BannerService.js'; import { translate } from '../utils/i18nHelpers.js'; -import { showModelModal } from '../components/shared/ModelModal.js'; - -/** Global endpoint: the alerts panel spans every model type. */ -const PRICE_ALERTS_ENDPOINT = '/api/lm/price-alerts'; -/** localStorage watermark of the newest alert the user has already seen. */ -const PRICE_ALERTS_VIEWED_KEY = 'lm_price_alerts_viewed_at'; - -async function fetchPriceAlerts(limit = 200) { - const response = await fetch( - `${PRICE_ALERTS_ENDPOINT}?limit=${encodeURIComponent(limit)}` - ); - const payload = await response.json().catch(() => ({})); - if (!response.ok || payload?.success !== true) { - throw new Error(payload?.error || response.statusText || 'Failed to load price alerts'); - } - return { - alerts: Array.isArray(payload.alerts) ? payload.alerts : [], - enabled: payload.enabled === true, - thresholdBuzz: payload.thresholdBuzz ?? 0, - newestCheckedAt: payload.newestCheckedAt ?? null, - unavailableCount: payload.unavailableCount ?? 0, - pricedCount: payload.pricedCount ?? 0 - }; -} - -/** POSIX seconds of the moment an alert started, for unread/sorting comparisons. */ -function priceAlertTimestamp(alert) { - if (typeof alert?.priceAlertSince === 'number') { - return alert.priceAlertSince; - } - if (alert?.gateLapsedAt) { - const parsed = Date.parse(alert.gateLapsedAt); - if (!Number.isNaN(parsed)) { - return parsed / 1000; - } - } - return 0; -} - -function formatRelativeTime(timestampSeconds) { - if (!timestampSeconds) return ''; - const elapsedMs = Date.now() - timestampSeconds * 1000; - const minutes = Math.floor(elapsedMs / 60000); - if (minutes < 1) return translate('update.priceAlerts.justNow', {}, 'just now'); - if (minutes < 60) { - return translate('update.priceAlerts.minutesAgo', { count: minutes }, `${minutes} min ago`); - } - const hours = Math.floor(minutes / 60); - if (hours < 24) { - return translate('update.priceAlerts.hoursAgo', { count: hours }, `${hours} h ago`); - } - const days = Math.floor(hours / 24); - return translate('update.priceAlerts.daysAgo', { count: days }, `${days} d ago`); -} - -function formatShortDate(value) { - if (!value) return ''; - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) return ''; - return parsed.toLocaleDateString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric' - }); -} - -/** - * Open the notification bell on the Buzz price alerts tab. - * Shared by the controls dropdown item and the global context menu item. - */ -export function openPriceAlertsPanel() { - updateService.openPriceAlertsTab(); -} export class UpdateService { constructor() { @@ -108,18 +35,6 @@ export class UpdateService { this.activeNotificationTab = 'updates'; this.handleBannerHistoryUpdated = this.handleBannerHistoryUpdated.bind(this); this.handleNotificationTabKeydown = this.handleNotificationTabKeydown.bind(this); - // Buzz price alerts (panel data, loaded on demand; the count is cached so - // the bell badge and the context-menu label can share it). - this.priceAlerts = null; - this.priceAlertsEnabled = false; - this.priceAlertsThreshold = 0; - this.priceAlertsNewestCheckedAt = null; - this.priceAlertsUnavailableCount = 0; - this.priceAlertsPricedCount = 0; - this.priceAlertsLoading = false; - this.priceAlertSegment = 'below_threshold'; - this.unreadPriceAlertCount = 0; - this.handlePriceAlertSegmentClick = this.handlePriceAlertSegmentClick.bind(this); } initialize() { @@ -335,349 +250,7 @@ export class UpdateService { }); this.renderRecentBanners(); - this.wirePriceAlertsPanel(); this.switchNotificationTab(this.activeNotificationTab); - - // One request, and only for users who turned price tracking on. It feeds - // the tab badge and the global-context-menu count. - if (state?.global?.settings?.price_tracking_enabled) { - this.loadPriceAlerts().catch((error) => { - console.warn('Failed to preload price alerts:', error); - }); - } - } - - wirePriceAlertsPanel() { - const modal = document.getElementById('updateModal'); - if (!modal) return; - - this.priceAlertSegments = Array.from( - modal.querySelectorAll('[data-price-alert-segment]') - ); - this.priceAlertSegments.forEach((segment) => { - segment.addEventListener('click', this.handlePriceAlertSegmentClick); - }); - - const settingsBtn = document.getElementById('priceAlertsSettingsBtn'); - if (settingsBtn) { - settingsBtn.addEventListener('click', () => { - this.toggleUpdateModal(); - if (window.settingsManager) { - window.settingsManager.toggleSettings(); - } - }); - } - } - - handlePriceAlertSegmentClick(event) { - const segment = event?.currentTarget?.getAttribute('data-price-alert-segment'); - if (!segment) return; - this.priceAlertSegment = segment; - this.renderPriceAlerts(); - } - - /** - * Open the bell on the price alerts tab. - * - * ``toggleUpdateModal()`` *closes* an open bell, so an entry point that called - * it unconditionally would dismiss the modal instead of switching to the tab. - */ - openPriceAlertsTab() { - const modal = modalManager.getModal('updateModal'); - if (!modal || !modal.isOpen) { - this.toggleUpdateModal(); - } - this.switchNotificationTab('priceAlerts', { markRead: true }); - } - - /** - * Load the global alerts payload (all model types in one query). - * @param {{force?: boolean}} [options] - */ - async loadPriceAlerts({ force = false } = {}) { - if (this.priceAlertsLoading) return this.priceAlerts; - if (!force && this.priceAlerts) { - this.renderPriceAlerts(); - return this.priceAlerts; - } - - this.priceAlertsLoading = true; - try { - const payload = await fetchPriceAlerts(); - this.priceAlerts = payload.alerts; - this.priceAlertsEnabled = payload.enabled; - this.priceAlertsThreshold = payload.thresholdBuzz; - this.priceAlertsNewestCheckedAt = payload.newestCheckedAt; - this.priceAlertsUnavailableCount = payload.unavailableCount || 0; - this.priceAlertsPricedCount = payload.pricedCount || 0; - this.refreshUnreadPriceAlertCount(); - this.renderPriceAlerts(); - return this.priceAlerts; - } catch (error) { - console.error('Failed to load price alerts:', error); - // Keep whatever we already had: a stale list beats an empty panel. - this.renderPriceAlerts({ error: true }); - return this.priceAlerts; - } finally { - this.priceAlertsLoading = false; - } - } - - /** Alerts newer than the last time the user opened the panel. */ - refreshUnreadPriceAlertCount() { - const alerts = Array.isArray(this.priceAlerts) ? this.priceAlerts : []; - const viewedAt = Number(getStorageItem(PRICE_ALERTS_VIEWED_KEY, 0)) || 0; - this.unreadPriceAlertCount = alerts.filter( - (alert) => priceAlertTimestamp(alert) > viewedAt - ).length; - this.updateTabBadges(); - return this.unreadPriceAlertCount; - } - - getUnreadPriceAlertCount() { - return this.unreadPriceAlertCount || 0; - } - - markPriceAlertsViewed() { - const alerts = Array.isArray(this.priceAlerts) ? this.priceAlerts : []; - const newest = alerts.reduce( - (latest, alert) => Math.max(latest, priceAlertTimestamp(alert)), - 0 - ); - if (newest > 0) { - setStorageItem(PRICE_ALERTS_VIEWED_KEY, newest); - } - this.unreadPriceAlertCount = 0; - this.updateTabBadges(); - } - - renderPriceAlerts({ error = false } = {}) { - const list = document.getElementById('priceAlertsList'); - const empty = document.getElementById('priceAlertsEmpty'); - const disabled = document.getElementById('priceAlertsDisabled'); - const stale = document.getElementById('priceAlertsStale'); - const thresholdLabel = document.getElementById('priceAlertsThreshold'); - if (!list || !empty) return; - - if (disabled) { - disabled.classList.toggle('hidden', this.priceAlertsEnabled !== false); - } - if (thresholdLabel) { - thresholdLabel.textContent = this.priceAlertsEnabled - ? translate( - 'update.priceAlerts.thresholdLabel', - { buzz: this.priceAlertsThreshold }, - `Alert threshold: ${this.priceAlertsThreshold} Buzz` - ) - : ''; - } - if (stale) { - const checkedAt = this.priceAlertsNewestCheckedAt; - let staleText = error - ? translate( - 'update.priceAlerts.loadFailed', - {}, - 'Could not refresh the alerts; showing the last known list.' - ) - : checkedAt - ? translate( - 'update.priceAlerts.lastChecked', - { when: formatRelativeTime(checkedAt) }, - `Prices last checked ${formatRelativeTime(checkedAt)}` - ) - : ''; - const unavailable = this.priceAlertsUnavailableCount; - if (unavailable > 0) { - const unavailableText = translate( - 'update.priceAlerts.unavailable', - { count: unavailable }, - `${unavailable} paid version(s) have no readable price (mature models can only be read in a browser)` - ); - staleText = staleText ? `${staleText} · ${unavailableText}` : unavailableText; - } - stale.textContent = staleText; - stale.classList.toggle('hidden', !staleText); - } - - if (Array.isArray(this.priceAlertSegments)) { - this.priceAlertSegments.forEach((segment) => { - const isActive = - segment.getAttribute('data-price-alert-segment') === - this.priceAlertSegment; - segment.classList.toggle('active', isActive); - segment.setAttribute('aria-selected', isActive ? 'true' : 'false'); - }); - } - - const alerts = (Array.isArray(this.priceAlerts) ? this.priceAlerts : []).filter( - (alert) => (alert.kind || 'below_threshold') === this.priceAlertSegment - ); - - list.innerHTML = ''; - if (!alerts.length) { - empty.textContent = this.buildPriceAlertsEmptyText(); - empty.classList.remove('hidden'); - return; - } - - empty.classList.add('hidden'); - alerts.forEach((alert) => { - list.appendChild(this.buildPriceAlertItem(alert)); - }); - } - - /** - * The empty state has to explain a 0 Buzz threshold: "nothing is under your - * threshold" is technically true but useless when dozens of paid versions - * already have a known price. - */ - buildPriceAlertsEmptyText() { - if (this.priceAlertSegment === 'became_free') { - return translate( - 'update.priceAlerts.emptyFree', - {}, - 'No version has become free yet.' - ); - } - if (!this.priceAlertsThreshold && this.priceAlertsPricedCount > 0) { - return translate( - 'update.priceAlerts.emptyZeroThreshold', - { count: this.priceAlertsPricedCount }, - `${this.priceAlertsPricedCount} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops.` - ); - } - return translate( - 'update.priceAlerts.empty', - {}, - 'Nothing is under your price threshold right now.' - ); - } - - buildPriceAlertItem(alert) { - const item = document.createElement('li'); - item.className = 'price-alert-item'; - - const head = document.createElement('div'); - head.className = 'price-alert-head'; - - const name = document.createElement('span'); - name.className = 'price-alert-model'; - const modelLabel = alert.modelName || alert.versionName || `#${alert.modelId}`; - name.textContent = alert.versionName - ? `${modelLabel} · ${alert.versionName}` - : modelLabel; - head.appendChild(name); - - if (alert.kind === 'below_threshold') { - const price = document.createElement('span'); - price.className = 'price-alert-price is-alert'; - price.textContent = `${Number(alert.priceBuzz).toLocaleString()} Buzz`; - if ( - typeof alert.listPriceBuzz === 'number' && - alert.listPriceBuzz > alert.priceBuzz - ) { - const was = document.createElement('span'); - was.className = 'price-alert-was'; - was.textContent = `${alert.listPriceBuzz.toLocaleString()} Buzz`; - price.appendChild(was); - } - head.appendChild(price); - } else { - const free = document.createElement('span'); - free.className = 'price-alert-price is-alert'; - free.textContent = translate('update.priceAlerts.freeNow', {}, 'Free now'); - head.appendChild(free); - } - item.appendChild(head); - - const meta = document.createElement('div'); - meta.className = 'price-alert-meta'; - const metaParts = [alert.modelType]; - if (alert.acceptsBlueBuzz) { - metaParts.push(translate('update.priceAlerts.blueBuzz', {}, 'Blue Buzz OK')); - } - if (alert.earlyAccessEndsAt) { - metaParts.push( - translate( - 'update.priceAlerts.earlyAccessUntil', - { date: formatShortDate(alert.earlyAccessEndsAt) }, - `Early access until ${formatShortDate(alert.earlyAccessEndsAt)}` - ) - ); - } - metaParts.push( - alert.isInLibrary - ? translate('update.priceAlerts.inLibrary', {}, 'In library') - : translate('update.priceAlerts.notInLibrary', {}, 'Not in library') - ); - const since = priceAlertTimestamp(alert); - if (since > 0) { - metaParts.push( - translate( - 'update.priceAlerts.droppedAgo', - { when: formatRelativeTime(since) }, - `dropped ${formatRelativeTime(since)}` - ) - ); - } - meta.textContent = metaParts.filter(Boolean).join(' · '); - item.appendChild(meta); - - const actions = document.createElement('div'); - actions.className = 'price-alert-actions'; - - if (alert.civitaiUrl) { - actions.appendChild( - this.buildPriceAlertAction( - 'civitai', - translate('update.priceAlerts.openCivitai', {}, 'Open on CivitAI'), - 'fa-external-link-alt', - () => window.open(alert.civitaiUrl, '_blank', 'noopener') - ) - ); - } - // Only possible for a model that is actually in the library: the modal - // reads the local metadata by file path. - if (alert.filePath) { - actions.appendChild( - this.buildPriceAlertAction( - 'open', - translate('update.priceAlerts.openLocal', {}, 'Open'), - 'fa-folder-open', - () => { - showModelModal( - { - model_name: alert.modelName || alert.versionName, - file_path: alert.filePath, - civitai: {}, - }, - alert.modelType - ); - } - ) - ); - } - item.appendChild(actions); - - return item; - } - - buildPriceAlertAction(name, label, icon, onClick) { - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'price-alert-action'; - button.dataset.priceAlertAction = name; - - const iconElement = document.createElement('i'); - iconElement.className = `fas ${icon}`; - button.appendChild(iconElement); - - const text = document.createElement('span'); - text.textContent = label; - button.appendChild(text); - - button.addEventListener('click', onClick); - return button; } switchNotificationTab(tabName, { markRead = false } = {}) { @@ -710,14 +283,6 @@ export class UpdateService { } } - if (tabName === 'priceAlerts') { - this.loadPriceAlerts().then(() => { - if (markRead) { - this.markPriceAlertsViewed(); - } - }); - } - this.updateTabBadges(); } @@ -778,12 +343,10 @@ export class UpdateService { updateTabBadges() { const updatesBadge = document.getElementById('updatesTabBadge'); const bannerBadge = document.getElementById('bannerTabBadge'); - const priceAlertsBadge = document.getElementById('priceAlertsTabBadge'); const hasUpdate = this.updateNotificationsEnabled && this.updateAvailable; const unreadBanners = typeof bannerService.getUnreadBannerCount === 'function' ? bannerService.getUnreadBannerCount() : 0; - const unreadAlerts = this.getUnreadPriceAlertCount(); if (updatesBadge) { updatesBadge.classList.toggle('visible', hasUpdate); @@ -791,12 +354,6 @@ export class UpdateService { updatesBadge.textContent = ''; } - if (priceAlertsBadge) { - priceAlertsBadge.textContent = unreadAlerts > 9 ? '9+' : unreadAlerts ? String(unreadAlerts) : ''; - priceAlertsBadge.classList.toggle('visible', unreadAlerts > 0); - priceAlertsBadge.classList.remove('is-dot'); - } - if (bannerBadge) { if (unreadBanners > 0) { bannerBadge.textContent = unreadBanners > 9 ? '9+' : unreadBanners.toString(); diff --git a/static/js/state/index.js b/static/js/state/index.js index 54322601..69956ad7 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -58,7 +58,6 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({ hide_early_access_updates: false, hide_paid_updates: false, price_tracking_enabled: false, - price_alert_threshold_buzz: 0, price_check_ttl_hours: 24, auto_organize_exclusions: [], metadata_refresh_skip_paths: [], diff --git a/static/js/utils/updateCheckHelpers.js b/static/js/utils/updateCheckHelpers.js index ee4f69a7..56ad6090 100644 --- a/static/js/utils/updateCheckHelpers.js +++ b/static/js/utils/updateCheckHelpers.js @@ -18,7 +18,6 @@ export function summarizeGateEvents(events) { return { becameFree: list.filter((event) => event?.kind === 'became_free').length, newGate: list.filter((event) => event?.kind === 'new_gate').length, - priceDrop: list.filter((event) => event?.kind === 'price_drop').length, total: list.length }; } @@ -29,8 +28,8 @@ export function summarizeGateEvents(events) { * @param {Array} events */ export function showGateEventsToast(events) { - const { becameFree, newGate, priceDrop } = summarizeGateEvents(events); - if (!becameFree && !newGate && !priceDrop) { + const { becameFree, newGate } = summarizeGateEvents(events); + if (!becameFree && !newGate) { return; } @@ -44,15 +43,6 @@ export function showGateEventsToast(events) { ) ); } - if (priceDrop) { - parts.push( - translate( - 'globalContextMenu.checkModelUpdates.gateEvents.priceDrop', - { count: priceDrop }, - `${priceDrop} version(s) dropped below your price threshold` - ) - ); - } if (newGate) { parts.push( translate( @@ -63,7 +53,7 @@ export function showGateEventsToast(events) { ); } - showToast(parts.join(' · '), {}, becameFree || priceDrop ? 'success' : 'info'); + showToast(parts.join(' · '), {}, becameFree ? 'success' : 'info'); } /** diff --git a/templates/components/context_menu.html b/templates/components/context_menu.html index fe38f287..67e6d080 100644 --- a/templates/components/context_menu.html +++ b/templates/components/context_menu.html @@ -185,9 +185,6 @@
    {{ t('globalContextMenu.checkModelUpdates.label') }}
    -
    - {{ t('globalContextMenu.priceAlerts.label') }} -
    {{ t('globalContextMenu.fetchMissingLicenses.label') }}
    diff --git a/templates/components/controls.html b/templates/components/controls.html index 5499b7b3..b542371d 100644 --- a/templates/components/controls.html +++ b/templates/components/controls.html @@ -122,9 +122,6 @@ - {% endif %} diff --git a/templates/components/modals/settings/library.html b/templates/components/modals/settings/library.html index 831d9b10..f3314c77 100644 --- a/templates/components/modals/settings/library.html +++ b/templates/components/modals/settings/library.html @@ -197,28 +197,6 @@ {{ sm.subsection_header('settings.sections.priceTracking') }}
    {{ t('settings.priceTracking.help') }}
    {{ sm.setting_toggle('priceTrackingEnabled', 'price_tracking_enabled', 'settings.priceTracking.enabled', 'settings.priceTracking.enabledHelp') }} -
    -
    -
    - -
    -
    -
    - -
    -
    -
    -
    diff --git a/templates/components/modals/update_modal.html b/templates/components/modals/update_modal.html index d3bc5db1..63ba4904 100644 --- a/templates/components/modals/update_modal.html +++ b/templates/components/modals/update_modal.html @@ -15,10 +15,6 @@ {{ t('update.tabs.messages') }} -
    @@ -98,34 +94,6 @@
    -
    diff --git a/tests/frontend/components/modelVersionsTab.downloadButton.test.js b/tests/frontend/components/modelVersionsTab.downloadButton.test.js index a4ffd5fe..2c89f6f3 100644 --- a/tests/frontend/components/modelVersionsTab.downloadButton.test.js +++ b/tests/frontend/components/modelVersionsTab.downloadButton.test.js @@ -284,7 +284,7 @@ describe('ModelVersionsTab download button visibility', () => { }); }); -describe('ModelVersionsTab price badges', () => { +describe('ModelVersionsTab obtainability badges', () => { let getModelApiClient; let fetchModelUpdateVersions; @@ -316,63 +316,83 @@ describe('ModelVersionsTab price badges', () => { return document.querySelector(`.model-version-row[data-version-id="${versionId}"]`); } - it('shows the price for a gated version whose price was captured', async () => { + function renderOne(version) { fetchModelUpdateVersions.mockResolvedValue(buildRecord([ { - versionId: 20, - name: 'Alpha', + versionId: 1, + name: 'v1', isInLibrary: false, shouldIgnore: false, - isPaid: true, - paidAccess: { permanent: true, endsAt: null }, - priceBuzz: 5000, - listPriceBuzz: 5000, - priceCheckedAt: 1791039694.5, - priceAttemptedAt: 1791039694.5, + ...version, }, ])); + return renderVersions(); + } - await renderVersions(); + const PAID_GATE = { permanent: true, endsAt: null }; - expect(rowFor(20).textContent).toContain('5,000 Buzz'); + it('shows the download price for a paid version the user does not have', async () => { + await renderOne({ isPaid: true, paidAccess: PAID_GATE, priceBuzz: 5000 }); + + expect(rowFor(1).textContent).toContain('5,000 Buzz'); }); - it('marks a gated version with no readable price as unavailable', async () => { - // The mature-model case: we looked, no host would serve the page. - fetchModelUpdateVersions.mockResolvedValue(buildRecord([ - { - versionId: 21, - name: 'Beta', - isInLibrary: false, - shouldIgnore: false, - isPaid: true, - paidAccess: { permanent: true, endsAt: null }, - priceBuzz: null, - priceAttemptedAt: 1791039694.5, - }, - ])); + it('says Paid without a number when the price could not be read', async () => { + // The gate is certain (it comes from the public API); only the number is + // best-effort, and that is our plumbing rather than the user's problem. + await renderOne({ isPaid: true, paidAccess: PAID_GATE, priceBuzz: null }); - await renderVersions(); - - expect(rowFor(21).textContent).toContain('Price unavailable'); + expect(rowFor(1).textContent).toContain('Paid'); + expect(rowFor(1).textContent).not.toContain('Buzz'); }); - it('stays quiet when the price was never looked up', async () => { - fetchModelUpdateVersions.mockResolvedValue(buildRecord([ - { - versionId: 22, - name: 'Gamma', - isInLibrary: false, - shouldIgnore: false, - isPaid: true, - paidAccess: { permanent: true, endsAt: null }, - priceBuzz: null, - priceAttemptedAt: null, - }, - ])); + it('shows the early access countdown alongside the price', async () => { + await renderOne({ + isEarlyAccess: true, + earlyAccessEndsAt: new Date(Date.now() + 3 * 86400000).toISOString(), + paidAccess: { permanent: false, endsAt: '2999-01-01T00:00:00.000Z' }, + priceBuzz: 500, + }); - await renderVersions(); + const text = rowFor(1).textContent; + expect(text).toContain('Early Access'); + expect(text).toContain('500 Buzz'); + }); - expect(rowFor(22).textContent).not.toContain('Price unavailable'); + it('shows nothing about cost for a version already in the library', async () => { + // A version on disk cannot become cheaper *for this user*. + await renderOne({ + isInLibrary: true, + isPaid: true, + paidAccess: PAID_GATE, + priceBuzz: 5000, + gateLapsedAt: '2026-09-28T00:00:00.000Z', + }); + + const text = rowFor(1).textContent; + expect(text).not.toContain('Buzz'); + expect(text).not.toContain('Paid'); + expect(text).not.toContain('Free Now'); + expect(text).toContain('In Library'); + }); + + it('marks a version that became free and is not in the library', async () => { + await renderOne({ + isInLibrary: false, + isPaid: false, + priceBuzz: null, + gateLapsedAt: '2026-09-28T00:00:00.000Z', + }); + + expect(rowFor(1).textContent).toContain('Free Now'); + }); + + it('shows nothing at all for a plain free version', async () => { + await renderOne({ isInLibrary: false, isPaid: false, priceBuzz: null }); + + const text = rowFor(1).textContent; + expect(text).not.toContain('Buzz'); + expect(text).not.toContain('Paid'); + expect(text).not.toContain('Free Now'); }); }); diff --git a/tests/frontend/managers/updateServicePriceAlerts.test.js b/tests/frontend/managers/updateServicePriceAlerts.test.js deleted file mode 100644 index fee8dd36..00000000 --- a/tests/frontend/managers/updateServicePriceAlerts.test.js +++ /dev/null @@ -1,300 +0,0 @@ -import { describe, beforeEach, afterEach, expect, it, vi } from 'vitest'; -import { UpdateService } from '../../../static/js/managers/UpdateService.js'; -import { state } from '../../../static/js/state/index.js'; -import { modalManager } from '../../../static/js/managers/ModalManager.js'; - -/** - * The price alerts panel is rendered into the notification bell modal, so the - * tests build the same element ids the template provides and drive the service - * directly. - */ -function buildPanelDom() { - document.body.innerHTML = ` -
    - - -
    -
    - - - - - - -

    -
      -
      - - - -
      - `; -} - -function alertPayload(alerts, overrides = {}) { - return { - success: true, - enabled: true, - thresholdBuzz: 500, - newestCheckedAt: 1791039694.5, - alerts, - ...overrides, - }; -} - -const BELOW_THRESHOLD_ALERT = { - modelId: 2981320, - modelType: 'checkpoint', - modelName: 'Glorious Art', - versionId: 3379626, - versionName: 'Alpha', - kind: 'below_threshold', - priceBuzz: 250, - listPriceBuzz: 500, - acceptsBlueBuzz: true, - priceAlertSince: 1791000000, - isInLibrary: false, - filePath: null, - civitaiUrl: 'https://civitai.com/models/2981320?modelVersionId=3379626', -}; - -const BECAME_FREE_ALERT = { - modelId: 1802980, - modelType: 'lora', - modelName: 'Eira Kishida', - versionId: 3262917, - versionName: 'v3', - kind: 'became_free', - priceBuzz: null, - priceAlertSince: null, - gateLapsedAt: '2026-09-28T00:00:00.000Z', - isInLibrary: true, - filePath: '/models/loras/eira.safetensors', - civitaiUrl: 'https://civitai.com/models/1802980?modelVersionId=3262917', -}; - -function createFetchResponse(payload, ok = true) { - return { json: vi.fn().mockResolvedValue(payload), ok, statusText: '' }; -} - -describe('UpdateService price alerts panel', () => { - let service; - - beforeEach(() => { - buildPanelDom(); - localStorage.clear(); - state.global = state.global || {}; - state.global.settings = state.global.settings || {}; - service = new UpdateService(); - service.setupNotificationCenter(); - }); - - afterEach(() => { - delete global.fetch; - document.body.innerHTML = ''; - localStorage.clear(); - }); - - it('loads the global endpoint and renders a below-threshold row', async () => { - global.fetch = vi - .fn() - .mockResolvedValue(createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT]))); - - await service.loadPriceAlerts({ force: true }); - - expect(global.fetch).toHaveBeenCalledTimes(1); - expect(String(global.fetch.mock.calls[0][0])).toContain('/api/lm/price-alerts'); - - const items = document.querySelectorAll('#priceAlertsList .price-alert-item'); - expect(items).toHaveLength(1); - expect(items[0].textContent).toContain('Glorious Art'); - expect(items[0].textContent).toContain('250 Buzz'); - // The stored list price is shown struck through. - expect(items[0].querySelector('.price-alert-was').textContent).toBe('500 Buzz'); - expect(items[0].textContent).toContain('Blue Buzz OK'); - expect(items[0].textContent).toContain('Not in library'); - // No local file -> no "Open" action, only CivitAI. - expect(items[0].querySelector('[data-price-alert-action="open"]')).toBeNull(); - expect( - items[0].querySelector('[data-price-alert-action="civitai"]') - ).not.toBeNull(); - expect(document.getElementById('priceAlertsDisabled').classList.contains('hidden')).toBe(true); - expect(document.getElementById('priceAlertsEmpty').classList.contains('hidden')).toBe(true); - }); - - it('separates became-free alerts into their own segment', async () => { - global.fetch = vi - .fn() - .mockResolvedValue( - createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT, BECAME_FREE_ALERT])) - ); - - await service.loadPriceAlerts({ force: true }); - - // Below-threshold is the default segment. - expect(document.querySelectorAll('#priceAlertsList .price-alert-item')).toHaveLength(1); - - service.priceAlertSegment = 'became_free'; - service.renderPriceAlerts(); - - const items = document.querySelectorAll('#priceAlertsList .price-alert-item'); - expect(items).toHaveLength(1); - expect(items[0].textContent).toContain('Free now'); - expect(items[0].textContent).toContain('In library'); - // In-library row offers both actions. - expect(items[0].querySelector('[data-price-alert-action="open"]')).not.toBeNull(); - expect(items[0].querySelector('[data-price-alert-action="civitai"]')).not.toBeNull(); - }); - - it('counts unread alerts and clears the count once viewed', async () => { - global.fetch = vi - .fn() - .mockResolvedValue( - createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT, BECAME_FREE_ALERT])) - ); - - await service.loadPriceAlerts({ force: true }); - - expect(service.getUnreadPriceAlertCount()).toBe(2); - const badge = document.getElementById('priceAlertsTabBadge'); - expect(badge.classList.contains('visible')).toBe(true); - expect(badge.textContent).toBe('2'); - - service.markPriceAlertsViewed(); - - expect(service.getUnreadPriceAlertCount()).toBe(0); - expect(badge.classList.contains('visible')).toBe(false); - - // A later alert is unread again. - service.priceAlerts = [ - { ...BELOW_THRESHOLD_ALERT, priceAlertSince: 1791000000 + 1000 }, - ]; - expect(service.refreshUnreadPriceAlertCount()).toBe(1); - }); - - it('explains itself while price tracking is off', async () => { - global.fetch = vi - .fn() - .mockResolvedValue( - createFetchResponse( - alertPayload([BECAME_FREE_ALERT], { enabled: false, thresholdBuzz: 0 }) - ) - ); - - await service.loadPriceAlerts({ force: true }); - - expect( - document.getElementById('priceAlertsDisabled').classList.contains('hidden') - ).toBe(false); - expect(document.getElementById('priceAlertsThreshold').textContent).toBe(''); - }); - - it('shows the empty state when the segment has no matches', async () => { - global.fetch = vi - .fn() - .mockResolvedValue(createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT]))); - - await service.loadPriceAlerts({ force: true }); - service.priceAlertSegment = 'became_free'; - service.renderPriceAlerts(); - - const empty = document.getElementById('priceAlertsEmpty'); - expect(empty.classList.contains('hidden')).toBe(false); - expect(document.querySelectorAll('#priceAlertsList .price-alert-item')).toHaveLength(0); - }); - - it('reports how many gated versions have no readable price', async () => { - global.fetch = vi.fn().mockResolvedValue( - createFetchResponse( - alertPayload([BELOW_THRESHOLD_ALERT], { unavailableCount: 3 }) - ) - ); - - await service.loadPriceAlerts({ force: true }); - - expect(service.priceAlertsUnavailableCount).toBe(3); - const note = document.getElementById('priceAlertsStale'); - expect(note.classList.contains('hidden')).toBe(false); - expect(note.textContent).toContain('3 paid version(s) have no readable price'); - }); - - it('explains a 0 Buzz threshold instead of claiming there is nothing', async () => { - // The exact confusion a real user hit: 52 priced versions, threshold 0, - // and an empty panel saying "nothing is under your threshold". - global.fetch = vi.fn().mockResolvedValue( - createFetchResponse( - alertPayload([], { thresholdBuzz: 0, pricedCount: 52 }) - ) - ); - - await service.loadPriceAlerts({ force: true }); - - const empty = document.getElementById('priceAlertsEmpty'); - expect(empty.classList.contains('hidden')).toBe(false); - expect(empty.textContent).toContain('52 paid version(s) have a known price'); - expect(empty.textContent).toContain('threshold is 0 Buzz'); - }); - - it('keeps the plain empty message when a real threshold is set', async () => { - global.fetch = vi.fn().mockResolvedValue( - createFetchResponse( - alertPayload([], { thresholdBuzz: 500, pricedCount: 52 }) - ) - ); - - await service.loadPriceAlerts({ force: true }); - - const empty = document.getElementById('priceAlertsEmpty'); - expect(empty.textContent).toBe('Nothing is under your price threshold right now.'); - }); - - it('keeps the last known list when the request fails', async () => { - global.fetch = vi - .fn() - .mockResolvedValueOnce(createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT]))) - .mockResolvedValueOnce(createFetchResponse({ success: false, error: 'boom' }, false)); - - await service.loadPriceAlerts({ force: true }); - await service.loadPriceAlerts({ force: true }); - - expect(document.querySelectorAll('#priceAlertsList .price-alert-item')).toHaveLength(1); - const stale = document.getElementById('priceAlertsStale'); - expect(stale.classList.contains('hidden')).toBe(false); - }); - - it('switches to the tab without closing an already-open bell', () => { - global.fetch = vi.fn().mockResolvedValue(createFetchResponse(alertPayload([]))); - const toggleSpy = vi.spyOn(service, 'toggleUpdateModal'); - const getModalSpy = vi - .spyOn(modalManager, 'getModal') - .mockReturnValue({ isOpen: true }); - - service.openPriceAlertsTab(); - - expect(getModalSpy).toHaveBeenCalledWith('updateModal'); - // toggleUpdateModal() would have *closed* the open bell. - expect(toggleSpy).not.toHaveBeenCalled(); - expect(service.activeNotificationTab).toBe('priceAlerts'); - - getModalSpy.mockRestore(); - toggleSpy.mockRestore(); - }); - - it('opens the bell when it is closed', () => { - global.fetch = vi.fn().mockResolvedValue(createFetchResponse(alertPayload([]))); - const toggleSpy = vi - .spyOn(service, 'toggleUpdateModal') - .mockImplementation(() => {}); - const getModalSpy = vi - .spyOn(modalManager, 'getModal') - .mockReturnValue({ isOpen: false }); - - service.openPriceAlertsTab(); - - expect(toggleSpy).toHaveBeenCalled(); - expect(service.activeNotificationTab).toBe('priceAlerts'); - - getModalSpy.mockRestore(); - toggleSpy.mockRestore(); - }); -}); diff --git a/tests/routes/test_misc_routes.py b/tests/routes/test_misc_routes.py index 50d4178a..2c0a0974 100644 --- a/tests/routes/test_misc_routes.py +++ b/tests/routes/test_misc_routes.py @@ -17,7 +17,6 @@ from py.routes.handlers.misc_handlers import ( BackupHandler, DoctorHandler, MiscHandlerSet, - PriceAlertsHandler, FileSystemHandler, HealthCheckHandler, LoraCodeHandler, @@ -2896,7 +2895,6 @@ def _stub_misc_handler_set(**overrides) -> MiscHandlerSet: "doctor", "example_workflows", "base_model", - "price_alerts", "model_source_handler", "agent_handler", "download_routing", @@ -2920,177 +2918,14 @@ def test_every_misc_route_definition_resolves_to_a_handler(): ] == [] -def test_price_alerts_route_is_registered_once_app_wide(): - definitions = [ +def test_price_alert_endpoints_are_gone(): + """The redesign removed the standalone alerts surface: obtainability is an + attribute of the update surfaces, so no route may serve an alert list.""" + + leftovers = [ definition for definition in MISC_ROUTE_DEFINITIONS - if definition.handler_name == "get_price_alerts" + if "price-alert" in definition.path or "price_alert" in definition.handler_name ] - # Exactly one entry, and no {prefix}: the panel spans every model type. - assert len(definitions) == 1 - assert definitions[0].method == "GET" - assert definitions[0].path == "/api/lm/price-alerts" - - -def _price_alerts_adapter(update_service, scanners=None): - async def _unused_scanner(): - raise AssertionError("scanner should not be requested in this test") - - async def _get_scanner(): - return scanners - - return ServiceRegistryAdapter( - get_lora_scanner=_get_scanner, - get_checkpoint_scanner=_unused_scanner, - get_embedding_scanner=_unused_scanner, - get_downloaded_version_history_service=_unused_scanner, - get_model_update_service=AsyncMock(return_value=update_service), - ) - - -class _FakeUpdateService: - def __init__(self, alerts, *, unavailable_count=0, priced_count=0): - self.alerts = alerts - self.calls = [] - self.unavailable_count = unavailable_count - self.priced_count = priced_count - - async def get_price_alerts(self, model_type=None, *, threshold_buzz=None, limit=200): - self.calls.append((model_type, threshold_buzz, limit)) - return [dict(alert) for alert in self.alerts] - - def newest_price_checked_at(self): - return 1791039694.5 - - def count_unavailable_prices(self, model_type=None): - return self.unavailable_count - - def count_priced_versions(self, model_type=None): - return self.priced_count - - -@pytest.mark.asyncio -async def test_price_alerts_handler_returns_the_global_list(): - update_service = _FakeUpdateService( - [ - { - "modelId": 2981320, - "modelType": "checkpoint", - "versionId": 3379626, - "kind": "below_threshold", - "priceBuzz": 250, - } - ] - ) - settings = DummySettings( - { - "price_tracking_enabled": True, - "price_alert_threshold_buzz": 300, - "civitai_host": "civitai.com", - } - ) - handler = PriceAlertsHandler( - settings_service=settings, - service_registry=_price_alerts_adapter(update_service), - ) - - response = await handler.get_price_alerts( - FakeRequest(method="GET", query={"limit": "50"}) # pyright: ignore[reportArgumentType] - ) - payload = _json_payload(response) - - assert response.status == 200 - assert payload["success"] is True - assert payload["enabled"] is True - assert payload["thresholdBuzz"] == 300 - assert payload["newestCheckedAt"] == 1791039694.5 - assert payload["unavailableCount"] == 0 - assert payload["pricedCount"] == 0 - assert payload["alerts"][0]["civitaiUrl"] == ( - "https://civitai.com/models/2981320?modelVersionId=3379626" - ) - # All model types in one call, with the threshold resolved from settings. - assert update_service.calls == [(None, 300, 50)] - - -@pytest.mark.asyncio -async def test_price_alerts_handler_decorates_local_context(): - update_service = _FakeUpdateService( - [ - { - "modelId": 1, - "modelType": "lora", - "versionId": 12, - "kind": "below_threshold", - "priceBuzz": 250, - } - ] - ) - cache = SimpleNamespace( - model_id_index={1: [{"model_name": "Glorious Art", "file_name": "glorious.safetensors"}]}, - version_index={12: {"file_path": "/models/loras/glorious.safetensors", "file_name": "glorious.safetensors"}}, - ) - - class _Scanner: - async def get_cached_data(self): - return cache - - async def _get_scanner(): - return _Scanner() - - adapter = ServiceRegistryAdapter( - get_lora_scanner=_get_scanner, - get_checkpoint_scanner=_get_scanner, - get_embedding_scanner=_get_scanner, - get_downloaded_version_history_service=_get_scanner, - get_model_update_service=AsyncMock(return_value=update_service), - ) - handler = PriceAlertsHandler( - settings_service=DummySettings({"civitai_host": "civitai.red"}), - service_registry=adapter, - ) - - response = await handler.get_price_alerts( - FakeRequest(method="GET") # pyright: ignore[reportArgumentType] - ) - alert = _json_payload(response)["alerts"][0] - - assert alert["modelName"] == "Glorious Art" - assert alert["filePath"] == "/models/loras/glorious.safetensors" - assert alert["civitaiUrl"].startswith("https://civitai.red/models/1") - - -@pytest.mark.asyncio -async def test_price_alerts_handler_survives_a_cold_scanner_cache(): - """Local context is best-effort: the panel must still render.""" - - update_service = _FakeUpdateService( - [{"modelId": 1, "modelType": "lora", "versionId": 12, "kind": "became_free"}] - ) - - async def _broken_scanner(): - raise RuntimeError("scanner not ready") - - adapter = ServiceRegistryAdapter( - get_lora_scanner=_broken_scanner, - get_checkpoint_scanner=_broken_scanner, - get_embedding_scanner=_broken_scanner, - get_downloaded_version_history_service=_broken_scanner, - get_model_update_service=AsyncMock(return_value=update_service), - ) - handler = PriceAlertsHandler( - settings_service=DummySettings(), service_registry=adapter - ) - - response = await handler.get_price_alerts( - FakeRequest(method="GET") # pyright: ignore[reportArgumentType] - ) - payload = _json_payload(response) - - assert response.status == 200 - assert payload["success"] is True - # Tracking is off in this fixture, so the panel explains itself. - assert payload["enabled"] is False - assert "modelName" not in payload["alerts"][0] - assert "filePath" not in payload["alerts"][0] + assert leftovers == [] diff --git a/tests/routes/test_model_update_handler.py b/tests/routes/test_model_update_handler.py index fda73c9f..77f17479 100644 --- a/tests/routes/test_model_update_handler.py +++ b/tests/routes/test_model_update_handler.py @@ -326,39 +326,6 @@ async def test_refresh_model_updates_filters_records_without_updates(): assert call["target_model_ids"] is None -@pytest.mark.asyncio -async def test_get_price_alerts_returns_flagged_versions(): - """The price alert list is a GET (companion-extension friendly) and reports - the threshold it was computed with.""" - - class AlertsService: - def __init__(self): - self.calls = [] - - async def get_price_alerts(self, model_type, limit=200): - self.calls.append((model_type, limit)) - return [{"modelId": 1, "versionId": 12, "priceBuzz": 250}] - - update_service = AlertsService() - settings = {"price_tracking_enabled": True, "price_alert_threshold_buzz": 300} - handler = ModelUpdateHandler( - service=DummyService(SimpleNamespace(version_index={})), - update_service=update_service, - metadata_provider_selector=lambda *_: None, - settings_service=SimpleNamespace(get=lambda key, default=None: settings.get(key, default)), - logger=logging.getLogger(__name__), - ) - request = SimpleNamespace(query={"limit": "50"}) - - response = await handler.get_price_alerts(request) # pyright: ignore[reportArgumentType] - - assert response.status == 200 - payload = json.loads(response.text) - assert payload["success"] is True - assert payload["enabled"] is True - assert payload["thresholdBuzz"] == 300 - assert payload["alerts"] == [{"modelId": 1, "versionId": 12, "priceBuzz": 250}] - assert update_service.calls == [("lora", 50)] @pytest.mark.asyncio @@ -1216,13 +1183,12 @@ def test_every_common_route_definition_resolves_to_a_handler(): assert missing == [] -def test_get_price_alerts_is_registered_as_a_route(): - """The alerts endpoint must be declared and reachable via GET.""" +def test_price_alert_endpoints_are_not_registered(): + """The redesign dropped the standalone alerts surface: obtainability rides on + the update surfaces, so no per-type alert route may remain.""" - definition = next( - d - for d in COMMON_ROUTE_DEFINITIONS - if d.handler_name == "get_price_alerts" - ) - assert definition.method == "GET" - assert definition.build_path("loras") == "/api/lm/loras/updates/price-alerts" + assert [ + definition.path_template + for definition in COMMON_ROUTE_DEFINITIONS + if "price-alert" in definition.path_template + ] == [] diff --git a/tests/services/test_model_update_service.py b/tests/services/test_model_update_service.py index 37e30b3f..83b60ce7 100644 --- a/tests/services/test_model_update_service.py +++ b/tests/services/test_model_update_service.py @@ -1153,9 +1153,9 @@ def test_build_record_emits_became_free_event(tmp_path): assert version.gate_lapsed_at is not None assert version.price_buzz is None assert version.price_checked_at is None - assert version.price_alert_state is False assert [event["kind"] for event in record.events] == ["became_free"] assert record.events[0]["versionId"] == 7 + assert record.events[0]["isInLibrary"] is False def test_build_record_emits_new_gate_event(tmp_path): @@ -1318,9 +1318,12 @@ def test_migration_adds_price_columns_to_legacy_db(tmp_path): "accepts_blue_buzz", "price_sale_ends_at", "price_checked_at", - "price_alert_state", + "price_check_attempted_at", ): assert column in columns + # The alert-state columns were removed together with the threshold they served. + assert "price_alert_state" not in columns + assert "price_alert_since" not in columns record = service._get_record("lora", 999) assert record is not None @@ -1343,7 +1346,6 @@ def test_price_fields_round_trip_through_sqlite(tmp_path): generation_price_buzz=100, accepts_blue_buzz=True, price_sale_ends_at="2026-10-01T00:00:00.000Z", - price_alert_state=True, is_in_library=True, gate_lapsed_at=None, ) @@ -1358,7 +1360,7 @@ def test_price_fields_round_trip_through_sqlite(tmp_path): assert loaded.accepts_blue_buzz is True assert loaded.price_sale_ends_at == "2026-10-01T00:00:00.000Z" assert loaded.price_checked_at == 123.5 - assert loaded.price_alert_state is True + assert loaded.price_check_attempted_at is None # --- Optional price capture -------------------------------------------------- @@ -1625,107 +1627,14 @@ def _prices(price_buzz: int) -> dict: return {12: {"price_buzz": price_buzz, "list_price_buzz": price_buzz}} -@pytest.mark.asyncio -async def test_price_alert_state_persists_and_fires_on_crossing(tmp_path): - service = _price_service( - tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=300 - ) - scanner = DummyScanner(LOCAL_RAW_DATA) - - # First sight: the state is recorded (so the alerts list shows it) but nothing - # is announced - otherwise enabling the feature would toast every cheap - # version in the library at once. - first_refresh = await service.refresh_for_model_type( - "lora", - scanner, - PriceProvider(_gated_response("2999-01-01T00:00:00.000Z"), prices=_prices(250)), - ) - version = next(v for v in first_refresh[1].versions if v.version_id == 12) - assert version.price_alert_state is True - assert first_refresh[1].events == [] - - alerts = await service.get_price_alerts("lora") - assert [alert["versionId"] for alert in alerts] == [12] - assert alerts[0]["priceBuzz"] == 250 - assert alerts[0]["modelId"] == 1 - - # Price rises above the threshold: the state resets silently. - raised = await service.refresh_for_model_type( - "lora", - scanner, - PriceProvider(_gated_response("2999-02-01T00:00:00.000Z"), prices=_prices(900)), - ) - assert raised[1].versions[0].price_alert_state is False - assert raised[1].events == [] - assert await service.get_price_alerts("lora") == [] - - # …and drops back under it: now it is news. - dropped = await service.refresh_for_model_type( - "lora", - scanner, - PriceProvider(_gated_response("2999-03-01T00:00:00.000Z"), prices=_prices(200)), - ) - assert dropped[1].versions[0].price_alert_state is True - assert [event["kind"] for event in dropped[1].events] == ["price_drop"] - assert dropped[1].events[0]["priceBuzz"] == 200 - # Events are derived, never persisted: a later read reports none. - stored = await service.get_record("lora", 1) - assert stored.events == [] - assert stored.versions[0].price_alert_state is True -@pytest.mark.asyncio -async def test_price_alert_not_fired_above_threshold(tmp_path): - service = _price_service( - tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=100 - ) - scanner = DummyScanner(LOCAL_RAW_DATA) - provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD) - - await service.refresh_for_model_type("lora", scanner, provider) - record = await service.get_record("lora", 1) - - assert record.versions[0].price_alert_state is False - assert record.events == [] - assert await service.get_price_alerts("lora") == [] -@pytest.mark.asyncio -async def test_price_alert_requires_price_tracking(tmp_path): - """With tracking off there are no prices, so no price alert either.""" - - service = _price_service(tmp_path, price_alert_threshold_buzz=100000) - scanner = DummyScanner(LOCAL_RAW_DATA) - provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD) - - await service.refresh_for_model_type("lora", scanner, provider) - record = await service.get_record("lora", 1) - - assert record.versions[0].price_alert_state is False - assert await service.get_price_alerts("lora") == [] -@pytest.mark.asyncio -async def test_price_alerts_respect_ignores_and_model_type(tmp_path): - service = _price_service( - tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=1000 - ) - scanner = DummyScanner(LOCAL_RAW_DATA) - provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD) - - await service.refresh_for_model_type("lora", scanner, provider) - assert len(await service.get_price_alerts("lora")) == 1 - - await service.set_version_should_ignore("lora", 1, 12, True) - assert await service.get_price_alerts("lora") == [] - assert await service.get_price_alerts("checkpoint") == [] -@pytest.mark.asyncio -async def test_get_price_alerts_tolerates_bad_limit(tmp_path): - service = ModelUpdateService(str(tmp_path / "updates.sqlite")) - - assert await service.get_price_alerts("lora", limit="not-a-number") == [] # --- Price alerts panel semantics (read-time threshold, across types) --------- @@ -1760,133 +1669,14 @@ def _free_response_for(version_id: int) -> dict: } -@pytest.mark.asyncio -async def test_price_alerts_threshold_is_compared_at_read_time(tmp_path): - """Changing the threshold must change panel membership without a refresh.""" - - service = _price_service( - tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=300 - ) - scanner = DummyScanner(LOCAL_RAW_DATA) - await service.refresh_for_model_type( - "lora", scanner, PriceProvider(GATED_RESPONSE, prices=_prices(250)) - ) - - assert len(await service.get_price_alerts("lora")) == 1 - # The stored alert state still says "hit"... - stored = await service.get_record("lora", 1) - assert stored.versions[0].price_alert_state is True - # ...but a tighter threshold excludes it immediately. - assert await service.get_price_alerts("lora", threshold_buzz=100) == [] - assert len(await service.get_price_alerts("lora", threshold_buzz=300)) == 1 -@pytest.mark.asyncio -async def test_price_alerts_span_all_model_types(tmp_path): - """model_type=None is what the global panel uses: one list, all types.""" - - service = _price_service( - tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=1000 - ) - lora_scanner = DummyScanner([{"civitai": {"modelId": 1, "id": 11}}]) - checkpoint_scanner = DummyScanner([{"civitai": {"modelId": 2, "id": 21}}]) - - await service.refresh_for_model_type( - "lora", - lora_scanner, - PriceProvider(_gated_response_for(12, "2999-01-01T00:00:00.000Z"), prices=_prices(250)), - ) - await service.refresh_for_model_type( - "checkpoint", - checkpoint_scanner, - PriceProvider( - _gated_response_for(22, "2999-01-01T00:00:00.000Z"), - prices={22: {"price_buzz": 100}}, - ), - ) - - everything = await service.get_price_alerts() - assert {(a["modelType"], a["modelId"], a["versionId"]) for a in everything} == { - ("lora", 1, 12), - ("checkpoint", 2, 22), - } - # Cheapest first. - assert [a["priceBuzz"] for a in everything] == [100, 250] - - assert [a["modelType"] for a in await service.get_price_alerts("checkpoint")] == [ - "checkpoint" - ] -@pytest.mark.asyncio -async def test_price_alert_since_tracks_the_crossing_and_clears(tmp_path): - service = _price_service( - tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=300 - ) - scanner = DummyScanner(LOCAL_RAW_DATA) - - first = await service.refresh_for_model_type( - "lora", - scanner, - PriceProvider(_gated_response("2999-01-01T00:00:00.000Z"), prices=_prices(250)), - ) - since = first[1].versions[0].price_alert_since - assert since is not None - - # Still under the threshold: the original crossing time is preserved. - second = await service.refresh_for_model_type( - "lora", - scanner, - PriceProvider(_gated_response("2999-02-01T00:00:00.000Z"), prices=_prices(200)), - ) - assert second[1].versions[0].price_alert_since == since - stored = await service.get_record("lora", 1) - assert stored.versions[0].price_alert_since == since - - # Back above it: cleared, and the row leaves the panel. - third = await service.refresh_for_model_type( - "lora", - scanner, - PriceProvider(_gated_response("2999-03-01T00:00:00.000Z"), prices=_prices(900)), - ) - assert third[1].versions[0].price_alert_since is None - assert await service.get_price_alerts("lora") == [] -@pytest.mark.asyncio -async def test_price_alerts_report_became_free_without_price_tracking(tmp_path): - """Gate transitions need no price data, so the "became free" half of the panel - works even while price tracking is switched off.""" - - service = _price_service(tmp_path) # tracking off - scanner = DummyScanner(LOCAL_RAW_DATA) - - await service.refresh_for_model_type("lora", scanner, DummyProvider(GATED_RESPONSE)) - assert await service.get_price_alerts("lora") == [] - - await service.refresh_for_model_type("lora", scanner, DummyProvider(FREE_RESPONSE)) - alerts = await service.get_price_alerts("lora") - - assert [alert["kind"] for alert in alerts] == ["became_free"] - assert alerts[0]["priceBuzz"] is None - assert alerts[0]["gateLapsedAt"] is not None - assert alerts[0]["versionId"] == 12 -@pytest.mark.asyncio -async def test_newest_price_checked_at_reports_the_latest_fetch(tmp_path): - service = _price_service(tmp_path, price_tracking_enabled=True) - scanner = DummyScanner(LOCAL_RAW_DATA) - - assert service.newest_price_checked_at() is None - - await service.refresh_for_model_type( - "lora", scanner, PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD) - ) - newest = service.newest_price_checked_at() - - assert newest is not None - assert newest > 0 @pytest.mark.asyncio @@ -1907,11 +1697,6 @@ async def test_failed_price_attempt_is_recorded_as_unavailable(tmp_path): assert version.price_buzz is None assert version.price_checked_at is None assert version.price_check_attempted_at is not None - assert service.count_unavailable_prices("lora") == 1 - assert service.count_unavailable_prices("checkpoint") == 0 - # Nothing to alert on, and no price alert state. - assert await service.get_price_alerts("lora") == [] - assert version.price_alert_state is False @pytest.mark.asyncio @@ -1928,7 +1713,6 @@ async def test_successful_price_attempt_sets_both_markers(tmp_path): assert version.price_buzz == 250 assert version.price_checked_at is not None assert version.price_check_attempted_at is not None - assert service.count_unavailable_prices("lora") == 0 @pytest.mark.asyncio @@ -1940,7 +1724,6 @@ async def test_no_price_attempt_is_recorded_while_tracking_is_off(tmp_path): record = await service.get_record("lora", 1) assert record.versions[0].price_check_attempted_at is None - assert service.count_unavailable_prices("lora") == 0 @pytest.mark.asyncio @@ -1951,13 +1734,11 @@ async def test_unavailable_marker_clears_when_the_version_becomes_free(tmp_path) await service.refresh_for_model_type( "lora", scanner, PriceProvider(GATED_RESPONSE, prices=None) ) - assert service.count_unavailable_prices("lora") == 1 await service.refresh_for_model_type("lora", scanner, DummyProvider(FREE_RESPONSE)) record = await service.get_record("lora", 1) assert record.versions[0].price_check_attempted_at is None - assert service.count_unavailable_prices("lora") == 0 def _long_ttl_service(tmp_path, **settings): @@ -2020,21 +1801,8 @@ async def test_failed_price_attempt_is_not_retried_within_the_ttl(tmp_path): await service.refresh_for_model_type("lora", scanner, provider) assert provider.price_calls == 1 - assert service.count_unavailable_prices("lora") == 1 -@pytest.mark.asyncio -async def test_priced_count_reports_known_prices(tmp_path): - service = _price_service(tmp_path, price_tracking_enabled=True) - scanner = DummyScanner(LOCAL_RAW_DATA) - provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD) - - assert service.count_priced_versions("lora") == 0 - - await service.refresh_for_model_type("lora", scanner, provider) - - assert service.count_priced_versions("lora") == 1 - assert service.count_priced_versions("checkpoint") == 0 @pytest.mark.asyncio @@ -2051,3 +1819,85 @@ async def test_forced_refresh_reprices_within_the_ttl(tmp_path): ) assert provider.price_calls == 2 + + +# --- Ownership scoping and the removed threshold ----------------------------- + + +def test_gate_event_is_suppressed_for_a_version_the_user_owns(tmp_path): + """A version already on disk cannot become cheaper *for this user*, so it must + not produce an event. More than half of a real library's gated versions are + owned, so this is the common case, not an edge case.""" + + service = ModelUpdateService(str(tmp_path / "updates.sqlite")) + existing = make_record(replace(_remote_gated(7), is_in_library=True)) + + record = service._build_record_from_remote( + model_type="lora", + model_id=999, + local_versions=[7], + remote_versions=[_remote_free(7)], + existing=existing, + timestamp=1000.0, + ) + + assert record.events == [] + # The lapse itself is still recorded - it is the *notification* that is + # pointless for a version the user already has. + assert record.versions[0].gate_lapsed_at is not None + + +def test_new_gate_event_is_suppressed_for_a_version_the_user_owns(tmp_path): + service = ModelUpdateService(str(tmp_path / "updates.sqlite")) + existing = make_record(replace(_remote_free(7), is_in_library=True)) + + record = service._build_record_from_remote( + model_type="lora", + model_id=999, + local_versions=[7], + remote_versions=[_remote_gated(7)], + existing=existing, + timestamp=1000.0, + ) + + assert record.events == [] + + +def test_threshold_setting_is_gone_from_the_defaults(): + """The redesign removed the numeric threshold: every obtainability decision is + categorical (wait / pay / skip), so no number is stored or compared.""" + + from py.services.settings_manager import DEFAULT_SETTINGS + + assert "price_alert_threshold_buzz" not in DEFAULT_SETTINGS + assert "price_tracking_enabled" in DEFAULT_SETTINGS + assert "price_check_ttl_hours" in DEFAULT_SETTINGS + + +def test_obsolete_alert_columns_are_dropped_from_a_development_database(tmp_path): + """A database created while the feature was unreleased still carries the two + alert-state columns; opening it must drop them rather than leave dead schema.""" + + db_path = tmp_path / "updates.sqlite" + service = ModelUpdateService(str(db_path)) + with service._connect() as conn: + conn.execute( + "ALTER TABLE model_update_versions " + "ADD COLUMN price_alert_state INTEGER NOT NULL DEFAULT 0" + ) + conn.execute( + "ALTER TABLE model_update_versions ADD COLUMN price_alert_since REAL" + ) + conn.commit() + assert "price_alert_state" in service._get_table_columns( + conn, "model_update_versions" + ) + + # A fresh service instance runs the migration on open. + reopened = ModelUpdateService(str(db_path)) + columns = reopened._get_table_columns( + reopened._connect(), "model_update_versions" + ) + + assert "price_alert_state" not in columns + assert "price_alert_since" not in columns From cbe9d58522c8a9d4b5ad6c309033ee3516256a92 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Mon, 5 Oct 2026 09:05:58 +0800 Subject: [PATCH 08/10] docs(update): the model card cannot carry a single price Live model 958009 has 37 gated versions across 6 price points, and the owner's own library has a model whose two early access versions end on different dates, so an 'Update - 500 Buzz' badge would be fabricated rather than summarised. The uniformity that made it look plausible is an artefact of floor pricing. Prices stay in the version list; only a deadline is well defined at model level. --- docs/plans/paid-model-price-tracking.md | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/plans/paid-model-price-tracking.md b/docs/plans/paid-model-price-tracking.md index a378efbb..79ead9d6 100644 --- a/docs/plans/paid-model-price-tracking.md +++ b/docs/plans/paid-model-price-tracking.md @@ -812,3 +812,30 @@ Verified: `pytest` 3653 passed / 7 skipped, `npm run test:js` 1444 passed. Again owner's real database the population is 52 gated versions -> **28 owned (now silent) + 24 that the feature is actually about**; the drop migration logged twice and left no `price_alert_*` column, and both removed endpoints return 404. + +### 12.8 Correction to P6b: the model card cannot carry a price + +P6b proposed annotating the model card's update badge with `Update - 500 Buzz` / +`Update - free on Oct 15`. The owner objected that the badge represents a **model** already in the +library while the update is a set of versions, each with its own price and its own early access end +date. The objection is correct, and the data is blunt about it: + +* Live model `958009` has **37 gated versions across 6 price points** (1111, 2220, 4440, 8880, + 88800, 888888); model `153568` has two gated versions at 600 and 4000. +* In the owner's own library, model `647926` has two early access versions with **two different end + dates**. + +So a single price or date on the card would be fabricated, not summarised. It looked plausible only +because that library is uniform (44 of its 52 priced versions sit at the 100 Buzz floor) - an +artefact of floor pricing, not a guarantee. + +What is well defined at model level, by construction: counts ("3 updates"), the earliest upcoming +free date (a minimum over the set), a minimum price ("from 100 Buzz", well defined but misleading +when the cheap version is an old one), and uniformity statements only when they are true. "The price +is X" is not among them. + +Revised scope: the card badge stays a plain update indicator, and **prices stay in the version list**, +where every row has exactly one subject. The only model-level fact worth considering is the deadline +- "1 of 3 updates becomes free on Oct 15" - because a deadline can change what the user does *before* +opening the model; it is rare in practice (4 unexpired early access versions across 3 models in that +library), so it is optional polish rather than part of the correction. From 98ba319735bd5cc77d28e4fdeac5c2a703ab64ea Mon Sep 17 00:00:00 2001 From: Will Miao Date: Mon, 5 Oct 2026 09:13:58 +0800 Subject: [PATCH 09/10] i18n(update): translate the Buzz download price strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the 13 keys the obtainability feature left as [TODO: Translate], in all 9 locales: the two gate-event counts, the sale / Blue Buzz / "Free Now" / early access end-date badge strings, and the six settings.priceTracking strings plus the section header. - Buzz and Blue Buzz stay as-is everywhere: they are CivitAI currency names, not translatable words (R3) - register follows each file's existing norm (你 zh-CN, 您 zh-TW, Sie de, tú es, вы ru); fr keeps the file's ASCII apostrophe style; no full-width punctuation leaked into the Latin/Cyrillic/Hebrew locales - two source fixes came with the pass: the unused settings.priceTracking.label key is removed (no template renders it - the toggle uses enabled/enabledHelp), and settings.sections.priceTracking now reads "Buzz Download Prices" so the header matches what the feature does: prices are displayed, nothing is tracked for alerts - the locale files were edited by exact-value replacement rather than re-serialised, so each diff is 13 lines and the per-file indentation is untouched (R1) --- docs/i18n-translation-guidelines.md | 12 ++++++++++++ locales/de.json | 27 +++++++++++++-------------- locales/en.json | 3 +-- locales/es.json | 27 +++++++++++++-------------- locales/fr.json | 27 +++++++++++++-------------- locales/he.json | 27 +++++++++++++-------------- locales/ja.json | 27 +++++++++++++-------------- locales/ko.json | 27 +++++++++++++-------------- locales/ru.json | 27 +++++++++++++-------------- locales/zh-CN.json | 27 +++++++++++++-------------- locales/zh-TW.json | 27 +++++++++++++-------------- 11 files changed, 130 insertions(+), 128 deletions(-) diff --git a/docs/i18n-translation-guidelines.md b/docs/i18n-translation-guidelines.md index 922116e7..1faab191 100644 --- a/docs/i18n-translation-guidelines.md +++ b/docs/i18n-translation-guidelines.md @@ -133,6 +133,18 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL). > now always on. All 9 locales are translated (terminology in §2, "Civitai ids feature"), > so the "no remaining placeholders" claim holds again. +> **Status (2026-10, Buzz download prices):** the paid/early-access obtainability feature added +> 13 keys — the two `globalContextMenu.checkModelUpdates.gateEvents.*` counts, five +> `modals.model.versions.badges.*` (sale, Blue Buzz, "free now" and its tooltip, and the early +> access end-date tooltip) and the six `settings.priceTracking.*` strings — plus the section +> header. All 13 are now translated in all 9 locales. **Buzz** and **Blue Buzz** stay as-is +> everywhere (CivitAI currency names, R3). Two source fixes came with the pass: the unused +> `settings.priceTracking.label` key was removed (no template renders it; the toggle uses +> `enabled`/`enabledHelp`) and `settings.sections.priceTracking` was reworded to +> "Buzz Download Prices" so the header matches what the feature does (prices are displayed; +> nothing is tracked for alerts). Register follows each file's existing norm: 你 (zh-CN), +> 您 (zh-TW), Sie (de), tú (es), вы (ru). No remaining `[TODO: Translate]` placeholders. + --- ## 1. Hard rules (do not violate) diff --git a/locales/de.json b/locales/de.json index a762479e..8161a73a 100644 --- a/locales/de.json +++ b/locales/de.json @@ -203,8 +203,8 @@ "none": "Alle {type} sind auf dem neuesten Stand", "error": "Fehler beim Prüfen auf {type}-Updates: {message}", "gateEvents": { - "becameFree": "[TODO: Translate] {count} version(s) became free", - "newGate": "[TODO: Translate] {count} version(s) are now paid" + "becameFree": "{count} Version(en), die Sie nicht besitzen, sind jetzt kostenlos", + "newGate": "{count} Version(en) sind jetzt kostenpflichtig" } }, "cleanupExampleImages": { @@ -391,7 +391,7 @@ "downloadPathTemplates": "Download-Pfad-Vorlagen", "priorityTags": "Prioritäts-Tags", "versionScope": "Update-Markierungen", - "priceTracking": "[TODO: Translate] Buzz Price Tracking", + "priceTracking": "Buzz-Downloadpreise", "exampleImages": "Beispielbilder", "autoOrganize": "Auto-Organisierung", "metadata": "Metadaten", @@ -759,12 +759,11 @@ "help": "Wenn aktiviert, zeigen Modelle mit nur bezahlten Updates kein 'Update verfügbar'-Badge an" }, "priceTracking": { - "label": "[TODO: Translate] Buzz Price Tracking", - "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", - "enabled": "[TODO: Translate] Track Buzz prices for paid versions", - "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "refreshHours": "[TODO: Translate] Price refresh interval (hours)", - "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" + "help": "Die öffentliche API von CivitAI veröffentlicht keine Downloadpreise, daher werden sie von der Modellseite gelesen - eine zusätzliche Anfrage pro bezahltem Modell, aktualisiert im unten stehenden Intervall. Preise werden nur bei Versionen angezeigt, die Sie noch nicht haben.", + "enabled": "Downloadpreise für bezahlte Versionen anzeigen", + "enabledHelp": "Liest den Downloadpreis von der Modellseite, damit bezahlte Versionen und Versionen mit frühem Zugriff ihren Preis und das Ende des frühen Zugriffs zeigen", + "refreshHours": "Aktualisierungsintervall für Preise (Stunden)", + "refreshHoursHelp": "Wie lange ein gespeicherter Preis gültig bleibt, bevor er erneut gelesen wird" }, "licenseIcons": { "useNewStyle": "Aktualisierte Lizenzsymbole verwenden", @@ -2021,15 +2020,15 @@ "earlyAccessTooltip": "Für diese Version ist derzeit CivitAI Early Access erforderlich", "paid": "Bezahlt", "paidTooltip": "Diese Version erfordert eine Zahlung zum Herunterladen", - "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", - "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "freeNow": "[TODO: Translate] Free Now", - "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", + "priceSaleTooltip": "Im Angebot, normalerweise {list} Buzz", + "priceBlueBuzzTooltip": "Kann auch mit Blue Buzz bezahlt werden", + "freeNow": "Jetzt kostenlos", + "freeNowTooltip": "Diese Version erfordert keine Zahlung mehr", "ignored": "Ignoriert", "ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert", "onSiteOnly": "Nur On-Site", "onSiteOnlyTooltip": "Diese Version ist nur für die On-Site-Generierung auf CivitAI verfügbar", - "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" + "earlyAccessTooltipUntil": "Früher Zugriff bis {date}, danach kostenlos zum Herunterladen" }, "actions": { "download": "Herunterladen", diff --git a/locales/en.json b/locales/en.json index 6bf9c749..b4879064 100644 --- a/locales/en.json +++ b/locales/en.json @@ -391,7 +391,7 @@ "downloadPathTemplates": "Download Path Templates", "priorityTags": "Priority Tags", "versionScope": "Version Scope", - "priceTracking": "Buzz Price Tracking", + "priceTracking": "Buzz Download Prices", "exampleImages": "Example Images", "autoOrganize": "Auto-organize", "metadata": "Metadata", @@ -759,7 +759,6 @@ "help": "When enabled, models with only paid updates will not show 'Update available' badge" }, "priceTracking": { - "label": "Buzz Download Prices", "help": "CivitAI does not publish download prices in its public API, so they are read from the model page - one extra request per paid model, refreshed on the interval below. Prices are shown on versions you do not have yet.", "enabled": "Show download prices for paid versions", "enabledHelp": "Reads the download price from the model page so paid and early access versions show what they cost, and when early access ends", diff --git a/locales/es.json b/locales/es.json index 2349ec7b..8197015f 100644 --- a/locales/es.json +++ b/locales/es.json @@ -203,8 +203,8 @@ "none": "Todos los {type} están actualizados", "error": "Error al buscar actualizaciones de {type}: {message}", "gateEvents": { - "becameFree": "[TODO: Translate] {count} version(s) became free", - "newGate": "[TODO: Translate] {count} version(s) are now paid" + "becameFree": "{count} versión(es) que no tienes se han vuelto gratuitas", + "newGate": "{count} versión(es) ahora son de pago" } }, "cleanupExampleImages": { @@ -391,7 +391,7 @@ "downloadPathTemplates": "Plantillas de rutas de descarga", "priorityTags": "Etiquetas prioritarias", "versionScope": "Indicadores de actualización", - "priceTracking": "[TODO: Translate] Buzz Price Tracking", + "priceTracking": "Precios de descarga en Buzz", "exampleImages": "Imágenes de ejemplo", "autoOrganize": "Organización automática", "metadata": "Metadatos", @@ -759,12 +759,11 @@ "help": "Cuando está activado, los modelos que solo tienen actualizaciones de pago no mostrarán la insignia de 'Actualización disponible'" }, "priceTracking": { - "label": "[TODO: Translate] Buzz Price Tracking", - "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", - "enabled": "[TODO: Translate] Track Buzz prices for paid versions", - "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "refreshHours": "[TODO: Translate] Price refresh interval (hours)", - "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" + "help": "La API pública de CivitAI no publica los precios de descarga, así que se leen de la página del modelo: una petición adicional por modelo de pago, actualizada según el intervalo de abajo. Los precios se muestran solo en las versiones que aún no tienes.", + "enabled": "Mostrar los precios de descarga de las versiones de pago", + "enabledHelp": "Lee el precio de descarga de la página del modelo para mostrar cuánto cuestan las versiones de pago y de acceso temprano, y cuándo termina el acceso temprano", + "refreshHours": "Intervalo de actualización de precios (horas)", + "refreshHoursHelp": "Cuánto tiempo sigue vigente un precio guardado antes de volver a leerse" }, "licenseIcons": { "useNewStyle": "Usar iconos de licencia actualizados", @@ -2021,15 +2020,15 @@ "earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de CivitAI", "paid": "De pago", "paidTooltip": "Esta versión requiere pago para descargarse", - "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", - "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "freeNow": "[TODO: Translate] Free Now", - "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", + "priceSaleTooltip": "En oferta, normalmente {list} Buzz", + "priceBlueBuzzTooltip": "También se puede pagar con Blue Buzz", + "freeNow": "Ahora gratis", + "freeNowTooltip": "Esta versión ya no requiere pago", "ignored": "Ignorada", "ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión", "onSiteOnly": "Solo en Sitio", "onSiteOnlyTooltip": "Esta versión solo está disponible para generación en el sitio de CivitAI", - "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" + "earlyAccessTooltipUntil": "Acceso temprano hasta el {date}, descarga gratuita después" }, "actions": { "download": "Descargar", diff --git a/locales/fr.json b/locales/fr.json index f016f7fa..1fbf6778 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -203,8 +203,8 @@ "none": "Tous les {type} sont à jour", "error": "Échec de la vérification des mises à jour pour {type} : {message}", "gateEvents": { - "becameFree": "[TODO: Translate] {count} version(s) became free", - "newGate": "[TODO: Translate] {count} version(s) are now paid" + "becameFree": "{count} version(s) que vous ne possédez pas sont devenues gratuites", + "newGate": "{count} version(s) sont désormais payantes" } }, "cleanupExampleImages": { @@ -391,7 +391,7 @@ "downloadPathTemplates": "Modèles de chemin de téléchargement", "priorityTags": "Tags prioritaires", "versionScope": "Indicateurs de mise à jour", - "priceTracking": "[TODO: Translate] Buzz Price Tracking", + "priceTracking": "Prix de téléchargement en Buzz", "exampleImages": "Images d'exemple", "autoOrganize": "Organisation automatique", "metadata": "Métadonnées", @@ -759,12 +759,11 @@ "help": "Lorsque cette option est activée, les modèles n'ayant que des mises à jour payantes n'affichent pas le badge « Mise à jour disponible »" }, "priceTracking": { - "label": "[TODO: Translate] Buzz Price Tracking", - "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", - "enabled": "[TODO: Translate] Track Buzz prices for paid versions", - "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "refreshHours": "[TODO: Translate] Price refresh interval (hours)", - "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" + "help": "L'API publique de CivitAI ne publie pas les prix de téléchargement : ils sont donc lus sur la page du modèle - une requête supplémentaire par modèle payant, actualisée selon l'intervalle ci-dessous. Les prix sont affichés sur les versions que vous ne possédez pas encore.", + "enabled": "Afficher les prix de téléchargement des versions payantes", + "enabledHelp": "Lit le prix de téléchargement sur la page du modèle pour indiquer le coût des versions payantes et en accès anticipé, ainsi que la fin de l'accès anticipé", + "refreshHours": "Intervalle d'actualisation des prix (heures)", + "refreshHoursHelp": "Durée pendant laquelle un prix enregistré reste valide avant d'être relu" }, "licenseIcons": { "useNewStyle": "Utiliser les icônes de licence mises à jour", @@ -2021,15 +2020,15 @@ "earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé CivitAI", "paid": "Payant", "paidTooltip": "Cette version nécessite un paiement pour être téléchargée", - "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", - "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "freeNow": "[TODO: Translate] Free Now", - "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", + "priceSaleTooltip": "En promotion, normalement {list} Buzz", + "priceBlueBuzzTooltip": "Peut aussi être payé avec des Blue Buzz", + "freeNow": "Gratuit maintenant", + "freeNowTooltip": "Cette version ne nécessite plus de paiement", "ignored": "Ignorée", "ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version", "onSiteOnly": "Uniquement sur Site", "onSiteOnlyTooltip": "Cette version n'est disponible que pour la génération sur le site CivitAI", - "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" + "earlyAccessTooltipUntil": "Accès anticipé jusqu'au {date}, téléchargement gratuit ensuite" }, "actions": { "download": "Télécharger", diff --git a/locales/he.json b/locales/he.json index 2d602130..16bb04c9 100644 --- a/locales/he.json +++ b/locales/he.json @@ -203,8 +203,8 @@ "none": "כל ה-{type} מעודכנים", "error": "נכשל בבדיקת העדכונים עבור {type}: {message}", "gateEvents": { - "becameFree": "[TODO: Translate] {count} version(s) became free", - "newGate": "[TODO: Translate] {count} version(s) are now paid" + "becameFree": "{count} גרסאות שאין לך הפכו לחינמיות", + "newGate": "{count} גרסאות דורשות כעת תשלום" } }, "cleanupExampleImages": { @@ -391,7 +391,7 @@ "downloadPathTemplates": "תבניות נתיב הורדה", "priorityTags": "תגיות עדיפות", "versionScope": "תגי עדכון", - "priceTracking": "[TODO: Translate] Buzz Price Tracking", + "priceTracking": "מחירי הורדה ב-Buzz", "exampleImages": "תמונות דוגמה", "autoOrganize": "ארגון אוטומטי", "metadata": "מטא-נתונים", @@ -759,12 +759,11 @@ "help": "כשאפשרות זו מופעלת, מודלים עם עדכונים בתשלום בלבד לא יציגו את תגית 'עדכון זמין'" }, "priceTracking": { - "label": "[TODO: Translate] Buzz Price Tracking", - "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", - "enabled": "[TODO: Translate] Track Buzz prices for paid versions", - "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "refreshHours": "[TODO: Translate] Price refresh interval (hours)", - "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" + "help": "ה-API הציבורי של CivitAI אינו מפרסם מחירי הורדה, ולכן הם נקראים מדף המודל - בקשה נוספת אחת לכל מודל בתשלום, ברענון לפי המרווח שלמטה. המחירים מוצגים רק בגרסאות שאין לך עדיין.", + "enabled": "הצג מחירי הורדה של גרסאות בתשלום", + "enabledHelp": "קורא את מחיר ההורדה מדף המודל כדי להציג כמה עולות גרסאות בתשלום וגרסאות בגישה מוקדמת, ומתי מסתיימת הגישה המוקדמת", + "refreshHours": "מרווח רענון המחירים (שעות)", + "refreshHoursHelp": "כמה זמן מחיר שמור נחשב מעודכן לפני קריאה חוזרת" }, "licenseIcons": { "useNewStyle": "השתמש בסמלי רישיון מעודכנים", @@ -2021,15 +2020,15 @@ "earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של CivitAI", "paid": "בתשלום", "paidTooltip": "גרסה זו דורשת תשלום כדי להוריד", - "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", - "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "freeNow": "[TODO: Translate] Free Now", - "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", + "priceSaleTooltip": "במבצע, בדרך כלל {list} Buzz", + "priceBlueBuzzTooltip": "ניתן לשלם גם ב-Blue Buzz", + "freeNow": "חינם כעת", + "freeNowTooltip": "גרסה זו אינה דורשת עוד תשלום", "ignored": "התעלם", "ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו", "onSiteOnly": "רק באתר", "onSiteOnlyTooltip": "גרסה זו זמינה רק ליצירה באתר CivitAI", - "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" + "earlyAccessTooltipUntil": "גישה מוקדמת עד {date}, לאחר מכן ההורדה חינם" }, "actions": { "download": "הורדה", diff --git a/locales/ja.json b/locales/ja.json index aef5fe03..92114a55 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -203,8 +203,8 @@ "none": "すべての {type} は最新です", "error": "{type} のアップデート確認に失敗しました: {message}", "gateEvents": { - "becameFree": "[TODO: Translate] {count} version(s) became free", - "newGate": "[TODO: Translate] {count} version(s) are now paid" + "becameFree": "未ダウンロードの {count} バージョンが無料になりました", + "newGate": "{count} バージョンが有料になりました" } }, "cleanupExampleImages": { @@ -391,7 +391,7 @@ "downloadPathTemplates": "ダウンロードパステンプレート", "priorityTags": "優先タグ", "versionScope": "アップデートフラグ", - "priceTracking": "[TODO: Translate] Buzz Price Tracking", + "priceTracking": "Buzz ダウンロード価格", "exampleImages": "例画像", "autoOrganize": "自動整理", "metadata": "メタデータ", @@ -759,12 +759,11 @@ "help": "有効にすると、有料の更新のみがあるモデルには「更新あり」バッジが表示されません" }, "priceTracking": { - "label": "[TODO: Translate] Buzz Price Tracking", - "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", - "enabled": "[TODO: Translate] Track Buzz prices for paid versions", - "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "refreshHours": "[TODO: Translate] Price refresh interval (hours)", - "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" + "help": "CivitAI の公開 API はダウンロード価格を提供していないため、モデルページから読み取ります。有料モデルごとに 1 回の追加リクエストが発生し、以下の間隔で更新されます。価格は未ダウンロードのバージョンにのみ表示されます。", + "enabled": "有料バージョンのダウンロード価格を表示", + "enabledHelp": "モデルページからダウンロード価格を読み取り、有料バージョンと早期アクセスバージョンの費用と早期アクセスの終了時期を表示します", + "refreshHours": "価格の更新間隔(時間)", + "refreshHoursHelp": "保存された価格が再読み取りまで有効な期間" }, "licenseIcons": { "useNewStyle": "更新されたライセンスアイコンを使用", @@ -2021,15 +2020,15 @@ "earlyAccessTooltip": "このバージョンは現在 CivitAI の早期アクセスが必要です", "paid": "有料", "paidTooltip": "このバージョンのダウンロードには支払いが必要です", - "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", - "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "freeNow": "[TODO: Translate] Free Now", - "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", + "priceSaleTooltip": "セール中、通常 {list} Buzz", + "priceBlueBuzzTooltip": "Blue Buzz でも支払えます", + "freeNow": "無料になりました", + "freeNowTooltip": "このバージョンは支払いが不要になりました", "ignored": "無視中", "ignoredTooltip": "このバージョンの更新通知は無効です", "onSiteOnly": "サイト内のみ", "onSiteOnlyTooltip": "このバージョンはCivitAIサイト内でのみ利用可能で、ダウンロードはできません", - "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" + "earlyAccessTooltipUntil": "{date} まで早期アクセス、以降は無料でダウンロードできます" }, "actions": { "download": "ダウンロード", diff --git a/locales/ko.json b/locales/ko.json index cf074d32..5bd753ac 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -203,8 +203,8 @@ "none": "모든 {type}가 최신 상태입니다", "error": "{type} 업데이트 확인 실패: {message}", "gateEvents": { - "becameFree": "[TODO: Translate] {count} version(s) became free", - "newGate": "[TODO: Translate] {count} version(s) are now paid" + "becameFree": "보유하지 않은 버전 {count}개가 무료가 되었습니다", + "newGate": "버전 {count}개가 유료로 전환되었습니다" } }, "cleanupExampleImages": { @@ -391,7 +391,7 @@ "downloadPathTemplates": "다운로드 경로 템플릿", "priorityTags": "우선순위 태그", "versionScope": "업데이트 표시", - "priceTracking": "[TODO: Translate] Buzz Price Tracking", + "priceTracking": "Buzz 다운로드 가격", "exampleImages": "예시 이미지", "autoOrganize": "자동 정리", "metadata": "메타데이터", @@ -759,12 +759,11 @@ "help": "활성화하면 유료 업데이트만 있는 모델에 '업데이트 가능' 배지가 표시되지 않습니다" }, "priceTracking": { - "label": "[TODO: Translate] Buzz Price Tracking", - "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", - "enabled": "[TODO: Translate] Track Buzz prices for paid versions", - "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "refreshHours": "[TODO: Translate] Price refresh interval (hours)", - "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" + "help": "CivitAI 공개 API는 다운로드 가격을 제공하지 않으므로 모델 페이지에서 읽어옵니다. 유료 모델마다 요청이 한 번씩 추가되며, 아래 간격에 따라 갱신됩니다. 가격은 아직 보유하지 않은 버전에만 표시됩니다.", + "enabled": "유료 버전의 다운로드 가격 표시", + "enabledHelp": "모델 페이지에서 다운로드 가격을 읽어 유료 및 얼리 액세스 버전의 비용과 얼리 액세스 종료 시점을 표시합니다", + "refreshHours": "가격 갱신 간격(시간)", + "refreshHoursHelp": "저장된 가격을 다시 읽기 전까지 유지하는 기간" }, "licenseIcons": { "useNewStyle": "업데이트된 라이선스 아이콘 사용", @@ -2021,15 +2020,15 @@ "earlyAccessTooltip": "이 버전은 현재 CivitAI 얼리 액세스가 필요합니다", "paid": "유료", "paidTooltip": "이 버전은 다운로드하려면 결제가 필요합니다", - "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", - "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "freeNow": "[TODO: Translate] Free Now", - "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", + "priceSaleTooltip": "할인 중, 원래 {list} Buzz", + "priceBlueBuzzTooltip": "Blue Buzz로도 결제할 수 있습니다", + "freeNow": "지금 무료", + "freeNowTooltip": "이 버전은 더 이상 결제가 필요하지 않습니다", "ignored": "무시됨", "ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다", "onSiteOnly": "사이트 내 전용", "onSiteOnlyTooltip": "이 버전은 CivitAI 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다", - "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" + "earlyAccessTooltipUntil": "{date}까지 얼리 액세스, 이후 무료로 다운로드할 수 있습니다" }, "actions": { "download": "다운로드", diff --git a/locales/ru.json b/locales/ru.json index 9137eb27..c2491563 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -203,8 +203,8 @@ "none": "Все {type} актуальны", "error": "Не удалось проверить обновления для {type}: {message}", "gateEvents": { - "becameFree": "[TODO: Translate] {count} version(s) became free", - "newGate": "[TODO: Translate] {count} version(s) are now paid" + "becameFree": "{count} версий, которых у вас нет, стали бесплатными", + "newGate": "{count} версий стали платными" } }, "cleanupExampleImages": { @@ -391,7 +391,7 @@ "downloadPathTemplates": "Шаблоны путей загрузки", "priorityTags": "Приоритетные теги", "versionScope": "Метки обновлений", - "priceTracking": "[TODO: Translate] Buzz Price Tracking", + "priceTracking": "Цены за скачивание в Buzz", "exampleImages": "Примеры изображений", "autoOrganize": "Автоорганизация", "metadata": "Метаданные", @@ -759,12 +759,11 @@ "help": "Если включено, у моделей, для которых доступны только платные обновления, не будет отображаться значок «Доступно обновление»" }, "priceTracking": { - "label": "[TODO: Translate] Buzz Price Tracking", - "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", - "enabled": "[TODO: Translate] Track Buzz prices for paid versions", - "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "refreshHours": "[TODO: Translate] Price refresh interval (hours)", - "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" + "help": "Публичный API CivitAI не публикует цены за скачивание, поэтому они читаются со страницы модели — один дополнительный запрос на каждую платную модель, обновление с интервалом ниже. Цены показываются только для версий, которых у вас ещё нет.", + "enabled": "Показывать цены за скачивание платных версий", + "enabledHelp": "Читает цену за скачивание со страницы модели, чтобы платные версии и версии с ранним доступом показывали стоимость и дату окончания раннего доступа", + "refreshHours": "Интервал обновления цен (часы)", + "refreshHoursHelp": "Сколько хранится цена, прежде чем её прочитают снова" }, "licenseIcons": { "useNewStyle": "Использовать обновлённые значки лицензии", @@ -2021,15 +2020,15 @@ "earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ CivitAI", "paid": "Платная", "paidTooltip": "Скачивание этой версии платное", - "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", - "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "freeNow": "[TODO: Translate] Free Now", - "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", + "priceSaleTooltip": "Со скидкой, обычно {list} Buzz", + "priceBlueBuzzTooltip": "Можно также оплатить Blue Buzz", + "freeNow": "Теперь бесплатно", + "freeNowTooltip": "Эта версия больше не требует оплаты", "ignored": "Игнорируется", "ignoredTooltip": "Уведомления об обновлениях для этой версии отключены", "onSiteOnly": "Только на Сайте", "onSiteOnlyTooltip": "Эта версия доступна только для генерации на сайте CivitAI", - "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" + "earlyAccessTooltipUntil": "Ранний доступ до {date}, после этого скачивание бесплатно" }, "actions": { "download": "Скачать", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 0037373f..1a54486f 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -203,8 +203,8 @@ "none": "所有 {type} 均已是最新版本", "error": "检查 {type} 更新失败:{message}", "gateEvents": { - "becameFree": "[TODO: Translate] {count} version(s) became free", - "newGate": "[TODO: Translate] {count} version(s) are now paid" + "becameFree": "{count} 个你未拥有的版本已免费", + "newGate": "{count} 个版本已变为付费" } }, "cleanupExampleImages": { @@ -391,7 +391,7 @@ "downloadPathTemplates": "下载路径模板", "priorityTags": "优先标签", "versionScope": "版本范围", - "priceTracking": "[TODO: Translate] Buzz Price Tracking", + "priceTracking": "Buzz 下载价格", "exampleImages": "示例图片", "autoOrganize": "自动整理", "metadata": "元数据", @@ -759,12 +759,11 @@ "help": "启用后,仅有付费更新的模型将不显示“有可用更新”徽标" }, "priceTracking": { - "label": "[TODO: Translate] Buzz Price Tracking", - "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", - "enabled": "[TODO: Translate] Track Buzz prices for paid versions", - "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "refreshHours": "[TODO: Translate] Price refresh interval (hours)", - "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" + "help": "CivitAI 的公开 API 不提供下载价格,因此价格从模型页面读取 —— 每个付费模型多一次请求,按下面的间隔刷新。价格只显示在你尚未拥有的版本上。", + "enabled": "显示付费版本的下载价格", + "enabledHelp": "从模型页面读取下载价格,让付费与抢先体验版本显示所需费用,以及抢先体验的结束时间", + "refreshHours": "价格刷新间隔(小时)", + "refreshHoursHelp": "已保存的价格在重新读取前保持有效的时长" }, "licenseIcons": { "useNewStyle": "使用新版许可协议图标", @@ -2021,15 +2020,15 @@ "earlyAccessTooltip": "此版本当前需要 CivitAI 抢先体验权限", "paid": "付费", "paidTooltip": "此版本需要付费后才能下载", - "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", - "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "freeNow": "[TODO: Translate] Free Now", - "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", + "priceSaleTooltip": "促销中,原价 {list} Buzz", + "priceBlueBuzzTooltip": "也可以使用 Blue Buzz 支付", + "freeNow": "现已免费", + "freeNowTooltip": "此版本已不再需要付费", "ignored": "已忽略", "ignoredTooltip": "此版本已关闭更新通知", "onSiteOnly": "仅站内生成", "onSiteOnlyTooltip": "此版本仅在 CivitAI 站内可用,无法下载", - "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" + "earlyAccessTooltipUntil": "抢先体验至 {date},之后可免费下载" }, "actions": { "download": "下载", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index a9fb564c..adc1b8bf 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -203,8 +203,8 @@ "none": "所有 {type} 都是最新版本", "error": "檢查 {type} 更新失敗:{message}", "gateEvents": { - "becameFree": "[TODO: Translate] {count} version(s) became free", - "newGate": "[TODO: Translate] {count} version(s) are now paid" + "becameFree": "{count} 個您未擁有的版本已免費", + "newGate": "{count} 個版本已變為付費" } }, "cleanupExampleImages": { @@ -391,7 +391,7 @@ "downloadPathTemplates": "下載路徑範本", "priorityTags": "優先標籤", "versionScope": "版本範圍", - "priceTracking": "[TODO: Translate] Buzz Price Tracking", + "priceTracking": "Buzz 下載價格", "exampleImages": "範例圖片", "autoOrganize": "自動整理", "metadata": "中繼資料", @@ -759,12 +759,11 @@ "help": "啟用後,只有付費更新的模型將不會顯示「有可用更新」徽章" }, "priceTracking": { - "label": "[TODO: Translate] Buzz Price Tracking", - "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", - "enabled": "[TODO: Translate] Track Buzz prices for paid versions", - "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "refreshHours": "[TODO: Translate] Price refresh interval (hours)", - "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" + "help": "CivitAI 的公開 API 不提供下載價格,因此價格從模型頁面讀取 —— 每個付費模型多一次請求,並依下方間隔重新整理。價格只顯示在您尚未擁有的版本上。", + "enabled": "顯示付費版本的下載價格", + "enabledHelp": "從模型頁面讀取下載價格,讓付費與搶先體驗版本顯示所需費用,以及搶先體驗的結束時間", + "refreshHours": "價格重新整理間隔(小時)", + "refreshHoursHelp": "已儲存的價格在重新讀取前保持有效的時長" }, "licenseIcons": { "useNewStyle": "使用新版許可協議圖標", @@ -2021,15 +2020,15 @@ "earlyAccessTooltip": "此版本目前需要 CivitAI 搶先體驗權限", "paid": "付費", "paidTooltip": "此版本需要付費才能下載", - "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", - "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "freeNow": "[TODO: Translate] Free Now", - "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", + "priceSaleTooltip": "特價中,原價 {list} Buzz", + "priceBlueBuzzTooltip": "也可以使用 Blue Buzz 支付", + "freeNow": "現已免費", + "freeNowTooltip": "此版本已不再需要付費", "ignored": "已忽略", "ignoredTooltip": "此版本已關閉更新通知", "onSiteOnly": "僅站內生成", "onSiteOnlyTooltip": "此版本僅在 CivitAI 站內可用,無法下載", - "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" + "earlyAccessTooltipUntil": "搶先體驗至 {date},之後可免費下載" }, "actions": { "download": "下載", From 977bbffa6723dcf730b3f69235d92622e10df740 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Mon, 5 Oct 2026 09:17:23 +0800 Subject: [PATCH 10/10] =?UTF-8?q?i18n(ja):=20shorten=20the=20"Free=20Now"?= =?UTF-8?q?=20badge=20to=20=E7=84=A1=E6=96=99=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 無料になりました (8 characters) is a sentence, not a badge label: it sits next to 有料 (2) and 早期アクセス (6) in the version row. 無料化 is three characters and names the state transition this badge actually marks - it only appears when a version's gate lapsed, never for a version that was free all along. The tooltip keeps the full sentence. --- locales/ja.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/locales/ja.json b/locales/ja.json index 92114a55..f232df6f 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -2022,7 +2022,7 @@ "paidTooltip": "このバージョンのダウンロードには支払いが必要です", "priceSaleTooltip": "セール中、通常 {list} Buzz", "priceBlueBuzzTooltip": "Blue Buzz でも支払えます", - "freeNow": "無料になりました", + "freeNow": "無料化", "freeNowTooltip": "このバージョンは支払いが不要になりました", "ignored": "無視中", "ignoredTooltip": "このバージョンの更新通知は無効です",