feat(update): track buzz prices and alert below a threshold

CivitAI's public API deliberately omits prices — paidAccess is trimmed to
{permanent, endsAt} because "pricing belongs to the purchase flow" — but the
public model page embeds the site's own model.getById result, including
paidAccess.terms, in its server-rendered payload. That is read anonymously
(no API key, no internal endpoint, no forged Origin), one request per gated
model, so only the ~2% of models that actually carry a gate pay for it.

- optional capture, off by default: price_tracking_enabled,
  price_alert_threshold_buzz (0 = alert on "became free" only) and
  price_check_ttl_hours; prices refresh on their own TTL and immediately when a
  gate changes, and a failed fetch keeps the stored price instead of blanking it
- versions that stop carrying a gate are marked free (persisted gate_lapsed_at)
  and gate transitions are reported as events on the refresh response, so a
  version already in the library can announce that it became free
- price_alert_state plus a price_drop edge event; new
  GET /api/lm/{type}/updates/price-alerts lists what is under the threshold
- versions tab shows the price (effective, with the list price struck through
  and a Blue Buzz note) and a Free Now badge; an update check toasts the
  transitions in one message
- the parser and the alerts query are unit-tested against a trimmed page
  fixture, and every route definition is now asserted to resolve to a handler

Plan, verification notes and the deviations from it are in
docs/plans/paid-model-price-tracking.md.
This commit is contained in:
Will Miao
2026-10-04 08:53:24 +08:00
parent 5f4054265d
commit ad2402724b
31 changed files with 2829 additions and 33 deletions
+381
View File
@@ -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/<id>` and `https://civitai.red/models/<id>` → `<script id="__NEXT_DATA__" type="application/json">` → `props.pageProps.trpcState.json.queries[*]` where `queryKey[0] == ["model","getById"]` → `state.data.modelVersions[*].paidAccess` |
| Observed payloads (anonymous, no cookies) | version 3379626 → `{terms:{download:{price:5000},generation:{price:100,trialLimit:5}}, endsAt:null, sale:null}`; version 3380114 → `download.price 125` `acceptsBlueBuzz:true` `endsAt:2026-10-10`; another → `200` |
| Per-generation licensing fees are already public and official | `GET /api/v1/model-versions/mini/{id}` (`MixedAuthEndpoint`, anonymous 200) exposes `fees` / `freeTrialLimit` — `~/code/civitai/src/pages/api/v1/model-versions/mini/[id].ts:455` |
| Webhooks cannot carry price changes | `updated-model` is driven by `Model.lastVersionAt` (fires only on new versions) and its select has no `paidAccess`: `~/code/civitai/src/server/webhooks/model.webooks.ts:88-96`, `~/code/civitai/src/server/selectors/model.selector.ts:15-36` |
| Prevalence (sampled newest 600 models / 4634 versions via public API cursor): **90 gated (1.9%), 87 permanent vs 3 timed** | live scan, 2026-xx; dominated by permanent gates, so "EA expiry" alone covers ~3% of gated versions |
### 2.3 Consequences for the design
* Threshold alerts on download price require **one new data source**: the model page payload. It is
public, but it is page data — so it must be isolated, opt-in, cheap, and fail-open.
* Request budget is small because only gated models need it: ~2% of models, one extra ~200 KB
request per gated model per price TTL.
* "Becomes free" and "gained a gate" need no new data source at all and should ship first.
* One page fetch returns **all** versions of a model, so prices cost one request per model, not per
version.
## 3. Data source decision
**Chosen: `__NEXT_DATA__` of the public model page**, fetched with the existing HTTP stack.
Rejected alternatives:
* *Internal tRPC* — origin-gated; passing it means forging `Origin` or relying on bearer auth against
an undocumented endpoint whose own error message says to use the public API. Not worth the
stability/compliance risk for a convenience feature.
* *Official v1 API as-is* — no price field, by design. Track it as an upstream request (§8), not as a
dependency.
* *`mini/{id}` `fees`* — useful extra signal for per-generation fees, but it is not the download
price and does not cover the gate.
## 4. Design
### 4.1 Schema (all additive, in the existing update DB)
`model_update_versions` gains:
```sql
ALTER TABLE model_update_versions ADD COLUMN price_buzz INTEGER; -- effective (sale-adjusted) download price
ALTER TABLE model_update_versions ADD COLUMN list_price_buzz INTEGER; -- undiscounted download price
ALTER TABLE model_update_versions ADD COLUMN generation_price_buzz INTEGER;
ALTER TABLE model_update_versions ADD COLUMN accepts_blue_buzz INTEGER NOT NULL DEFAULT 0;
ALTER TABLE model_update_versions ADD COLUMN price_sale_ends_at TEXT;
ALTER TABLE model_update_versions ADD COLUMN price_checked_at REAL; -- last *successful* price fetch
ALTER TABLE model_update_versions ADD COLUMN price_alert_state INTEGER NOT NULL DEFAULT 0; -- last computed alert hit, for edge detection
```
Adding a column to this table requires touching **all eight** enumerations (missing one silently
drops data or breaks reads):
1. dataclass `ModelVersionRecord` — `py/services/model_update_service.py:65-84`
2. `_SCHEMA` `CREATE TABLE` — `:300-330`
3. `_apply_migrations` column dict — `:554-580`
4. `_migrate_model_update_versions_primary_key` `target_columns` + `defaults` — `:680-745` (**easy to
miss; omitting it loses the new columns whenever the PK migration runs**)
5. `_build_record_from_remote` — `:1630-1665`
6. `_extract_single_version` — `:1740-1795`
7. `_get_records_bulk` SELECT + row→record mapping — `:1945-1990`
8. `_upsert_record` INSERT — `:2028-2066`
`price_checked_at` lives on the version row (not the model status row) so a stale price on one
version does not force a re-fetch for its siblings; the fetch itself is per model.
### 4.2 Parser contract
New pure function, unit-testable without network:
```
parse_model_page_prices(html: str) -> dict[int, dict] | None
```
* Locate `<script id="__NEXT_DATA__" type="application/json">…</script>`; `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.
+22 -1
View File
@@ -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",
+22 -1
View File
@@ -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",
+22 -1
View File
@@ -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",
+22 -1
View File
@@ -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",
+22 -1
View File
@@ -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": "רק באתר",
+22 -1
View File
@@ -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": "サイト内のみ",
+22 -1
View File
@@ -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": "사이트 내 전용",
+22 -1
View File
@@ -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": "Только на Сайте",
+22 -1
View File
@@ -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": "仅站内生成",
+22 -1
View File
@@ -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": "僅站內生成",
+73 -10
View File
@@ -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,
}
+3
View File
@@ -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"),
+68 -1
View File
@@ -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]]:
+53
View File
@@ -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"""
+465 -5
View File
@@ -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()
+5
View File
@@ -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,
+257
View File
@@ -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)::
<script id="__NEXT_DATA__" type="application/json">
{"props": {"pageProps": {"trpcState": {"json": {"queries": [
{"queryKey": [["model", "getById"], {...}],
"state": {"data": {"modelVersions": [
{"id": 3379626, "paidAccess": {
"endsAt": null, "timeframeDays": null,
"terms": {"download": {"price": 5000},
"generation": {"price": 100, "trialLimit": 5}},
"sale": null}}
]}}}
]}}}}}
</script>
"""
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'<script[^>]*id="__NEXT_DATA__"[^>]*>(.*?)</script>',
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
@@ -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;
+1
View File
@@ -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`,
+27
View File
@@ -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');
@@ -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'
),
}));
}
+16
View File
@@ -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 =
+3
View File
@@ -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,
+62
View File
@@ -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') {
@@ -192,6 +192,57 @@
{{ sm.setting_toggle('hidePaidUpdates', 'hide_paid_updates', 'settings.hidePaidUpdates.label', 'settings.hidePaidUpdates.help') }}
</div>
<!-- Buzz Price Tracking -->
<div class="settings-subsection">
{{ sm.subsection_header('settings.sections.priceTracking') }}
<div class="settings-help-text subtle">{{ t('settings.priceTracking.help') }}</div>
{{ sm.setting_toggle('priceTrackingEnabled', 'price_tracking_enabled', 'settings.priceTracking.enabled', 'settings.priceTracking.enabledHelp') }}
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="priceAlertThresholdBuzz">
{{ t('settings.priceTracking.threshold') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.priceTracking.thresholdHelp') }}"></i>
</label>
</div>
<div class="setting-control">
<div class="text-input-wrapper">
<input
type="number"
id="priceAlertThresholdBuzz"
min="0"
step="1"
onblur="settingsManager.saveInputSetting('priceAlertThresholdBuzz', 'price_alert_threshold_buzz')"
onkeydown="if(event.key === 'Enter') { this.blur(); }"
/>
</div>
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="priceCheckTtlHours">
{{ t('settings.priceTracking.refreshHours') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.priceTracking.refreshHoursHelp') }}"></i>
</label>
</div>
<div class="setting-control">
<div class="text-input-wrapper">
<input
type="number"
id="priceCheckTtlHours"
min="1"
step="1"
onblur="settingsManager.saveInputSetting('priceCheckTtlHours', 'price_check_ttl_hours')"
onkeydown="if(event.key === 'Enter') { this.blur(); }"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Example Images -->
<div class="settings-subsection">
{{ sm.subsection_header('settings.sections.exampleImages') }}
+149
View File
@@ -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"
+62
View File
@@ -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 = (
'<script id="__NEXT_DATA__" type="application/json">'
'{"props":{"pageProps":{"trpcState":{"json":{"queries":['
'{"queryKey":[["model","getById"],{"input":{"id":7}}],"state":{"data":'
'{"modelVersions":[{"id":42,"paidAccess":{"endsAt":null,'
'"timeframeDays":null,"terms":{"download":{"price":5000}},'
'"sale":null}}]}}}]}}}}}</script>'
)
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, "<html><body>challenge</body></html>"
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)
+631
View File
@@ -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") == []
@@ -0,0 +1,108 @@
<!DOCTYPE html>
<!--
Trimmed stand-in for a CivitAI model page, mirroring the shape verified against
civitai.com / civitai.red. Values are synthetic; the structure (unrelated query
first, then the model.getById query, tombstones included) is what matters.
-->
<html>
<head>
<title>Fixture model</title>
</head>
<body>
<script id="__NEXT_DATA__" type="application/json">
{
"props": {
"pageProps": {
"trpcState": {
"json": {
"queries": [
{
"queryKey": [["common", "getEntityAccess"], { "entityId": 1001 }],
"state": { "data": [] }
},
{
"queryKey": [
["model", "getById"],
{ "input": { "id": 4242, "browsingLevel": 1 }, "type": "query" }
],
"state": {
"data": {
"id": 4242,
"name": "Fixture model",
"modelVersions": [
{
"id": 1001,
"name": "permanent paid",
"canDownload": false,
"paidAccess": {
"endsAt": null,
"timeframeDays": null,
"terms": {
"download": { "price": 5000 },
"generation": { "price": 100, "trialLimit": 5 }
},
"sale": null
}
},
{
"id": 1002,
"name": "timed gate on sale",
"canDownload": false,
"paidAccess": {
"endsAt": "2999-10-10T13:10:17.404Z",
"timeframeDays": 7,
"terms": {
"download": { "price": 125 },
"generation": { "trialLimit": 10 },
"acceptsBlueBuzz": true
},
"sale": {
"listTerms": { "download": { "price": 125 } },
"buyerTerms": { "download": { "price": 100 } },
"endsAt": "2999-09-01T00:00:00.000Z",
"discountType": "percent",
"discountAmount": 20
}
}
},
{
"id": 1003,
"name": "timed gate with no recorded end",
"canDownload": false,
"paidAccess": {
"endsAt": null,
"timeframeDays": 7,
"terms": { "download": { "price": 300 } },
"sale": null
}
},
{
"id": 1004,
"name": "lapsed gate (tombstone)",
"canDownload": true,
"paidAccess": {
"endsAt": "2000-08-27T16:56:57.438Z",
"timeframeDays": 3,
"terms": { "download": { "price": 200 } },
"sale": null
}
},
{
"id": 1005,
"name": "never gated",
"canDownload": true,
"paidAccess": null
}
]
}
}
}
]
}
}
}
}
}
</script>
</body>
</html>
+91
View File
@@ -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,
"",
"<html><body>no payload here</body></html>",
'<script id="__NEXT_DATA__" type="application/json">{not json</script>',
# Valid JSON, but not the shape we need.
'<script id="__NEXT_DATA__" type="application/json">{"props":{}}</script>',
# Model query present, but no versions.
'<script id="__NEXT_DATA__" type="application/json">'
'{"props":{"pageProps":{"trpcState":{"json":{"queries":['
'{"queryKey":[["model","getById"]],"state":{"data":{}}}]}}}}}</script>',
],
)
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