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