feat(update): add a Buzz price alerts panel to the notification bell

P5a of docs/plans/paid-model-price-tracking.md: one surface that answers "what
got cheaper / became free", without a permanent button (the grid filter was
dropped by owner decision, so the panel carries the actions itself).

- price_alert_since records when an alert started, so the panel can say
  "dropped 3 d ago" and count what is new since the user last looked; it is set
  on the first sight of an already-cheap version, preserved while the alert
  stands, and cleared when the price rises back above the threshold
- get_price_alerts() compares the threshold at read time (editing it takes
  effect immediately, no refresh needed) and returns both kinds in one list;
  model_type=None covers every type, which the shared update DB makes a single
  query. "became free" needs no price data, so it is reported even while price
  tracking is off
- GET /api/lm/price-alerts, registered once in MiscRoutes rather than per model
  type, decorating rows best-effort with the local model name and file path from
  the scanner indexes (a cold cache just omits them)
- a third tab in the notification bell: segments for under-threshold and
  became-free, the three states (tracking off / nothing matching / stale), and
  per-row actions (CivitAI always, Open when the model is local)
- two non-permanent entry points share one helper: the controls-bar updates
  dropdown and the global context menu, whose label carries the unread count
- unread state stays client-side (localStorage watermark); the count is fetched
  once on init and only when price tracking is enabled
- the per-type frontend client method is removed as dead code; the per-type
  backend route stays for the companion extension
This commit is contained in:
Will Miao
2026-10-04 15:08:48 +08:00
parent ad2402724b
commit 7ed19c185c
27 changed files with 2142 additions and 86 deletions
+222 -1
View File
@@ -5,7 +5,9 @@ model versions and flag when it drops below a threshold or becomes free.
**Related:** [#1060 — Some "Early Access" models are not identified correctly](https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1060) **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 (closed) established `is_paid`, the Paid badge and `hide_paid_updates`; this FR is the next step
after gate *state* — gate *price*. after gate *state* — gate *price*.
**Status:** v2 — **P0–P3 implemented** (see §10 for what shipped and the deviations from this plan). **Status:** v3 — **P0–P3 implemented** (§10 records what shipped and the deviations); **P5 (alerts
panel) planned** in §11, not implemented. The grid-level "price alert only" filter was dropped by
owner decision, so §11 defines the panel as the only new browsing surface.
Feasibility was verified against this repo and upstream CivitAI `main` (`6d29ed1368`), including live Feasibility was verified against this repo and upstream CivitAI `main` (`6d29ed1368`), including live
probes against `civitai.com` / `civitai.red`. probes against `civitai.com` / `civitai.red`.
**Scope:** every model type that goes through `ModelUpdateService` (lora / checkpoint / embedding / **Scope:** every model type that goes through `ModelUpdateService` (lora / checkpoint / embedding /
@@ -379,3 +381,222 @@ extension is GET-only per `AGENTS.md`):
* `tests/services/test_civitai_client.py` — page fetch happy path, unusable payloads, rate limits. * `tests/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. * `tests/routes/test_model_update_handler.py` — alerts endpoint, gate events for non-updating records.
---
## 11. P5 — Price alerts panel
**Goal:** a single surface that answers "what got cheaper / became free, and can I act on it".
**Status:** **P5a implemented** (see §11.9); P5b (inline threshold editing, *Refresh prices*, type
chips, ignore action) and P5c (polish) are not.
**Naming:** called P4a–c in earlier discussion; renumbered to **P5** because §5 already uses P4 for
"docs and upstream".
**Owner decision:** the grid-level "price alert only" filter is **out of scope**. The panel is the
only new browsing surface; the existing per-version badges and the update-check toast stay as they
are. Consequence: the panel carries the "act on it" affordances itself (§11.2), and discoverability
rests on the bell badge plus the controls-dropdown entry (§11.1 D8).
### 11.1 Locked decisions
| # | Decision | Why / evidence |
| --- | --- | --- |
| D1 | Host it as a **third tab in the existing notification bell modal**, driven by `UpdateService` | `templates/components/modals.html` is included by `templates/base.html:75`, so the bell exists on every page; `UpdateService` already has `toggleUpdateModal()`, `switchNotificationTab()`, tab badges, arrow-key tab navigation and a list/empty-state pattern (`renderRecentBanners`) |
| D2 | One **global** endpoint `GET /api/lm/price-alerts`, registered **once** in `MiscRoutes` | The update DB is one file per library shared by all model types (`cache/model_update/<library>.sqlite`), and `ServiceRegistry.get_model_update_service()` returns one shared instance (`py/services/service_registry.py:153`). `get_price_alerts` only filters on `s.model_type`, so `model_type=None` is all types in one query. Adding it to `COMMON_ROUTE_DEFINITIONS` would bind the same path once per model type (4×) |
| D3 | **Compare the threshold at read time** (`WHERE v.price_buzz <= ?`); keep `price_alert_state` for the toast edge only | As shipped, panel membership only changes after a refresh, so editing the threshold in the panel would look broken |
| D4 | New column `price_alert_since REAL` | `price_alert_state` is a boolean; "dropped 3 days ago" and an unread count both need the moment the state flipped, which the existing edge detection already knows |
| D5 | Unread state is **client-side** (`localStorage` watermark compared against `price_alert_since`) | No per-user read-state table and no sync logic; the worst case is a conservative badge on a second browser |
| D6 | Row actions: **CivitAI** always; **Open** only when the payload resolved a local file path | `showModelModal(model, modelType)` needs a local metadata object and re-fetches by `file_path` (`static/js/components/shared/ModelModal.js:343-361`); a gated version the user does not own has no local file |
| D7 | **Refresh prices** must bypass the price TTL | Prices stay fresh for `price_check_ttl_hours` (24 h by default), so without a force flag the button would appear to do nothing |
| D8 | **Two non-permanent entry points**, both opening the bell on the Price alerts tab: the **updates dropdown** in the controls bar, and the **global context menu** (right-click on empty space) | The controls dropdown already hosts `checkUpdatesMenuItem` (`templates/components/controls.html:114-126`) and costs no layout space. The global menu is the established home for library-wide occasional actions and already holds the sibling `check-model-updates` (`templates/components/context_menu.html:185`); it has only 7 items and existing hide/separator machinery (`GlobalContextMenu.showMenu` / `_updateSeparatorVisibility`). The **model-card menu is deliberately left alone** — it already has 20 items, and P5b's *Refresh prices* covers the per-model case |
| D9 | Bell badge shows the **unread alert count**; when there are none it keeps today's behaviour (dot for app updates). The same cached count is appended to the global context menu item (`Price alerts (3)`) | Otherwise a price drop is invisible until the user opens the bell. The count is fetched once on init **only when `price_tracking_enabled`**, so users who never enable the feature pay nothing |
| D10 | The global context menu item is **always visible** on model pages (hidden on recipes, like its siblings) | The panel's disabled state is the explanation plus a deep link into Settings; hiding the entry would make the feature undiscoverable for exactly the users who have not enabled it yet |
### 11.2 Information architecture
```
┌ Price alerts [Refresh prices] ┐
│ Threshold: 500 Buzz (click to edit) │
│ [ Under threshold ] [ Became free ] Type: All · LoRA · … │
├──────────────────────────────────────────────────────────────────────┤
│ ▸ Glorious Art · Checkpoint · 2 versions │
│ Alpha · 250 Buzz (was 500) · Blue Buzz OK │
│ EA until Oct 10 · Not in library · dropped 3 days ago │
│ [CivitAI] [Open] [Ignore] │
│ ▸ Eira Kishida · LoRA · 1 version │
│ 125 Buzz · In library · dropped today [CivitAI] [Open] │
└──────────────────────────────────────────────────────────────────────┘
```
* Segments cover both halves of the FR: `Under threshold` (price at or below the threshold) and
`Became free` (versions with `gate_lapsed_at`, no price).
* Grouped by model (a model often has several versions at the same price), sorted cheapest first
then most recently crossed; the type chips filter client-side over the loaded list.
* Row: effective price with the list price struck through when on sale, Blue Buzz note, EA end date,
in-library marker, and "dropped X ago" from `price_alert_since`.
* Actions: open on CivitAI, open the local model modal (only when a file path was resolved), ignore
this version (reuses `setVersionUpdateIgnore`).
### 11.3 Data contract
```
GET /api/lm/price-alerts?limit=200
{
"success": true,
"enabled": true, // price_tracking_enabled
"thresholdBuzz": 500,
"newestCheckedAt": 1791039694.5, // staleness copy ("prices last checked X ago")
"alerts": [
{
"modelId": 2981320, "modelType": "checkpoint",
"modelName": "Glorious Art", // best-effort, from the scanner cache
"versionId": 3379626, "versionName": "Alpha",
"kind": "below_threshold", // or "became_free"
"priceBuzz": 250, "listPriceBuzz": 500,
"acceptsBlueBuzz": true, "priceSaleEndsAt": null,
"priceAlertSince": 1791000000.0,
"gateLapsedAt": null,
"earlyAccessEndsAt": null, "isPaid": true, "isEarlyAccess": false,
"isInLibrary": false,
"filePath": null, // set only when resolvable
"civitaiUrl": "https://civitai.com/models/2981320?modelVersionId=3379626"
}
]
}
```
One list with a `kind` discriminator (not two lists) so "All" needs no second request. `civitaiUrl`
is built server-side with the existing `build_civitai_model_page_url` so `civitai_host` stays the
single source of truth.
### 11.4 States (all three must be designed, not just the happy path)
1. **Tracking off** — explain that CivitAI publishes no price in its public API and that the feature
reads the model page, plus a button that opens Settings → Library.
2. **On, nothing matching** — "nothing under N Buzz right now", with the threshold editable inline.
3. **Stale / offline / rate-limited** — keep the last known list, add a "prices last checked X ago"
line and a retry; never blank the panel.
### 11.5 Tasks
**P5a — panel usable end to end**
1. `py/services/model_update_service.py`
* `get_price_alerts(model_type=None, *, threshold_buzz, limit=200)` — optional type filter, live
threshold comparison, `kind`, `price_alert_since`; keep the shipped per-type call site working.
* `price_alert_since` column through all eight enumerations (§4.1), set on the `0 → 1` edge and
cleared on `1 → 0` inside `_build_record_from_remote`.
2. `py/routes/misc_route_registrar.py` + `py/routes/handlers/misc_handlers.py` — register
`GET /api/lm/price-alerts` **once**, using `ServiceRegistry.get_model_update_service()` and the
settings service; resolve `modelName` / `filePath` best-effort per model type from the scanner
caches (`version_index[version_id]` → `file_path`, `file_name`; item `model_name` with a
`file_name` fallback), and omit them when resolution fails.
3. `templates/components/modals/update_modal.html` — third tab + panel skeleton (segments, threshold
row, list container, empty/disabled blocks), reusing `data-notification-tab` /
`data-notification-panel`.
4. `static/js/managers/UpdateService.js` — `renderPriceAlerts()`, panel fetch on open, tab badge
count, threshold display, row rendering and actions, `localStorage` watermark; extend
`updateTabBadges()` and `switchNotificationTab()`.
5. `static/js/api/apiConfig.js` + a small fetch helper — the endpoint is global, so it does not
belong in the per-model-type `endpoints` map.
6. `templates/components/controls.html` + `static/js/components/controls/PageControls.js` — the
controls-dropdown item (D8).
7. `templates/components/context_menu.html` + `static/js/components/ContextMenu/GlobalContextMenu.js`
— the global-context-menu item (D8): one template entry, the recipes-page hide list in
`showMenu()`, one `case` in `handleMenuAction`, and the optional `(N)` count from the cached
alert count. Both entries call one shared `openPriceAlertsPanel()` helper.
8. `locales/en.json` + `python scripts/sync_translation_keys.py`.
*Acceptance:* with tracking on and one refresh done, the bell shows a count, the tab lists the
versions with prices, CivitAI opens the right page, Open appears only for in-library models and opens
the modal, all three states render, and it works from every page type.
**P5b — actions and polish**
* Inline threshold editing (read-time comparison makes it instant) and **Refresh prices** with a
`force_price_refresh` flag on the existing refresh endpoint (or a dedicated
`POST /api/lm/price-alerts/refresh`).
* Type chips and a "only versions I do not own" toggle (this is open question 2 from §9).
* Ignore-this-version action.
**P5c — optional**
* Thumbnails from the scanner cache, "unread only", focus-management pass on the new tab, and a
deep link from the panel into the settings section.
### 11.6 Tests
* `tests/services/test_model_update_service.py` — `model_type=None` spans types; live threshold
parameter; `price_alert_since` set/cleared on the edge.
* `tests/routes/` — the global route is registered exactly once and returns the documented shape;
`filePath` resolution is best-effort (mock scanner; omit on failure).
* `tests/frontend/` — vitest for `renderPriceAlerts` covering rows, both segments, the three states
and the badge count with a mocked fetch.
* Manual eyeball for layout, per the repo's UI verification policy.
### 11.7 Risks
| Risk | Mitigation |
| --- | --- |
| The bell modal is nominally about app updates; a third tab changes its character | Clear labelling, and only count/badge when the feature is on; the app-update dot behaviour is unchanged when there are no alerts |
| Resolving local file paths pulls scanner caches into an app-wide route | Best-effort with `try/except`, one `get_cached_data()` per type (in-memory), omit the field and hide the Open button when it fails |
| `price_alert_since` is another migration | Same eight-touchpoint discipline as §4.1, covered by the existing migration test |
| A global route in `COMMON_ROUTE_DEFINITIONS` would bind 4× | Register it in `MiscRoutes` instead (D2) |
| Menu bloat / entry sprawl | Two entries maximum (D8), both calling one helper; the 20-item model-card menu is explicitly untouched |
| Panel data goes stale between refreshes | Show `newestCheckedAt`; the refresh action (P5b) makes it explicit rather than silent |
### 11.8 Open questions for the owner
1. **Badge policy** — count unread alerts on the bell (recommended, D9), or leave the bell alone and
put the number only on the tab?
2. **Unread at all** — the `localStorage` watermark (recommended, D5), or simply "current matches"
with no read state?
3. **Threshold editing inside the panel** — convenient, but it silently changes a global setting from
a surface the user opened just to look.
4. **"Open in library"** — worth the scanner-cache coupling (D6), or should the row offer only
"CivitAI" plus a copy-link action?
5. **Thumbnails** — skip for weight (recommended) or show them?
6. **Entry visibility when tracking is off** — always show the global-context-menu item
(recommended, D10: the panel's disabled state educates and deep-links to Settings), or hide it
until the feature is enabled for a cleaner menu?
7. **Count in the menu label** — `Price alerts (3)` using the cached count (recommended, D9), or a
plain label with the number only on the bell/tab?
### 11.9 What shipped (P5a)
Answers to §11.8: badge counts unread alerts (D9), unread uses the `localStorage` watermark (D5),
threshold editing is **display-only in P5a** (editing is P5b), "Open in library" ships with the
best-effort path resolution (D6), no thumbnails, the context-menu item is always visible (D10), and
the menu label carries the count (D9).
| Area | Where |
| --- | --- |
| `price_alert_since` column through all eight enumerations, set on the `0 → 1` edge, preserved while the alert stands, cleared when the price rises | `py/services/model_update_service.py` |
| `get_price_alerts(model_type=None, *, threshold_buzz, limit)` — read-time threshold, `kind`, one list across types; `newest_price_checked_at()` | `py/services/model_update_service.py` |
| Global endpoint `GET /api/lm/price-alerts`, registered once | `py/routes/misc_route_registrar.py`, `py/routes/misc_routes.py`, `PriceAlertsHandler` in `py/routes/handlers/misc_handlers.py` |
| Third bell tab, panel skeleton, segments, three states | `templates/components/modals/update_modal.html`, `static/css/components/modal/update-modal.css` |
| Loader, renderer, unread watermark, badge, `openPriceAlertsPanel()` | `static/js/managers/UpdateService.js` |
| Entry points (controls dropdown + global context menu, count in the label) | `templates/components/controls.html`, `static/js/components/controls/PageControls.js`, `templates/components/context_menu.html`, `static/js/components/ContextMenu/GlobalContextMenu.js` |
Deviations and decisions made while implementing:
* **`became_free` is reported even while price tracking is off.** It needs no price data, so the
panel shows the "tracking is off" explanation *and* whatever became free instead of hiding the
half of the feature that already works.
* **`price_alert_since` is also set on first sight** of an already-cheap version. The toast stays
silent (edge-triggered, §10.3), but the count is non-zero, which is what invites the user into the
panel after enabling the feature.
* **The per-type frontend client method was removed** (`baseModelApi.getPriceAlerts` and the
`priceAlerts` entry in `apiConfig.js`): with the global endpoint it was dead code. The per-type
**backend** route stays for the companion extension and a possible future grid filter.
* **`openPriceAlertsTab()` exists because `toggleUpdateModal()` closes an open bell** — an entry
point calling it unconditionally would dismiss the modal instead of switching tabs.
* **Local context uses the existing indexes** (`cache.model_id_index` for `model_name`,
`cache.version_index` for `file_path`/`file_name`), so it is O(1) per row; every failure just
omits the fields and the row loses its "Open" button.
Verification: `pytest` 3652 passed / 7 skipped, `npm run test:js` 1444 passed, plus a sandboxed
standalone server run that seeded the update DB and confirmed the payload shape, the `kind`
split, the `civitaiUrl`, and that changing `price_alert_threshold_buzz` through `POST /api/lm/settings`
changes panel membership immediately with no refresh.
+34 -2
View File
@@ -234,6 +234,9 @@
}, },
"groupByModel": { "groupByModel": {
"label": "Nach Modell gruppieren" "label": "Nach Modell gruppieren"
},
"priceAlerts": {
"label": "[TODO: Translate] Price alerts"
} }
}, },
"header": { "header": {
@@ -942,7 +945,9 @@
"action": "Updates", "action": "Updates",
"menuLabel": "Weitere Update-Optionen anzeigen", "menuLabel": "Weitere Update-Optionen anzeigen",
"check": "Updates prüfen", "check": "Updates prüfen",
"checkTooltip": "Die Aktualisierungssuche kann einige Zeit dauern." "checkTooltip": "Die Aktualisierungssuche kann einige Zeit dauern.",
"priceAlerts": "[TODO: Translate] Price alerts",
"priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions"
} }
}, },
"bulkOperations": { "bulkOperations": {
@@ -2321,7 +2326,8 @@
"notificationsTitle": "Benachrichtigungszentrum", "notificationsTitle": "Benachrichtigungszentrum",
"tabs": { "tabs": {
"updates": "Aktualisierungen", "updates": "Aktualisierungen",
"messages": "Mitteilungen" "messages": "Mitteilungen",
"priceAlerts": "[TODO: Translate] Price Alerts"
}, },
"updateAvailable": "Update verfügbar", "updateAvailable": "Update verfügbar",
"noChangelogAvailable": "Kein detailliertes Changelog verfügbar. Weitere Informationen auf GitHub.", "noChangelogAvailable": "Kein detailliertes Changelog verfügbar. Weitere Informationen auf GitHub.",
@@ -2377,6 +2383,32 @@
"shown": "{time} angezeigt", "shown": "{time} angezeigt",
"dismissed": "{time} geschlossen", "dismissed": "{time} geschlossen",
"active": "Aktiv" "active": "Aktiv"
},
"priceAlerts": {
"disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.",
"openSettings": "[TODO: Translate] Open settings",
"segmentsLabel": "[TODO: Translate] Alert kind",
"segments": {
"belowThreshold": "[TODO: Translate] Under threshold",
"becameFree": "[TODO: Translate] Became free"
},
"thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz",
"empty": "[TODO: Translate] Nothing is under your price threshold right now.",
"emptyFree": "[TODO: Translate] No version has become free yet.",
"loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.",
"lastChecked": "[TODO: Translate] Prices last checked {when}",
"justNow": "[TODO: Translate] just now",
"minutesAgo": "[TODO: Translate] {count} min ago",
"hoursAgo": "[TODO: Translate] {count} h ago",
"daysAgo": "[TODO: Translate] {count} d ago",
"freeNow": "[TODO: Translate] Free now",
"blueBuzz": "[TODO: Translate] Blue Buzz OK",
"earlyAccessUntil": "[TODO: Translate] Early access until {date}",
"inLibrary": "[TODO: Translate] In library",
"notInLibrary": "[TODO: Translate] Not in library",
"droppedAgo": "[TODO: Translate] dropped {when}",
"openCivitai": "[TODO: Translate] Open on CivitAI",
"openLocal": "[TODO: Translate] Open"
} }
}, },
"support": { "support": {
+34 -2
View File
@@ -234,6 +234,9 @@
}, },
"groupByModel": { "groupByModel": {
"label": "Group by Model" "label": "Group by Model"
},
"priceAlerts": {
"label": "Price alerts"
} }
}, },
"header": { "header": {
@@ -942,7 +945,9 @@
"action": "Updates", "action": "Updates",
"menuLabel": "Show update options", "menuLabel": "Show update options",
"check": "Check updates", "check": "Check updates",
"checkTooltip": "Checking updates may take a while." "checkTooltip": "Checking updates may take a while.",
"priceAlerts": "Price alerts",
"priceAlertsTooltip": "Buzz price alerts for paid versions"
} }
}, },
"bulkOperations": { "bulkOperations": {
@@ -2321,7 +2326,8 @@
"notificationsTitle": "Notifications", "notificationsTitle": "Notifications",
"tabs": { "tabs": {
"updates": "Updates", "updates": "Updates",
"messages": "Messages" "messages": "Messages",
"priceAlerts": "Price Alerts"
}, },
"updateAvailable": "Update Available", "updateAvailable": "Update Available",
"noChangelogAvailable": "No detailed changelog available. Check GitHub for more information.", "noChangelogAvailable": "No detailed changelog available. Check GitHub for more information.",
@@ -2377,6 +2383,32 @@
"shown": "Shown {time}", "shown": "Shown {time}",
"dismissed": "Dismissed {time}", "dismissed": "Dismissed {time}",
"active": "Active" "active": "Active"
},
"priceAlerts": {
"disabled": "CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.",
"openSettings": "Open settings",
"segmentsLabel": "Alert kind",
"segments": {
"belowThreshold": "Under threshold",
"becameFree": "Became free"
},
"thresholdLabel": "Alert threshold: {buzz} Buzz",
"empty": "Nothing is under your price threshold right now.",
"emptyFree": "No version has become free yet.",
"loadFailed": "Could not refresh the alerts; showing the last known list.",
"lastChecked": "Prices last checked {when}",
"justNow": "just now",
"minutesAgo": "{count} min ago",
"hoursAgo": "{count} h ago",
"daysAgo": "{count} d ago",
"freeNow": "Free now",
"blueBuzz": "Blue Buzz OK",
"earlyAccessUntil": "Early access until {date}",
"inLibrary": "In library",
"notInLibrary": "Not in library",
"droppedAgo": "dropped {when}",
"openCivitai": "Open on CivitAI",
"openLocal": "Open"
} }
}, },
"support": { "support": {
+34 -2
View File
@@ -234,6 +234,9 @@
}, },
"groupByModel": { "groupByModel": {
"label": "Agrupar por modelo" "label": "Agrupar por modelo"
},
"priceAlerts": {
"label": "[TODO: Translate] Price alerts"
} }
}, },
"header": { "header": {
@@ -942,7 +945,9 @@
"action": "Actualizaciones", "action": "Actualizaciones",
"menuLabel": "Mostrar opciones de actualización", "menuLabel": "Mostrar opciones de actualización",
"check": "Buscar actualizaciones", "check": "Buscar actualizaciones",
"checkTooltip": "Comprobar actualizaciones puede tardar." "checkTooltip": "Comprobar actualizaciones puede tardar.",
"priceAlerts": "[TODO: Translate] Price alerts",
"priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions"
} }
}, },
"bulkOperations": { "bulkOperations": {
@@ -2321,7 +2326,8 @@
"notificationsTitle": "Centro de notificaciones", "notificationsTitle": "Centro de notificaciones",
"tabs": { "tabs": {
"updates": "Actualizaciones", "updates": "Actualizaciones",
"messages": "Mensajes" "messages": "Mensajes",
"priceAlerts": "[TODO: Translate] Price Alerts"
}, },
"updateAvailable": "Actualización disponible", "updateAvailable": "Actualización disponible",
"noChangelogAvailable": "No hay registro de cambios detallado disponible. Revisa GitHub para más información.", "noChangelogAvailable": "No hay registro de cambios detallado disponible. Revisa GitHub para más información.",
@@ -2377,6 +2383,32 @@
"shown": "Mostrado {time}", "shown": "Mostrado {time}",
"dismissed": "Descartado {time}", "dismissed": "Descartado {time}",
"active": "Activo" "active": "Activo"
},
"priceAlerts": {
"disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.",
"openSettings": "[TODO: Translate] Open settings",
"segmentsLabel": "[TODO: Translate] Alert kind",
"segments": {
"belowThreshold": "[TODO: Translate] Under threshold",
"becameFree": "[TODO: Translate] Became free"
},
"thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz",
"empty": "[TODO: Translate] Nothing is under your price threshold right now.",
"emptyFree": "[TODO: Translate] No version has become free yet.",
"loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.",
"lastChecked": "[TODO: Translate] Prices last checked {when}",
"justNow": "[TODO: Translate] just now",
"minutesAgo": "[TODO: Translate] {count} min ago",
"hoursAgo": "[TODO: Translate] {count} h ago",
"daysAgo": "[TODO: Translate] {count} d ago",
"freeNow": "[TODO: Translate] Free now",
"blueBuzz": "[TODO: Translate] Blue Buzz OK",
"earlyAccessUntil": "[TODO: Translate] Early access until {date}",
"inLibrary": "[TODO: Translate] In library",
"notInLibrary": "[TODO: Translate] Not in library",
"droppedAgo": "[TODO: Translate] dropped {when}",
"openCivitai": "[TODO: Translate] Open on CivitAI",
"openLocal": "[TODO: Translate] Open"
} }
}, },
"support": { "support": {
+34 -2
View File
@@ -234,6 +234,9 @@
}, },
"groupByModel": { "groupByModel": {
"label": "Grouper par modèle" "label": "Grouper par modèle"
},
"priceAlerts": {
"label": "[TODO: Translate] Price alerts"
} }
}, },
"header": { "header": {
@@ -942,7 +945,9 @@
"action": "Mises à jour", "action": "Mises à jour",
"menuLabel": "Afficher les options de mise à jour", "menuLabel": "Afficher les options de mise à jour",
"check": "Rechercher des mises à jour", "check": "Rechercher des mises à jour",
"checkTooltip": "La vérification peut prendre du temps." "checkTooltip": "La vérification peut prendre du temps.",
"priceAlerts": "[TODO: Translate] Price alerts",
"priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions"
} }
}, },
"bulkOperations": { "bulkOperations": {
@@ -2321,7 +2326,8 @@
"notificationsTitle": "Notifications", "notificationsTitle": "Notifications",
"tabs": { "tabs": {
"updates": "Mises à jour", "updates": "Mises à jour",
"messages": "Messages" "messages": "Messages",
"priceAlerts": "[TODO: Translate] Price Alerts"
}, },
"updateAvailable": "Mise à jour disponible", "updateAvailable": "Mise à jour disponible",
"noChangelogAvailable": "Aucun journal des modifications détaillé disponible. Consultez GitHub pour plus d'informations.", "noChangelogAvailable": "Aucun journal des modifications détaillé disponible. Consultez GitHub pour plus d'informations.",
@@ -2377,6 +2383,32 @@
"shown": "Affiché {time}", "shown": "Affiché {time}",
"dismissed": "Ignoré {time}", "dismissed": "Ignoré {time}",
"active": "Actif" "active": "Actif"
},
"priceAlerts": {
"disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.",
"openSettings": "[TODO: Translate] Open settings",
"segmentsLabel": "[TODO: Translate] Alert kind",
"segments": {
"belowThreshold": "[TODO: Translate] Under threshold",
"becameFree": "[TODO: Translate] Became free"
},
"thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz",
"empty": "[TODO: Translate] Nothing is under your price threshold right now.",
"emptyFree": "[TODO: Translate] No version has become free yet.",
"loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.",
"lastChecked": "[TODO: Translate] Prices last checked {when}",
"justNow": "[TODO: Translate] just now",
"minutesAgo": "[TODO: Translate] {count} min ago",
"hoursAgo": "[TODO: Translate] {count} h ago",
"daysAgo": "[TODO: Translate] {count} d ago",
"freeNow": "[TODO: Translate] Free now",
"blueBuzz": "[TODO: Translate] Blue Buzz OK",
"earlyAccessUntil": "[TODO: Translate] Early access until {date}",
"inLibrary": "[TODO: Translate] In library",
"notInLibrary": "[TODO: Translate] Not in library",
"droppedAgo": "[TODO: Translate] dropped {when}",
"openCivitai": "[TODO: Translate] Open on CivitAI",
"openLocal": "[TODO: Translate] Open"
} }
}, },
"support": { "support": {
+34 -2
View File
@@ -234,6 +234,9 @@
}, },
"groupByModel": { "groupByModel": {
"label": "קיבוץ לפי מודל" "label": "קיבוץ לפי מודל"
},
"priceAlerts": {
"label": "[TODO: Translate] Price alerts"
} }
}, },
"header": { "header": {
@@ -942,7 +945,9 @@
"action": "עדכונים", "action": "עדכונים",
"menuLabel": "הצגת אפשרויות עדכון", "menuLabel": "הצגת אפשרויות עדכון",
"check": "בדוק עדכונים", "check": "בדוק עדכונים",
"checkTooltip": "בדיקת עדכונים עלולה לקחת זמן." "checkTooltip": "בדיקת עדכונים עלולה לקחת זמן.",
"priceAlerts": "[TODO: Translate] Price alerts",
"priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions"
} }
}, },
"bulkOperations": { "bulkOperations": {
@@ -2321,7 +2326,8 @@
"notificationsTitle": "מרכז התראות", "notificationsTitle": "מרכז התראות",
"tabs": { "tabs": {
"updates": "עדכונים", "updates": "עדכונים",
"messages": "הודעות" "messages": "הודעות",
"priceAlerts": "[TODO: Translate] Price Alerts"
}, },
"updateAvailable": "עדכון זמין", "updateAvailable": "עדכון זמין",
"noChangelogAvailable": "אין יומן שינויים מפורט זמין. בדוק ב-GitHub למידע נוסף.", "noChangelogAvailable": "אין יומן שינויים מפורט זמין. בדוק ב-GitHub למידע נוסף.",
@@ -2377,6 +2383,32 @@
"shown": "הוצג {time}", "shown": "הוצג {time}",
"dismissed": "הוסר {time}", "dismissed": "הוסר {time}",
"active": "פעיל" "active": "פעיל"
},
"priceAlerts": {
"disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.",
"openSettings": "[TODO: Translate] Open settings",
"segmentsLabel": "[TODO: Translate] Alert kind",
"segments": {
"belowThreshold": "[TODO: Translate] Under threshold",
"becameFree": "[TODO: Translate] Became free"
},
"thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz",
"empty": "[TODO: Translate] Nothing is under your price threshold right now.",
"emptyFree": "[TODO: Translate] No version has become free yet.",
"loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.",
"lastChecked": "[TODO: Translate] Prices last checked {when}",
"justNow": "[TODO: Translate] just now",
"minutesAgo": "[TODO: Translate] {count} min ago",
"hoursAgo": "[TODO: Translate] {count} h ago",
"daysAgo": "[TODO: Translate] {count} d ago",
"freeNow": "[TODO: Translate] Free now",
"blueBuzz": "[TODO: Translate] Blue Buzz OK",
"earlyAccessUntil": "[TODO: Translate] Early access until {date}",
"inLibrary": "[TODO: Translate] In library",
"notInLibrary": "[TODO: Translate] Not in library",
"droppedAgo": "[TODO: Translate] dropped {when}",
"openCivitai": "[TODO: Translate] Open on CivitAI",
"openLocal": "[TODO: Translate] Open"
} }
}, },
"support": { "support": {
+34 -2
View File
@@ -234,6 +234,9 @@
}, },
"groupByModel": { "groupByModel": {
"label": "モデルでグループ化" "label": "モデルでグループ化"
},
"priceAlerts": {
"label": "[TODO: Translate] Price alerts"
} }
}, },
"header": { "header": {
@@ -942,7 +945,9 @@
"action": "アップデート", "action": "アップデート",
"menuLabel": "更新オプションを表示", "menuLabel": "更新オプションを表示",
"check": "アップデートを確認", "check": "アップデートを確認",
"checkTooltip": "確認には時間がかかる場合があります。" "checkTooltip": "確認には時間がかかる場合があります。",
"priceAlerts": "[TODO: Translate] Price alerts",
"priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions"
} }
}, },
"bulkOperations": { "bulkOperations": {
@@ -2321,7 +2326,8 @@
"notificationsTitle": "通知センター", "notificationsTitle": "通知センター",
"tabs": { "tabs": {
"updates": "更新", "updates": "更新",
"messages": "メッセージ" "messages": "メッセージ",
"priceAlerts": "[TODO: Translate] Price Alerts"
}, },
"updateAvailable": "更新が利用可能", "updateAvailable": "更新が利用可能",
"noChangelogAvailable": "詳細な変更ログは利用できません。詳細はGitHubでご確認ください。", "noChangelogAvailable": "詳細な変更ログは利用できません。詳細はGitHubでご確認ください。",
@@ -2377,6 +2383,32 @@
"shown": "{time} に表示", "shown": "{time} に表示",
"dismissed": "{time} に非表示", "dismissed": "{time} に非表示",
"active": "アクティブ" "active": "アクティブ"
},
"priceAlerts": {
"disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.",
"openSettings": "[TODO: Translate] Open settings",
"segmentsLabel": "[TODO: Translate] Alert kind",
"segments": {
"belowThreshold": "[TODO: Translate] Under threshold",
"becameFree": "[TODO: Translate] Became free"
},
"thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz",
"empty": "[TODO: Translate] Nothing is under your price threshold right now.",
"emptyFree": "[TODO: Translate] No version has become free yet.",
"loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.",
"lastChecked": "[TODO: Translate] Prices last checked {when}",
"justNow": "[TODO: Translate] just now",
"minutesAgo": "[TODO: Translate] {count} min ago",
"hoursAgo": "[TODO: Translate] {count} h ago",
"daysAgo": "[TODO: Translate] {count} d ago",
"freeNow": "[TODO: Translate] Free now",
"blueBuzz": "[TODO: Translate] Blue Buzz OK",
"earlyAccessUntil": "[TODO: Translate] Early access until {date}",
"inLibrary": "[TODO: Translate] In library",
"notInLibrary": "[TODO: Translate] Not in library",
"droppedAgo": "[TODO: Translate] dropped {when}",
"openCivitai": "[TODO: Translate] Open on CivitAI",
"openLocal": "[TODO: Translate] Open"
} }
}, },
"support": { "support": {
+34 -2
View File
@@ -234,6 +234,9 @@
}, },
"groupByModel": { "groupByModel": {
"label": "모델별 그룹화" "label": "모델별 그룹화"
},
"priceAlerts": {
"label": "[TODO: Translate] Price alerts"
} }
}, },
"header": { "header": {
@@ -942,7 +945,9 @@
"action": "업데이트", "action": "업데이트",
"menuLabel": "업데이트 옵션 표시", "menuLabel": "업데이트 옵션 표시",
"check": "업데이트 확인", "check": "업데이트 확인",
"checkTooltip": "업데이트 확인에는 시간이 걸릴 수 있습니다." "checkTooltip": "업데이트 확인에는 시간이 걸릴 수 있습니다.",
"priceAlerts": "[TODO: Translate] Price alerts",
"priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions"
} }
}, },
"bulkOperations": { "bulkOperations": {
@@ -2321,7 +2326,8 @@
"notificationsTitle": "알림 센터", "notificationsTitle": "알림 센터",
"tabs": { "tabs": {
"updates": "업데이트", "updates": "업데이트",
"messages": "메시지" "messages": "메시지",
"priceAlerts": "[TODO: Translate] Price Alerts"
}, },
"updateAvailable": "업데이트 사용 가능", "updateAvailable": "업데이트 사용 가능",
"noChangelogAvailable": "상세한 변경 로그가 없습니다. 더 많은 정보는 GitHub를 확인하세요.", "noChangelogAvailable": "상세한 변경 로그가 없습니다. 더 많은 정보는 GitHub를 확인하세요.",
@@ -2377,6 +2383,32 @@
"shown": "{time}에 표시", "shown": "{time}에 표시",
"dismissed": "{time}에 닫힘", "dismissed": "{time}에 닫힘",
"active": "활성" "active": "활성"
},
"priceAlerts": {
"disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.",
"openSettings": "[TODO: Translate] Open settings",
"segmentsLabel": "[TODO: Translate] Alert kind",
"segments": {
"belowThreshold": "[TODO: Translate] Under threshold",
"becameFree": "[TODO: Translate] Became free"
},
"thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz",
"empty": "[TODO: Translate] Nothing is under your price threshold right now.",
"emptyFree": "[TODO: Translate] No version has become free yet.",
"loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.",
"lastChecked": "[TODO: Translate] Prices last checked {when}",
"justNow": "[TODO: Translate] just now",
"minutesAgo": "[TODO: Translate] {count} min ago",
"hoursAgo": "[TODO: Translate] {count} h ago",
"daysAgo": "[TODO: Translate] {count} d ago",
"freeNow": "[TODO: Translate] Free now",
"blueBuzz": "[TODO: Translate] Blue Buzz OK",
"earlyAccessUntil": "[TODO: Translate] Early access until {date}",
"inLibrary": "[TODO: Translate] In library",
"notInLibrary": "[TODO: Translate] Not in library",
"droppedAgo": "[TODO: Translate] dropped {when}",
"openCivitai": "[TODO: Translate] Open on CivitAI",
"openLocal": "[TODO: Translate] Open"
} }
}, },
"support": { "support": {
+34 -2
View File
@@ -234,6 +234,9 @@
}, },
"groupByModel": { "groupByModel": {
"label": "Группировать по модели" "label": "Группировать по модели"
},
"priceAlerts": {
"label": "[TODO: Translate] Price alerts"
} }
}, },
"header": { "header": {
@@ -942,7 +945,9 @@
"action": "Обновления", "action": "Обновления",
"menuLabel": "Показать параметры обновления", "menuLabel": "Показать параметры обновления",
"check": "Проверить обновления", "check": "Проверить обновления",
"checkTooltip": "Проверка может занять время." "checkTooltip": "Проверка может занять время.",
"priceAlerts": "[TODO: Translate] Price alerts",
"priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions"
} }
}, },
"bulkOperations": { "bulkOperations": {
@@ -2321,7 +2326,8 @@
"notificationsTitle": "Центр уведомлений", "notificationsTitle": "Центр уведомлений",
"tabs": { "tabs": {
"updates": "Обновления", "updates": "Обновления",
"messages": "Сообщения" "messages": "Сообщения",
"priceAlerts": "[TODO: Translate] Price Alerts"
}, },
"updateAvailable": "Доступно обновление", "updateAvailable": "Доступно обновление",
"noChangelogAvailable": "Подробный список изменений недоступен. Проверьте GitHub для получения дополнительной информации.", "noChangelogAvailable": "Подробный список изменений недоступен. Проверьте GitHub для получения дополнительной информации.",
@@ -2377,6 +2383,32 @@
"shown": "Показано {time}", "shown": "Показано {time}",
"dismissed": "Закрыто {time}", "dismissed": "Закрыто {time}",
"active": "Активно" "active": "Активно"
},
"priceAlerts": {
"disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.",
"openSettings": "[TODO: Translate] Open settings",
"segmentsLabel": "[TODO: Translate] Alert kind",
"segments": {
"belowThreshold": "[TODO: Translate] Under threshold",
"becameFree": "[TODO: Translate] Became free"
},
"thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz",
"empty": "[TODO: Translate] Nothing is under your price threshold right now.",
"emptyFree": "[TODO: Translate] No version has become free yet.",
"loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.",
"lastChecked": "[TODO: Translate] Prices last checked {when}",
"justNow": "[TODO: Translate] just now",
"minutesAgo": "[TODO: Translate] {count} min ago",
"hoursAgo": "[TODO: Translate] {count} h ago",
"daysAgo": "[TODO: Translate] {count} d ago",
"freeNow": "[TODO: Translate] Free now",
"blueBuzz": "[TODO: Translate] Blue Buzz OK",
"earlyAccessUntil": "[TODO: Translate] Early access until {date}",
"inLibrary": "[TODO: Translate] In library",
"notInLibrary": "[TODO: Translate] Not in library",
"droppedAgo": "[TODO: Translate] dropped {when}",
"openCivitai": "[TODO: Translate] Open on CivitAI",
"openLocal": "[TODO: Translate] Open"
} }
}, },
"support": { "support": {
+34 -2
View File
@@ -234,6 +234,9 @@
}, },
"groupByModel": { "groupByModel": {
"label": "按模型分组" "label": "按模型分组"
},
"priceAlerts": {
"label": "[TODO: Translate] Price alerts"
} }
}, },
"header": { "header": {
@@ -942,7 +945,9 @@
"action": "更新", "action": "更新",
"menuLabel": "显示更新选项", "menuLabel": "显示更新选项",
"check": "检查更新", "check": "检查更新",
"checkTooltip": "检查更新可能耗时。" "checkTooltip": "检查更新可能耗时。",
"priceAlerts": "[TODO: Translate] Price alerts",
"priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions"
} }
}, },
"bulkOperations": { "bulkOperations": {
@@ -2321,7 +2326,8 @@
"notificationsTitle": "通知中心", "notificationsTitle": "通知中心",
"tabs": { "tabs": {
"updates": "更新", "updates": "更新",
"messages": "消息" "messages": "消息",
"priceAlerts": "[TODO: Translate] Price Alerts"
}, },
"updateAvailable": "更新可用", "updateAvailable": "更新可用",
"noChangelogAvailable": "没有详细的更新日志可用。请查看 GitHub 以获取更多信息。", "noChangelogAvailable": "没有详细的更新日志可用。请查看 GitHub 以获取更多信息。",
@@ -2377,6 +2383,32 @@
"shown": "{time} 显示", "shown": "{time} 显示",
"dismissed": "{time} 关闭", "dismissed": "{time} 关闭",
"active": "仍在显示" "active": "仍在显示"
},
"priceAlerts": {
"disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.",
"openSettings": "[TODO: Translate] Open settings",
"segmentsLabel": "[TODO: Translate] Alert kind",
"segments": {
"belowThreshold": "[TODO: Translate] Under threshold",
"becameFree": "[TODO: Translate] Became free"
},
"thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz",
"empty": "[TODO: Translate] Nothing is under your price threshold right now.",
"emptyFree": "[TODO: Translate] No version has become free yet.",
"loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.",
"lastChecked": "[TODO: Translate] Prices last checked {when}",
"justNow": "[TODO: Translate] just now",
"minutesAgo": "[TODO: Translate] {count} min ago",
"hoursAgo": "[TODO: Translate] {count} h ago",
"daysAgo": "[TODO: Translate] {count} d ago",
"freeNow": "[TODO: Translate] Free now",
"blueBuzz": "[TODO: Translate] Blue Buzz OK",
"earlyAccessUntil": "[TODO: Translate] Early access until {date}",
"inLibrary": "[TODO: Translate] In library",
"notInLibrary": "[TODO: Translate] Not in library",
"droppedAgo": "[TODO: Translate] dropped {when}",
"openCivitai": "[TODO: Translate] Open on CivitAI",
"openLocal": "[TODO: Translate] Open"
} }
}, },
"support": { "support": {
+34 -2
View File
@@ -234,6 +234,9 @@
}, },
"groupByModel": { "groupByModel": {
"label": "按模型分組" "label": "按模型分組"
},
"priceAlerts": {
"label": "[TODO: Translate] Price alerts"
} }
}, },
"header": { "header": {
@@ -942,7 +945,9 @@
"action": "更新", "action": "更新",
"menuLabel": "顯示更新選項", "menuLabel": "顯示更新選項",
"check": "檢查更新", "check": "檢查更新",
"checkTooltip": "檢查更新可能耗時。" "checkTooltip": "檢查更新可能耗時。",
"priceAlerts": "[TODO: Translate] Price alerts",
"priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions"
} }
}, },
"bulkOperations": { "bulkOperations": {
@@ -2321,7 +2326,8 @@
"notificationsTitle": "通知中心", "notificationsTitle": "通知中心",
"tabs": { "tabs": {
"updates": "更新", "updates": "更新",
"messages": "訊息" "messages": "訊息",
"priceAlerts": "[TODO: Translate] Price Alerts"
}, },
"updateAvailable": "有新版本可用", "updateAvailable": "有新版本可用",
"noChangelogAvailable": "無詳細更新日誌。請至 GitHub 查看更多資訊。", "noChangelogAvailable": "無詳細更新日誌。請至 GitHub 查看更多資訊。",
@@ -2377,6 +2383,32 @@
"shown": "{time} 顯示", "shown": "{time} 顯示",
"dismissed": "{time} 關閉", "dismissed": "{time} 關閉",
"active": "仍在顯示" "active": "仍在顯示"
},
"priceAlerts": {
"disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.",
"openSettings": "[TODO: Translate] Open settings",
"segmentsLabel": "[TODO: Translate] Alert kind",
"segments": {
"belowThreshold": "[TODO: Translate] Under threshold",
"becameFree": "[TODO: Translate] Became free"
},
"thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz",
"empty": "[TODO: Translate] Nothing is under your price threshold right now.",
"emptyFree": "[TODO: Translate] No version has become free yet.",
"loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.",
"lastChecked": "[TODO: Translate] Prices last checked {when}",
"justNow": "[TODO: Translate] just now",
"minutesAgo": "[TODO: Translate] {count} min ago",
"hoursAgo": "[TODO: Translate] {count} h ago",
"daysAgo": "[TODO: Translate] {count} d ago",
"freeNow": "[TODO: Translate] Free now",
"blueBuzz": "[TODO: Translate] Blue Buzz OK",
"earlyAccessUntil": "[TODO: Translate] Early access until {date}",
"inLibrary": "[TODO: Translate] In library",
"notInLibrary": "[TODO: Translate] Not in library",
"droppedAgo": "[TODO: Translate] dropped {when}",
"openCivitai": "[TODO: Translate] Open on CivitAI",
"openLocal": "[TODO: Translate] Open"
} }
}, },
"support": { "support": {
+153
View File
@@ -48,6 +48,7 @@ from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStat
from ...services.use_cases.sidecar_migration_use_case import SidecarMigrationUseCase from ...services.use_cases.sidecar_migration_use_case import SidecarMigrationUseCase
from ...services.websocket_progress_callback import WebSocketBroadcastCallback from ...services.websocket_progress_callback import WebSocketBroadcastCallback
from ...utils.models import BaseModelMetadata from ...utils.models import BaseModelMetadata
from ...utils.civitai_utils import build_civitai_model_page_url
from ...utils.constants import ( from ...utils.constants import (
CIVITAI_USER_MODEL_TYPES, CIVITAI_USER_MODEL_TYPES,
DEFAULT_NODE_COLOR, DEFAULT_NODE_COLOR,
@@ -2241,6 +2242,11 @@ class ServiceRegistryAdapter:
get_downloaded_version_history_service: Callable[[], Awaitable[Any]] get_downloaded_version_history_service: Callable[[], Awaitable[Any]]
get_backup_service: Callable[[], Awaitable[Any]] = _noop_backup_service get_backup_service: Callable[[], Awaitable[Any]] = _noop_backup_service
get_other_scanner: Callable[[], Awaitable[Any]] = ServiceRegistry.get_other_scanner get_other_scanner: Callable[[], Awaitable[Any]] = ServiceRegistry.get_other_scanner
# Shared across model types (the update DB is per library), which is what lets
# the global price-alerts endpoint cover every type in one query.
get_model_update_service: Callable[[], Awaitable[Any]] = (
ServiceRegistry.get_model_update_service
)
class ModelLibraryHandler: class ModelLibraryHandler:
@@ -4313,6 +4319,149 @@ class SidecarMigrationHandler:
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
class PriceAlertsHandler:
"""Data for the global Buzz price-alerts panel.
App-wide on purpose: the update DB is one file per library shared by every
model type, and ``ServiceRegistry.get_model_update_service()`` hands out one
instance, so the whole panel is a single query. Registered once (see
``MISC_ROUTE_DEFINITIONS``) rather than once per model type.
"""
# Model type -> adapter attribute, for the best-effort local name/path lookup.
_SCANNER_ATTRS = {
"lora": "get_lora_scanner",
"checkpoint": "get_checkpoint_scanner",
"embedding": "get_embedding_scanner",
"other": "get_other_scanner",
}
def __init__(
self,
*,
settings_service,
service_registry: ServiceRegistryAdapter,
) -> None:
self._settings = settings_service
self._service_registry = service_registry
def _setting(self, key: str, default: Any) -> Any:
if self._settings is None:
return default
try:
value = self._settings.get(key, default)
except Exception:
return default
return default if value is None else value
async def get_price_alerts(self, request: web.Request) -> web.Response:
"""Versions under the alert threshold, plus the ones that became free.
GET with an optional ``limit`` so the companion extension can call it too.
"""
try:
limit = int(request.query.get("limit") or 200)
except (TypeError, ValueError):
limit = 200
threshold = 0
try:
threshold = max(0, int(self._setting("price_alert_threshold_buzz", 0)))
except (TypeError, ValueError):
threshold = 0
try:
update_service = await self._service_registry.get_model_update_service()
except Exception as exc: # pragma: no cover - defensive
logger.error("Price alerts unavailable: %s", exc, exc_info=True)
return web.json_response(
{"success": False, "error": "Update service unavailable"}, status=503
)
try:
alerts = await update_service.get_price_alerts(
None, threshold_buzz=threshold, limit=limit
)
newest_checked_at = update_service.newest_price_checked_at()
except Exception as exc:
logger.error("Failed to load price alerts: %s", exc, exc_info=True)
return web.json_response(
{"success": False, "error": str(exc)}, status=500
)
await self._decorate_local_context(alerts)
civitai_host = self._setting("civitai_host", "civitai.com")
for alert in alerts:
alert["civitaiUrl"] = build_civitai_model_page_url(
alert.get("modelId"),
alert.get("versionId"),
host=civitai_host,
)
return web.json_response(
{
"success": True,
"enabled": bool(self._setting("price_tracking_enabled", False)),
"thresholdBuzz": threshold,
"newestCheckedAt": newest_checked_at,
"alerts": alerts,
}
)
async def _decorate_local_context(self, alerts: Sequence[Dict[str, Any]]) -> None:
"""Attach ``modelName`` / ``filePath`` / ``fileName`` when the model is local.
Best-effort by design: the panel must render even when a scanner cache is
cold or a type is unavailable, so every failure just leaves the fields as
None and the UI hides the affordances that need them.
"""
by_type: Dict[str, set] = {}
for alert in alerts:
model_type = alert.get("modelType")
model_id = alert.get("modelId")
if not model_type or model_id is None:
continue
by_type.setdefault(model_type, set()).add(model_id)
for model_type, model_ids in by_type.items():
attr = self._SCANNER_ATTRS.get(model_type)
scanner_getter = getattr(self._service_registry, attr, None) if attr else None
if scanner_getter is None:
continue
try:
scanner = await scanner_getter()
cache = await scanner.get_cached_data()
except Exception as exc:
logger.debug(
"Skipping local context for %s price alerts: %s", model_type, exc
)
continue
model_index = getattr(cache, "model_id_index", None) or {}
version_index = getattr(cache, "version_index", None) or {}
for alert in alerts:
if alert.get("modelType") != model_type:
continue
if alert.get("modelId") not in model_ids:
continue
entries = model_index.get(alert.get("modelId")) or []
if entries and isinstance(entries[0], Mapping):
first = entries[0]
alert["modelName"] = first.get("model_name") or first.get(
"file_name"
)
version_entry = version_index.get(alert.get("versionId"))
if isinstance(version_entry, Mapping):
alert["filePath"] = version_entry.get("file_path")
alert["fileName"] = version_entry.get("file_name")
alert.setdefault("modelName", version_entry.get("model_name"))
class MiscHandlerSet: class MiscHandlerSet:
"""Aggregate handlers into a lookup compatible with the registrar.""" """Aggregate handlers into a lookup compatible with the registrar."""
@@ -4336,6 +4485,7 @@ class MiscHandlerSet:
doctor: DoctorHandler, doctor: DoctorHandler,
example_workflows: ExampleWorkflowsHandler, example_workflows: ExampleWorkflowsHandler,
base_model: BaseModelHandlerSet, base_model: BaseModelHandlerSet,
price_alerts: PriceAlertsHandler,
model_source_handler: Any = None, model_source_handler: Any = None,
agent_handler: Any = None, agent_handler: Any = None,
download_routing: Any = None, download_routing: Any = None,
@@ -4358,6 +4508,7 @@ class MiscHandlerSet:
self.doctor = doctor self.doctor = doctor
self.example_workflows = example_workflows self.example_workflows = example_workflows
self.base_model = base_model self.base_model = base_model
self.price_alerts = price_alerts
self.model_source_handler = model_source_handler self.model_source_handler = model_source_handler
self.agent_handler = agent_handler self.agent_handler = agent_handler
self.download_routing = download_routing self.download_routing = download_routing
@@ -4371,6 +4522,7 @@ class MiscHandlerSet:
"get_init_status": self.health.get_init_status, "get_init_status": self.health.get_init_status,
"get_settings": self.settings.get_settings, "get_settings": self.settings.get_settings,
"update_settings": self.settings.update_settings, "update_settings": self.settings.update_settings,
"get_price_alerts": self.price_alerts.get_price_alerts,
"get_doctor_diagnostics": self.doctor.get_doctor_diagnostics, "get_doctor_diagnostics": self.doctor.get_doctor_diagnostics,
"repair_doctor_cache": self.doctor.repair_doctor_cache, "repair_doctor_cache": self.doctor.repair_doctor_cache,
"resolve_doctor_filename_conflicts": self.doctor.resolve_filename_conflicts, "resolve_doctor_filename_conflicts": self.doctor.resolve_filename_conflicts,
@@ -4447,4 +4599,5 @@ def build_service_registry_adapter() -> ServiceRegistryAdapter:
get_other_scanner=ServiceRegistry.get_other_scanner, get_other_scanner=ServiceRegistry.get_other_scanner,
get_downloaded_version_history_service=ServiceRegistry.get_downloaded_version_history_service, get_downloaded_version_history_service=ServiceRegistry.get_downloaded_version_history_service,
get_backup_service=ServiceRegistry.get_backup_service, get_backup_service=ServiceRegistry.get_backup_service,
get_model_update_service=ServiceRegistry.get_model_update_service,
) )
+3
View File
@@ -22,6 +22,9 @@ class RouteDefinition:
MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/settings", "get_settings"), RouteDefinition("GET", "/api/lm/settings", "get_settings"),
RouteDefinition("POST", "/api/lm/settings", "update_settings"), RouteDefinition("POST", "/api/lm/settings", "update_settings"),
# App-wide and registered once: the alerts panel spans every model type, and
# the update DB is shared, so there is nothing per-type about it.
RouteDefinition("GET", "/api/lm/price-alerts", "get_price_alerts"),
RouteDefinition("GET", "/api/lm/llm/models", "get_llm_models"), RouteDefinition("GET", "/api/lm/llm/models", "get_llm_models"),
RouteDefinition("GET", "/api/lm/llm/provider-models", "get_provider_models"), RouteDefinition("GET", "/api/lm/llm/provider-models", "get_provider_models"),
RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"), RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"),
+6
View File
@@ -31,6 +31,7 @@ from .handlers.misc_handlers import (
ModelLibraryHandler, ModelLibraryHandler,
NodeRegistry, NodeRegistry,
NodeRegistryHandler, NodeRegistryHandler,
PriceAlertsHandler,
SettingsHandler, SettingsHandler,
SidecarMigrationHandler, SidecarMigrationHandler,
SupportersHandler, SupportersHandler,
@@ -140,6 +141,10 @@ class MiscRoutes:
doctor = DoctorHandler(settings_service=self._settings) doctor = DoctorHandler(settings_service=self._settings)
example_workflows = ExampleWorkflowsHandler() example_workflows = ExampleWorkflowsHandler()
base_model = BaseModelHandlerSet() base_model = BaseModelHandlerSet()
price_alerts = PriceAlertsHandler(
settings_service=self._settings,
service_registry=self._service_registry_adapter,
)
model_source_handler = ModelSourceHandler() model_source_handler = ModelSourceHandler()
agent_handler = AgentHandler() agent_handler = AgentHandler()
download_routing = DownloadRoutingHandler() download_routing = DownloadRoutingHandler()
@@ -163,6 +168,7 @@ class MiscRoutes:
doctor=doctor, doctor=doctor,
example_workflows=example_workflows, example_workflows=example_workflows,
base_model=base_model, base_model=base_model,
price_alerts=price_alerts,
model_source_handler=model_source_handler, model_source_handler=model_source_handler,
agent_handler=agent_handler, agent_handler=agent_handler,
download_routing=download_routing, download_routing=download_routing,
+137 -36
View File
@@ -124,6 +124,10 @@ class ModelVersionRecord:
# Last computed "price alert threshold hit" state, used to detect the edge # Last computed "price alert threshold hit" state, used to detect the edge
# (0 -> 1) that fires a one-shot price_drop event. # (0 -> 1) that fires a one-shot price_drop event.
price_alert_state: bool = False price_alert_state: bool = False
# When the alert state last flipped to True (POSIX seconds); cleared when the
# price rises back above the threshold. Gives the panel "dropped X ago" and
# the unread count something to compare against.
price_alert_since: Optional[float] = None
@dataclass @dataclass
@@ -374,6 +378,7 @@ class ModelUpdateService:
price_sale_ends_at TEXT, price_sale_ends_at TEXT,
price_checked_at REAL, price_checked_at REAL,
price_alert_state INTEGER NOT NULL DEFAULT 0, price_alert_state INTEGER NOT NULL DEFAULT 0,
price_alert_since REAL,
PRIMARY KEY (model_id, version_id), PRIMARY KEY (model_id, version_id),
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
); );
@@ -657,6 +662,10 @@ class ModelUpdateService:
"ALTER TABLE model_update_versions " "ALTER TABLE model_update_versions "
"ADD COLUMN price_alert_state INTEGER NOT NULL DEFAULT 0" "ADD COLUMN price_alert_state INTEGER NOT NULL DEFAULT 0"
), ),
"price_alert_since": (
"ALTER TABLE model_update_versions "
"ADD COLUMN price_alert_since REAL"
),
} }
for column, statement in migrations.items(): for column, statement in migrations.items():
@@ -769,6 +778,7 @@ class ModelUpdateService:
price_sale_ends_at TEXT, price_sale_ends_at TEXT,
price_checked_at REAL, price_checked_at REAL,
price_alert_state INTEGER NOT NULL DEFAULT 0, price_alert_state INTEGER NOT NULL DEFAULT 0,
price_alert_since REAL,
PRIMARY KEY (model_id, version_id), PRIMARY KEY (model_id, version_id),
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
) )
@@ -799,6 +809,7 @@ class ModelUpdateService:
"price_sale_ends_at", "price_sale_ends_at",
"price_checked_at", "price_checked_at",
"price_alert_state", "price_alert_state",
"price_alert_since",
] ]
defaults = { defaults = {
"sort_index": "0", "sort_index": "0",
@@ -822,6 +833,7 @@ class ModelUpdateService:
"price_sale_ends_at": "NULL", "price_sale_ends_at": "NULL",
"price_checked_at": "NULL", "price_checked_at": "NULL",
"price_alert_state": "0", "price_alert_state": "0",
"price_alert_since": "NULL",
} }
select_parts = [] select_parts = []
@@ -1203,13 +1215,28 @@ class ModelUpdateService:
return self._get_records_bulk(model_type, normalized_ids) return self._get_records_bulk(model_type, normalized_ids)
async def get_price_alerts( async def get_price_alerts(
self, model_type: str, limit: int = 200 self,
model_type: Optional[str] = None,
*,
threshold_buzz: Optional[int] = None,
limit: int = 200,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Return the gated versions whose price currently crosses the threshold. """Return the versions worth alerting on, for one type or for all of them.
Reads the persisted ``price_alert_state`` rather than recomputing, so the The threshold is compared **at read time** rather than reading the persisted
list matches the badges the user sees. Cheapest versions first, since that ``price_alert_state``: otherwise editing the threshold in the panel would
is what a price watch is for. not take effect until the next refresh. ``price_alert_state`` stays the
source of the one-shot toast edge.
Two kinds are returned in one list:
* ``below_threshold`` — gated versions whose effective price is at or below
the threshold (only possible when price capture has run);
* ``became_free`` — versions whose gate lapsed, which needs no price data at
all, so they are reported even while price tracking is switched off.
``model_type=None`` covers every type, which is what the global alerts panel
wants; the update DB is shared across types, so that is still one query.
""" """
try: try:
@@ -1217,9 +1244,25 @@ class ModelUpdateService:
except (TypeError, ValueError): except (TypeError, ValueError):
normalized_limit = 200 normalized_limit = 200
if threshold_buzz is None:
threshold = self._price_alert_threshold_buzz()
else:
try:
threshold = max(0, int(threshold_buzz))
except (TypeError, ValueError):
threshold = self._price_alert_threshold_buzz()
params: List[Any] = []
type_filter = ""
if model_type:
type_filter = "AND s.model_type = ?"
params.append(model_type)
params.append(threshold)
params.append(normalized_limit)
with self._connect() as conn: with self._connect() as conn:
rows = conn.execute( rows = conn.execute(
""" f"""
SELECT v.model_id, SELECT v.model_id,
v.version_id, v.version_id,
v.name, v.name,
@@ -1228,6 +1271,8 @@ class ModelUpdateService:
v.accepts_blue_buzz, v.accepts_blue_buzz,
v.price_sale_ends_at, v.price_sale_ends_at,
v.price_checked_at, v.price_checked_at,
v.price_alert_since,
v.gate_lapsed_at,
v.is_in_library, v.is_in_library,
v.early_access_ends_at, v.early_access_ends_at,
v.paid_access, v.paid_access,
@@ -1236,38 +1281,74 @@ class ModelUpdateService:
s.model_type s.model_type
FROM model_update_versions v FROM model_update_versions v
JOIN model_update_status s ON s.model_id = v.model_id JOIN model_update_status s ON s.model_id = v.model_id
WHERE v.price_alert_state = 1 WHERE v.should_ignore = 0
AND v.should_ignore = 0
AND s.should_ignore_model = 0 AND s.should_ignore_model = 0
AND s.model_type = ? {type_filter}
ORDER BY v.price_buzz ASC, v.model_id ASC, v.version_id ASC AND (
(v.price_buzz IS NOT NULL AND v.price_buzz <= ?)
OR (v.gate_lapsed_at IS NOT NULL
AND v.paid_access IS NULL
AND v.is_paid = 0
AND v.is_early_access = 0)
)
ORDER BY (v.price_buzz IS NULL) ASC,
v.price_buzz ASC,
v.gate_lapsed_at DESC,
v.model_id ASC,
v.version_id ASC
LIMIT ? LIMIT ?
""", """,
(model_type, normalized_limit), tuple(params),
).fetchall() ).fetchall()
return [ alerts: List[Dict[str, Any]] = []
{ for row in rows:
"modelId": int(row["model_id"]), price_buzz = _normalize_int(row["price_buzz"])
"modelType": row["model_type"], below_threshold = price_buzz is not None and price_buzz <= threshold
"versionId": int(row["version_id"]), alerts.append(
"versionName": row["name"], {
"priceBuzz": _normalize_int(row["price_buzz"]), "modelId": int(row["model_id"]),
"listPriceBuzz": _normalize_int(row["list_price_buzz"]), "modelType": row["model_type"],
"acceptsBlueBuzz": bool(row["accepts_blue_buzz"]), "versionId": int(row["version_id"]),
"priceSaleEndsAt": row["price_sale_ends_at"], "versionName": row["name"],
"priceCheckedAt": ( "kind": "below_threshold" if below_threshold else "became_free",
float(row["price_checked_at"]) "priceBuzz": price_buzz if below_threshold else None,
if row["price_checked_at"] is not None "listPriceBuzz": (
else None _normalize_int(row["list_price_buzz"])
), if below_threshold
"isInLibrary": bool(row["is_in_library"]), else None
"earlyAccessEndsAt": row["early_access_ends_at"], ),
"isPaid": bool(row["is_paid"]), "acceptsBlueBuzz": bool(row["accepts_blue_buzz"]),
"isEarlyAccess": bool(row["is_early_access"]), "priceSaleEndsAt": row["price_sale_ends_at"],
} "priceCheckedAt": (
for row in rows float(row["price_checked_at"])
] if row["price_checked_at"] is not None
else None
),
"priceAlertSince": (
float(row["price_alert_since"])
if row["price_alert_since"] is not None
else None
),
"gateLapsedAt": row["gate_lapsed_at"],
"isInLibrary": bool(row["is_in_library"]),
"earlyAccessEndsAt": row["early_access_ends_at"],
"isPaid": bool(row["is_paid"]),
"isEarlyAccess": bool(row["is_early_access"]),
}
)
return alerts
def newest_price_checked_at(self) -> Optional[float]:
"""Newest successful price fetch across the tracked versions."""
with self._connect() as conn:
row = conn.execute(
"SELECT MAX(price_checked_at) AS newest FROM model_update_versions"
).fetchone()
if row is None or row["newest"] is None:
return None
return float(row["newest"])
async def _refresh_single_model( async def _refresh_single_model(
self, self,
@@ -1852,6 +1933,16 @@ class ModelUpdateService:
price_fields["price_alert_state"] = self._evaluate_price_alert( price_fields["price_alert_state"] = self._evaluate_price_alert(
price_fields.get("price_buzz") price_fields.get("price_buzz")
) )
# `price_alert_since` records when the alert last started, so the panel
# can say "dropped 3 days ago" and count what is new since the user
# last looked. A still-standing alert keeps its original timestamp; a
# price back above the threshold clears it.
if not price_fields["price_alert_state"]:
price_fields["price_alert_since"] = None
elif existing_version is not None and existing_version.price_alert_state:
price_fields["price_alert_since"] = existing_version.price_alert_since
else:
price_fields["price_alert_since"] = timestamp
if ( if (
existing_version is not None existing_version is not None
@@ -1987,6 +2078,7 @@ class ModelUpdateService:
"price_sale_ends_at": None, "price_sale_ends_at": None,
"price_checked_at": None, "price_checked_at": None,
"price_alert_state": False, "price_alert_state": False,
"price_alert_since": None,
} }
if remote_version.price_checked_at is not None: if remote_version.price_checked_at is not None:
@@ -2002,6 +2094,7 @@ class ModelUpdateService:
"price_sale_ends_at": source.price_sale_ends_at, "price_sale_ends_at": source.price_sale_ends_at,
"price_checked_at": source.price_checked_at, "price_checked_at": source.price_checked_at,
"price_alert_state": source.price_alert_state, "price_alert_state": source.price_alert_state,
"price_alert_since": source.price_alert_since,
} }
def _price_tracking_enabled(self) -> bool: def _price_tracking_enabled(self) -> bool:
@@ -2374,7 +2467,8 @@ class ModelUpdateService:
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at, 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, gate_lapsed_at, price_buzz, list_price_buzz, generation_price_buzz,
accepts_blue_buzz, price_sale_ends_at, price_checked_at, price_alert_state accepts_blue_buzz, price_sale_ends_at, price_checked_at, price_alert_state,
price_alert_since
FROM model_update_versions FROM model_update_versions
WHERE model_id IN ({placeholders}) WHERE model_id IN ({placeholders})
ORDER BY model_id ASC, sort_index ASC, version_id ASC ORDER BY model_id ASC, sort_index ASC, version_id ASC
@@ -2418,6 +2512,11 @@ class ModelUpdateService:
else None else None
), ),
price_alert_state=bool(row["price_alert_state"]), price_alert_state=bool(row["price_alert_state"]),
price_alert_since=(
float(row["price_alert_since"])
if row["price_alert_since"] is not None
else None
),
) )
) )
@@ -2482,8 +2581,9 @@ class ModelUpdateService:
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at, 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, gate_lapsed_at, price_buzz, list_price_buzz, generation_price_buzz,
accepts_blue_buzz, price_sale_ends_at, price_checked_at, price_alert_state accepts_blue_buzz, price_sale_ends_at, price_checked_at, price_alert_state,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) price_alert_since
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
version.version_id, version.version_id,
@@ -2510,6 +2610,7 @@ class ModelUpdateService:
version.price_sale_ends_at, version.price_sale_ends_at,
version.price_checked_at, version.price_checked_at,
1 if version.price_alert_state else 0, 1 if version.price_alert_state else 0,
version.price_alert_since,
), ),
) )
conn.commit() conn.commit()
@@ -652,3 +652,167 @@
margin-top: 5px; margin-top: 5px;
} }
} }
/* --- Buzz price alerts panel --------------------------------------------- */
.price-alerts {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.price-alerts-disabled {
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
background: var(--lora-surface);
padding: var(--space-2);
display: flex;
flex-direction: column;
gap: var(--space-2);
align-items: flex-start;
}
.price-alerts-disabled-text {
margin: 0;
color: var(--text-color);
opacity: 0.85;
font-size: 0.9rem;
}
.price-alerts-settings-btn {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 0.4rem 0.7rem;
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
background: var(--card-bg, #fff);
color: var(--text-color);
cursor: pointer;
}
.price-alerts-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
flex-wrap: wrap;
}
.price-alerts-segments {
display: inline-flex;
gap: var(--space-1);
}
.price-alerts-segment {
padding: 0.35rem 0.65rem;
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
background: var(--lora-surface);
color: var(--text-color);
cursor: pointer;
font-size: 0.85rem;
}
.price-alerts-segment.active {
background: var(--lora-accent, #845ef7);
border-color: var(--lora-accent, #845ef7);
color: #fff;
}
.price-alerts-threshold {
font-size: 0.85rem;
opacity: 0.8;
}
.price-alerts-note {
margin: 0;
font-size: 0.8rem;
opacity: 0.75;
}
.price-alerts-empty {
margin: var(--space-2) 0 0;
opacity: 0.7;
font-size: 0.9rem;
}
.price-alerts-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
max-height: 46vh;
overflow-y: auto;
}
.price-alert-item {
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
padding: var(--space-2);
background: var(--card-bg, #fff);
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.price-alert-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
flex-wrap: wrap;
}
.price-alert-model {
font-weight: 600;
}
.price-alert-price {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.price-alert-price.is-alert {
color: #2b8a3e;
}
[data-theme="dark"] .price-alert-price.is-alert {
color: #69db7c;
}
.price-alert-was {
text-decoration: line-through;
opacity: 0.6;
font-weight: 400;
margin-left: 0.35rem;
}
.price-alert-meta {
font-size: 0.8rem;
opacity: 0.75;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.price-alert-actions {
display: flex;
gap: var(--space-1);
flex-wrap: wrap;
}
.price-alert-action {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0.55rem;
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
background: var(--lora-surface);
color: var(--text-color);
cursor: pointer;
font-size: 0.8rem;
}
+3 -1
View File
@@ -98,7 +98,9 @@ export function getApiEndpoints(modelType) {
modelUpdateVersions: `/api/lm/${modelType}/updates/versions`, modelUpdateVersions: `/api/lm/${modelType}/updates/versions`,
ignoreModelUpdate: `/api/lm/${modelType}/updates/ignore`, ignoreModelUpdate: `/api/lm/${modelType}/updates/ignore`,
ignoreVersionUpdate: `/api/lm/${modelType}/updates/ignore-version`, ignoreVersionUpdate: `/api/lm/${modelType}/updates/ignore-version`,
priceAlerts: `/api/lm/${modelType}/updates/price-alerts`,
// Price alerts are app-wide (all model types in one query), so they are
// not part of this per-type endpoint map; see UpdateService.js.
// Preview management // Preview management
replacePreview: `/api/lm/${modelType}/replace-preview`, replacePreview: `/api/lm/${modelType}/replace-preview`,
-27
View File
@@ -1086,33 +1086,6 @@ export class BaseModelApiClient {
} }
} }
/**
* Versions whose stored price currently crosses the alert threshold.
* Backed by the persisted alert state, so it matches the version badges.
* @param {{limit?: number}} [options]
* @returns {Promise<{alerts: Array, thresholdBuzz: number, enabled: boolean}>}
*/
async getPriceAlerts({ limit = 200 } = {}) {
const endpoint = this.apiConfig?.endpoints?.priceAlerts;
if (!endpoint) {
return { alerts: [], thresholdBuzz: 0, enabled: false };
}
const response = await fetch(`${endpoint}?limit=${encodeURIComponent(limit)}`, {
method: 'GET'
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.success !== true) {
throw new Error(payload?.error || response.statusText || 'Failed to load price alerts');
}
return {
alerts: Array.isArray(payload.alerts) ? payload.alerts : [],
thresholdBuzz: payload.thresholdBuzz ?? 0,
enabled: payload.enabled === true
};
}
async refreshUpdatesForModels(modelIds, { force = false } = {}) { async refreshUpdatesForModels(modelIds, { force = false } = {}) {
if (!Array.isArray(modelIds) || modelIds.length === 0) { if (!Array.isArray(modelIds) || modelIds.length === 0) {
throw new Error('No model IDs provided'); throw new Error('No model IDs provided');
@@ -4,6 +4,7 @@ import { translate } from '../../utils/i18nHelpers.js';
import { state } from '../../state/index.js'; import { state } from '../../state/index.js';
import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js'; import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js'; import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { openPriceAlertsPanel, updateService } from '../../managers/UpdateService.js';
import { rematchModalManager } from '../../managers/RematchModalManager.js'; import { rematchModalManager } from '../../managers/RematchModalManager.js';
import { showRematchSummary } from '../RematchSummaryModal.js'; import { showRematchSummary } from '../RematchSummaryModal.js';
@@ -27,6 +28,7 @@ export class GlobalContextMenu extends BaseContextMenu {
const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]'); const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]');
const rematchRecipesItem = this.menu.querySelector('[data-action="rematch-recipes"]'); const rematchRecipesItem = this.menu.querySelector('[data-action="rematch-recipes"]');
const groupByModelItem = this.menu.querySelector('[data-action="toggle-group-by-model"]'); const groupByModelItem = this.menu.querySelector('[data-action="toggle-group-by-model"]');
const priceAlertsItem = this.menu.querySelector('[data-action="price-alerts"]');
const groupByModelCheck = groupByModelItem?.querySelector('.check-indicator'); const groupByModelCheck = groupByModelItem?.querySelector('.check-indicator');
// Update check indicator for group-by-model // Update check indicator for group-by-model
@@ -35,6 +37,16 @@ export class GlobalContextMenu extends BaseContextMenu {
groupByModelCheck.style.display = isEnabled ? 'block' : 'none'; groupByModelCheck.style.display = isEnabled ? 'block' : 'none';
} }
// Append the unread count when it is already known (loaded once on init,
// and only when price tracking is on), so the label stays a plain label
// for everyone else.
const priceAlertsLabel = priceAlertsItem?.querySelector('#globalPriceAlertsLabel');
if (priceAlertsLabel) {
const unread = updateService?.getUnreadPriceAlertCount?.() || 0;
const base = translate('globalContextMenu.priceAlerts.label', {}, 'Price alerts');
priceAlertsLabel.textContent = unread > 0 ? `${base} (${unread})` : base;
}
if (isRecipesPage) { if (isRecipesPage) {
modelUpdateItem?.classList.add('hidden'); modelUpdateItem?.classList.add('hidden');
licenseRefreshItem?.classList.add('hidden'); licenseRefreshItem?.classList.add('hidden');
@@ -42,6 +54,7 @@ export class GlobalContextMenu extends BaseContextMenu {
cleanupExamplesItem?.classList.add('hidden'); cleanupExamplesItem?.classList.add('hidden');
excludedModelsItem?.classList.add('hidden'); excludedModelsItem?.classList.add('hidden');
groupByModelItem?.classList.add('hidden'); groupByModelItem?.classList.add('hidden');
priceAlertsItem?.classList.add('hidden');
rematchRecipesItem?.classList.remove('hidden'); rematchRecipesItem?.classList.remove('hidden');
} else { } else {
modelUpdateItem?.classList.remove('hidden'); modelUpdateItem?.classList.remove('hidden');
@@ -50,6 +63,7 @@ export class GlobalContextMenu extends BaseContextMenu {
cleanupExamplesItem?.classList.remove('hidden'); cleanupExamplesItem?.classList.remove('hidden');
excludedModelsItem?.classList.remove('hidden'); excludedModelsItem?.classList.remove('hidden');
groupByModelItem?.classList.remove('hidden'); groupByModelItem?.classList.remove('hidden');
priceAlertsItem?.classList.remove('hidden');
rematchRecipesItem?.classList.add('hidden'); rematchRecipesItem?.classList.add('hidden');
} }
@@ -89,6 +103,9 @@ export class GlobalContextMenu extends BaseContextMenu {
console.error('Failed to check model updates:', error); console.error('Failed to check model updates:', error);
}); });
break; break;
case 'price-alerts':
openPriceAlertsPanel();
break;
case 'fetch-missing-licenses': case 'fetch-missing-licenses':
this.fetchMissingLicenses(menuItem).catch((error) => { this.fetchMissingLicenses(menuItem).catch((error) => {
console.error('Failed to refresh missing license metadata:', error); console.error('Failed to refresh missing license metadata:', error);
@@ -4,6 +4,7 @@ import { getStorageItem, setStorageItem, removeStorageItem, getSessionItem, setS
import { showToast, openCivitaiByMetadata, isTypingContext } from '../../utils/uiHelpers.js'; import { showToast, openCivitaiByMetadata, isTypingContext } from '../../utils/uiHelpers.js';
import { eventManager } from '../../utils/EventManager.js'; import { eventManager } from '../../utils/EventManager.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js'; import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { openPriceAlertsPanel } from '../../managers/UpdateService.js';
import { sidebarManager } from '../SidebarManager.js'; import { sidebarManager } from '../SidebarManager.js';
import { initSortDropdown, applySortToSelect, randomizeSortValue } from './SortDropdown.js'; import { initSortDropdown, applySortToSelect, randomizeSortValue } from './SortDropdown.js';
@@ -256,6 +257,17 @@ export class PageControls {
}); });
} }
// Opens the notification bell on the price alerts tab (same action as the
// global context menu entry).
const priceAlertsOption = document.getElementById('priceAlertsMenuItem');
if (priceAlertsOption) {
priceAlertsOption.addEventListener('click', (e) => {
e.stopPropagation();
document.querySelector('.dropdown-group.active')?.classList.remove('active');
openPriceAlertsPanel();
});
}
// Close dropdowns when clicking outside // Close dropdowns when clicking outside
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (!e.target.closest('.dropdown-group')) { if (!e.target.closest('.dropdown-group')) {
+411
View File
@@ -9,6 +9,77 @@ import {
import { state } from '../state/index.js'; import { state } from '../state/index.js';
import { bannerService } from './BannerService.js'; import { bannerService } from './BannerService.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { showModelModal } from '../components/shared/ModelModal.js';
/** Global endpoint: the alerts panel spans every model type. */
const PRICE_ALERTS_ENDPOINT = '/api/lm/price-alerts';
/** localStorage watermark of the newest alert the user has already seen. */
const PRICE_ALERTS_VIEWED_KEY = 'lm_price_alerts_viewed_at';
async function fetchPriceAlerts(limit = 200) {
const response = await fetch(
`${PRICE_ALERTS_ENDPOINT}?limit=${encodeURIComponent(limit)}`
);
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.success !== true) {
throw new Error(payload?.error || response.statusText || 'Failed to load price alerts');
}
return {
alerts: Array.isArray(payload.alerts) ? payload.alerts : [],
enabled: payload.enabled === true,
thresholdBuzz: payload.thresholdBuzz ?? 0,
newestCheckedAt: payload.newestCheckedAt ?? null
};
}
/** POSIX seconds of the moment an alert started, for unread/sorting comparisons. */
function priceAlertTimestamp(alert) {
if (typeof alert?.priceAlertSince === 'number') {
return alert.priceAlertSince;
}
if (alert?.gateLapsedAt) {
const parsed = Date.parse(alert.gateLapsedAt);
if (!Number.isNaN(parsed)) {
return parsed / 1000;
}
}
return 0;
}
function formatRelativeTime(timestampSeconds) {
if (!timestampSeconds) return '';
const elapsedMs = Date.now() - timestampSeconds * 1000;
const minutes = Math.floor(elapsedMs / 60000);
if (minutes < 1) return translate('update.priceAlerts.justNow', {}, 'just now');
if (minutes < 60) {
return translate('update.priceAlerts.minutesAgo', { count: minutes }, `${minutes} min ago`);
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return translate('update.priceAlerts.hoursAgo', { count: hours }, `${hours} h ago`);
}
const days = Math.floor(hours / 24);
return translate('update.priceAlerts.daysAgo', { count: days }, `${days} d ago`);
}
function formatShortDate(value) {
if (!value) return '';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '';
return parsed.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
/**
* Open the notification bell on the Buzz price alerts tab.
* Shared by the controls dropdown item and the global context menu item.
*/
export function openPriceAlertsPanel() {
updateService.openPriceAlertsTab();
}
export class UpdateService { export class UpdateService {
constructor() { constructor() {
@@ -35,6 +106,16 @@ export class UpdateService {
this.activeNotificationTab = 'updates'; this.activeNotificationTab = 'updates';
this.handleBannerHistoryUpdated = this.handleBannerHistoryUpdated.bind(this); this.handleBannerHistoryUpdated = this.handleBannerHistoryUpdated.bind(this);
this.handleNotificationTabKeydown = this.handleNotificationTabKeydown.bind(this); this.handleNotificationTabKeydown = this.handleNotificationTabKeydown.bind(this);
// Buzz price alerts (panel data, loaded on demand; the count is cached so
// the bell badge and the context-menu label can share it).
this.priceAlerts = null;
this.priceAlertsEnabled = false;
this.priceAlertsThreshold = 0;
this.priceAlertsNewestCheckedAt = null;
this.priceAlertsLoading = false;
this.priceAlertSegment = 'below_threshold';
this.unreadPriceAlertCount = 0;
this.handlePriceAlertSegmentClick = this.handlePriceAlertSegmentClick.bind(this);
} }
initialize() { initialize() {
@@ -250,7 +331,321 @@ export class UpdateService {
}); });
this.renderRecentBanners(); this.renderRecentBanners();
this.wirePriceAlertsPanel();
this.switchNotificationTab(this.activeNotificationTab); this.switchNotificationTab(this.activeNotificationTab);
// One request, and only for users who turned price tracking on. It feeds
// the tab badge and the global-context-menu count.
if (state?.global?.settings?.price_tracking_enabled) {
this.loadPriceAlerts().catch((error) => {
console.warn('Failed to preload price alerts:', error);
});
}
}
wirePriceAlertsPanel() {
const modal = document.getElementById('updateModal');
if (!modal) return;
this.priceAlertSegments = Array.from(
modal.querySelectorAll('[data-price-alert-segment]')
);
this.priceAlertSegments.forEach((segment) => {
segment.addEventListener('click', this.handlePriceAlertSegmentClick);
});
const settingsBtn = document.getElementById('priceAlertsSettingsBtn');
if (settingsBtn) {
settingsBtn.addEventListener('click', () => {
this.toggleUpdateModal();
if (window.settingsManager) {
window.settingsManager.toggleSettings();
}
});
}
}
handlePriceAlertSegmentClick(event) {
const segment = event?.currentTarget?.getAttribute('data-price-alert-segment');
if (!segment) return;
this.priceAlertSegment = segment;
this.renderPriceAlerts();
}
/**
* Open the bell on the price alerts tab.
*
* ``toggleUpdateModal()`` *closes* an open bell, so an entry point that called
* it unconditionally would dismiss the modal instead of switching to the tab.
*/
openPriceAlertsTab() {
const modal = modalManager.getModal('updateModal');
if (!modal || !modal.isOpen) {
this.toggleUpdateModal();
}
this.switchNotificationTab('priceAlerts', { markRead: true });
}
/**
* Load the global alerts payload (all model types in one query).
* @param {{force?: boolean}} [options]
*/
async loadPriceAlerts({ force = false } = {}) {
if (this.priceAlertsLoading) return this.priceAlerts;
if (!force && this.priceAlerts) {
this.renderPriceAlerts();
return this.priceAlerts;
}
this.priceAlertsLoading = true;
try {
const payload = await fetchPriceAlerts();
this.priceAlerts = payload.alerts;
this.priceAlertsEnabled = payload.enabled;
this.priceAlertsThreshold = payload.thresholdBuzz;
this.priceAlertsNewestCheckedAt = payload.newestCheckedAt;
this.refreshUnreadPriceAlertCount();
this.renderPriceAlerts();
return this.priceAlerts;
} catch (error) {
console.error('Failed to load price alerts:', error);
// Keep whatever we already had: a stale list beats an empty panel.
this.renderPriceAlerts({ error: true });
return this.priceAlerts;
} finally {
this.priceAlertsLoading = false;
}
}
/** Alerts newer than the last time the user opened the panel. */
refreshUnreadPriceAlertCount() {
const alerts = Array.isArray(this.priceAlerts) ? this.priceAlerts : [];
const viewedAt = Number(getStorageItem(PRICE_ALERTS_VIEWED_KEY, 0)) || 0;
this.unreadPriceAlertCount = alerts.filter(
(alert) => priceAlertTimestamp(alert) > viewedAt
).length;
this.updateTabBadges();
return this.unreadPriceAlertCount;
}
getUnreadPriceAlertCount() {
return this.unreadPriceAlertCount || 0;
}
markPriceAlertsViewed() {
const alerts = Array.isArray(this.priceAlerts) ? this.priceAlerts : [];
const newest = alerts.reduce(
(latest, alert) => Math.max(latest, priceAlertTimestamp(alert)),
0
);
if (newest > 0) {
setStorageItem(PRICE_ALERTS_VIEWED_KEY, newest);
}
this.unreadPriceAlertCount = 0;
this.updateTabBadges();
}
renderPriceAlerts({ error = false } = {}) {
const list = document.getElementById('priceAlertsList');
const empty = document.getElementById('priceAlertsEmpty');
const disabled = document.getElementById('priceAlertsDisabled');
const stale = document.getElementById('priceAlertsStale');
const thresholdLabel = document.getElementById('priceAlertsThreshold');
if (!list || !empty) return;
if (disabled) {
disabled.classList.toggle('hidden', this.priceAlertsEnabled !== false);
}
if (thresholdLabel) {
thresholdLabel.textContent = this.priceAlertsEnabled
? translate(
'update.priceAlerts.thresholdLabel',
{ buzz: this.priceAlertsThreshold },
`Alert threshold: ${this.priceAlertsThreshold} Buzz`
)
: '';
}
if (stale) {
const checkedAt = this.priceAlertsNewestCheckedAt;
const staleText = error
? translate(
'update.priceAlerts.loadFailed',
{},
'Could not refresh the alerts; showing the last known list.'
)
: checkedAt
? translate(
'update.priceAlerts.lastChecked',
{ when: formatRelativeTime(checkedAt) },
`Prices last checked ${formatRelativeTime(checkedAt)}`
)
: '';
stale.textContent = staleText;
stale.classList.toggle('hidden', !staleText);
}
if (Array.isArray(this.priceAlertSegments)) {
this.priceAlertSegments.forEach((segment) => {
const isActive =
segment.getAttribute('data-price-alert-segment') ===
this.priceAlertSegment;
segment.classList.toggle('active', isActive);
segment.setAttribute('aria-selected', isActive ? 'true' : 'false');
});
}
const alerts = (Array.isArray(this.priceAlerts) ? this.priceAlerts : []).filter(
(alert) => (alert.kind || 'below_threshold') === this.priceAlertSegment
);
list.innerHTML = '';
if (!alerts.length) {
empty.textContent = this.priceAlertSegment === 'became_free'
? translate(
'update.priceAlerts.emptyFree',
{},
'No version has become free yet.'
)
: translate(
'update.priceAlerts.empty',
{},
'Nothing is under your price threshold right now.'
);
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
alerts.forEach((alert) => {
list.appendChild(this.buildPriceAlertItem(alert));
});
}
buildPriceAlertItem(alert) {
const item = document.createElement('li');
item.className = 'price-alert-item';
const head = document.createElement('div');
head.className = 'price-alert-head';
const name = document.createElement('span');
name.className = 'price-alert-model';
const modelLabel = alert.modelName || alert.versionName || `#${alert.modelId}`;
name.textContent = alert.versionName
? `${modelLabel} · ${alert.versionName}`
: modelLabel;
head.appendChild(name);
if (alert.kind === 'below_threshold') {
const price = document.createElement('span');
price.className = 'price-alert-price is-alert';
price.textContent = `${Number(alert.priceBuzz).toLocaleString()} Buzz`;
if (
typeof alert.listPriceBuzz === 'number' &&
alert.listPriceBuzz > alert.priceBuzz
) {
const was = document.createElement('span');
was.className = 'price-alert-was';
was.textContent = `${alert.listPriceBuzz.toLocaleString()} Buzz`;
price.appendChild(was);
}
head.appendChild(price);
} else {
const free = document.createElement('span');
free.className = 'price-alert-price is-alert';
free.textContent = translate('update.priceAlerts.freeNow', {}, 'Free now');
head.appendChild(free);
}
item.appendChild(head);
const meta = document.createElement('div');
meta.className = 'price-alert-meta';
const metaParts = [alert.modelType];
if (alert.acceptsBlueBuzz) {
metaParts.push(translate('update.priceAlerts.blueBuzz', {}, 'Blue Buzz OK'));
}
if (alert.earlyAccessEndsAt) {
metaParts.push(
translate(
'update.priceAlerts.earlyAccessUntil',
{ date: formatShortDate(alert.earlyAccessEndsAt) },
`Early access until ${formatShortDate(alert.earlyAccessEndsAt)}`
)
);
}
metaParts.push(
alert.isInLibrary
? translate('update.priceAlerts.inLibrary', {}, 'In library')
: translate('update.priceAlerts.notInLibrary', {}, 'Not in library')
);
const since = priceAlertTimestamp(alert);
if (since > 0) {
metaParts.push(
translate(
'update.priceAlerts.droppedAgo',
{ when: formatRelativeTime(since) },
`dropped ${formatRelativeTime(since)}`
)
);
}
meta.textContent = metaParts.filter(Boolean).join(' · ');
item.appendChild(meta);
const actions = document.createElement('div');
actions.className = 'price-alert-actions';
if (alert.civitaiUrl) {
actions.appendChild(
this.buildPriceAlertAction(
'civitai',
translate('update.priceAlerts.openCivitai', {}, 'Open on CivitAI'),
'fa-external-link-alt',
() => window.open(alert.civitaiUrl, '_blank', 'noopener')
)
);
}
// Only possible for a model that is actually in the library: the modal
// reads the local metadata by file path.
if (alert.filePath) {
actions.appendChild(
this.buildPriceAlertAction(
'open',
translate('update.priceAlerts.openLocal', {}, 'Open'),
'fa-folder-open',
() => {
showModelModal(
{
model_name: alert.modelName || alert.versionName,
file_path: alert.filePath,
civitai: {},
},
alert.modelType
);
}
)
);
}
item.appendChild(actions);
return item;
}
buildPriceAlertAction(name, label, icon, onClick) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'price-alert-action';
button.dataset.priceAlertAction = name;
const iconElement = document.createElement('i');
iconElement.className = `fas ${icon}`;
button.appendChild(iconElement);
const text = document.createElement('span');
text.textContent = label;
button.appendChild(text);
button.addEventListener('click', onClick);
return button;
} }
switchNotificationTab(tabName, { markRead = false } = {}) { switchNotificationTab(tabName, { markRead = false } = {}) {
@@ -283,6 +678,14 @@ export class UpdateService {
} }
} }
if (tabName === 'priceAlerts') {
this.loadPriceAlerts().then(() => {
if (markRead) {
this.markPriceAlertsViewed();
}
});
}
this.updateTabBadges(); this.updateTabBadges();
} }
@@ -343,10 +746,12 @@ export class UpdateService {
updateTabBadges() { updateTabBadges() {
const updatesBadge = document.getElementById('updatesTabBadge'); const updatesBadge = document.getElementById('updatesTabBadge');
const bannerBadge = document.getElementById('bannerTabBadge'); const bannerBadge = document.getElementById('bannerTabBadge');
const priceAlertsBadge = document.getElementById('priceAlertsTabBadge');
const hasUpdate = this.updateNotificationsEnabled && this.updateAvailable; const hasUpdate = this.updateNotificationsEnabled && this.updateAvailable;
const unreadBanners = typeof bannerService.getUnreadBannerCount === 'function' const unreadBanners = typeof bannerService.getUnreadBannerCount === 'function'
? bannerService.getUnreadBannerCount() ? bannerService.getUnreadBannerCount()
: 0; : 0;
const unreadAlerts = this.getUnreadPriceAlertCount();
if (updatesBadge) { if (updatesBadge) {
updatesBadge.classList.toggle('visible', hasUpdate); updatesBadge.classList.toggle('visible', hasUpdate);
@@ -354,6 +759,12 @@ export class UpdateService {
updatesBadge.textContent = ''; updatesBadge.textContent = '';
} }
if (priceAlertsBadge) {
priceAlertsBadge.textContent = unreadAlerts > 9 ? '9+' : unreadAlerts ? String(unreadAlerts) : '';
priceAlertsBadge.classList.toggle('visible', unreadAlerts > 0);
priceAlertsBadge.classList.remove('is-dot');
}
if (bannerBadge) { if (bannerBadge) {
if (unreadBanners > 0) { if (unreadBanners > 0) {
bannerBadge.textContent = unreadBanners > 9 ? '9+' : unreadBanners.toString(); bannerBadge.textContent = unreadBanners > 9 ? '9+' : unreadBanners.toString();
+3
View File
@@ -185,6 +185,9 @@
<div class="context-menu-item" data-action="check-model-updates"> <div class="context-menu-item" data-action="check-model-updates">
<i class="fas fa-sync-alt"></i> <span>{{ t('globalContextMenu.checkModelUpdates.label') }}</span> <i class="fas fa-sync-alt"></i> <span>{{ t('globalContextMenu.checkModelUpdates.label') }}</span>
</div> </div>
<div class="context-menu-item" data-action="price-alerts">
<i class="fas fa-tags"></i> <span id="globalPriceAlertsLabel">{{ t('globalContextMenu.priceAlerts.label') }}</span>
</div>
<div class="context-menu-item" data-action="fetch-missing-licenses"> <div class="context-menu-item" data-action="fetch-missing-licenses">
<i class="fas fa-shield-alt"></i> <span>{{ t('globalContextMenu.fetchMissingLicenses.label') }}</span> <i class="fas fa-shield-alt"></i> <span>{{ t('globalContextMenu.fetchMissingLicenses.label') }}</span>
</div> </div>
+3
View File
@@ -122,6 +122,9 @@
<div id="checkUpdatesMenuItem" class="dropdown-item" data-action="check-updates" title="{{ t('loras.controls.updates.checkTooltip') }}"> <div id="checkUpdatesMenuItem" class="dropdown-item" data-action="check-updates" title="{{ t('loras.controls.updates.checkTooltip') }}">
<i class="fas fa-sync-alt"></i> <span>{{ t('loras.controls.updates.check') }}</span> <i class="fas fa-sync-alt"></i> <span>{{ t('loras.controls.updates.check') }}</span>
</div> </div>
<div id="priceAlertsMenuItem" class="dropdown-item" data-action="price-alerts" title="{{ t('loras.controls.updates.priceAlertsTooltip') }}">
<i class="fas fa-tags"></i> <span>{{ t('loras.controls.updates.priceAlerts') }}</span>
</div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
@@ -15,6 +15,10 @@
<span>{{ t('update.tabs.messages') }}</span> <span>{{ t('update.tabs.messages') }}</span>
<span class="notification-tab-badge" id="bannerTabBadge"></span> <span class="notification-tab-badge" id="bannerTabBadge"></span>
</button> </button>
<button type="button" class="notification-tab" id="priceAlertsTab" role="tab" aria-selected="false" aria-controls="priceAlertsPanel" tabindex="-1" data-notification-tab="priceAlerts">
<span>{{ t('update.tabs.priceAlerts') }}</span>
<span class="notification-tab-badge" id="priceAlertsTabBadge"></span>
</button>
</div> </div>
<div class="notification-panels"> <div class="notification-panels">
<div class="notification-panel active" id="updatesPanel" role="tabpanel" aria-labelledby="updatesTab" aria-hidden="false" tabindex="0" data-notification-panel="updates"> <div class="notification-panel active" id="updatesPanel" role="tabpanel" aria-labelledby="updatesTab" aria-hidden="false" tabindex="0" data-notification-panel="updates">
@@ -94,6 +98,34 @@
<ul class="banner-history-list" id="bannerHistoryList"></ul> <ul class="banner-history-list" id="bannerHistoryList"></ul>
</div> </div>
</div> </div>
<div class="notification-panel" id="priceAlertsPanel" role="tabpanel" aria-labelledby="priceAlertsTab" aria-hidden="true" tabindex="-1" data-notification-panel="priceAlerts">
<div class="price-alerts">
<!-- Shown while price tracking is off: the panel still lists
versions that became free, but says why prices are missing. -->
<div class="price-alerts-disabled hidden" id="priceAlertsDisabled">
<p class="price-alerts-disabled-text">{{ t('update.priceAlerts.disabled') }}</p>
<button type="button" class="price-alerts-settings-btn" id="priceAlertsSettingsBtn">
<i class="fas fa-sliders-h"></i> {{ t('update.priceAlerts.openSettings') }}
</button>
</div>
<div class="price-alerts-toolbar">
<div class="price-alerts-segments" role="tablist" aria-label="{{ t('update.priceAlerts.segmentsLabel') }}">
<button type="button" class="price-alerts-segment active" data-price-alert-segment="below_threshold" role="tab" aria-selected="true">
{{ t('update.priceAlerts.segments.belowThreshold') }}
</button>
<button type="button" class="price-alerts-segment" data-price-alert-segment="became_free" role="tab" aria-selected="false">
{{ t('update.priceAlerts.segments.becameFree') }}
</button>
</div>
<span class="price-alerts-threshold" id="priceAlertsThreshold"></span>
</div>
<p class="price-alerts-note hidden" id="priceAlertsStale"></p>
<p class="price-alerts-empty" id="priceAlertsEmpty">{{ t('update.priceAlerts.empty') }}</p>
<ul class="price-alerts-list" id="priceAlertsList"></ul>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,255 @@
import { describe, beforeEach, afterEach, expect, it, vi } from 'vitest';
import { UpdateService } from '../../../static/js/managers/UpdateService.js';
import { state } from '../../../static/js/state/index.js';
import { modalManager } from '../../../static/js/managers/ModalManager.js';
/**
* The price alerts panel is rendered into the notification bell modal, so the
* tests build the same element ids the template provides and drive the service
* directly.
*/
function buildPanelDom() {
document.body.innerHTML = `
<div id="updateModal">
<button data-notification-tab="updates"></button>
<button data-notification-tab="priceAlerts"></button>
<div data-notification-panel="updates"></div>
<div data-notification-panel="priceAlerts">
<div id="priceAlertsDisabled" class="hidden"></div>
<button id="priceAlertsSettingsBtn"></button>
<button data-price-alert-segment="below_threshold"></button>
<button data-price-alert-segment="became_free"></button>
<span id="priceAlertsThreshold"></span>
<p id="priceAlertsStale" class="hidden"></p>
<p id="priceAlertsEmpty"></p>
<ul id="priceAlertsList"></ul>
</div>
<span id="updatesTabBadge"></span>
<span id="bannerTabBadge"></span>
<span id="priceAlertsTabBadge"></span>
</div>
`;
}
function alertPayload(alerts, overrides = {}) {
return {
success: true,
enabled: true,
thresholdBuzz: 500,
newestCheckedAt: 1791039694.5,
alerts,
...overrides,
};
}
const BELOW_THRESHOLD_ALERT = {
modelId: 2981320,
modelType: 'checkpoint',
modelName: 'Glorious Art',
versionId: 3379626,
versionName: 'Alpha',
kind: 'below_threshold',
priceBuzz: 250,
listPriceBuzz: 500,
acceptsBlueBuzz: true,
priceAlertSince: 1791000000,
isInLibrary: false,
filePath: null,
civitaiUrl: 'https://civitai.com/models/2981320?modelVersionId=3379626',
};
const BECAME_FREE_ALERT = {
modelId: 1802980,
modelType: 'lora',
modelName: 'Eira Kishida',
versionId: 3262917,
versionName: 'v3',
kind: 'became_free',
priceBuzz: null,
priceAlertSince: null,
gateLapsedAt: '2026-09-28T00:00:00.000Z',
isInLibrary: true,
filePath: '/models/loras/eira.safetensors',
civitaiUrl: 'https://civitai.com/models/1802980?modelVersionId=3262917',
};
function createFetchResponse(payload, ok = true) {
return { json: vi.fn().mockResolvedValue(payload), ok, statusText: '' };
}
describe('UpdateService price alerts panel', () => {
let service;
beforeEach(() => {
buildPanelDom();
localStorage.clear();
state.global = state.global || {};
state.global.settings = state.global.settings || {};
service = new UpdateService();
service.setupNotificationCenter();
});
afterEach(() => {
delete global.fetch;
document.body.innerHTML = '';
localStorage.clear();
});
it('loads the global endpoint and renders a below-threshold row', async () => {
global.fetch = vi
.fn()
.mockResolvedValue(createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT])));
await service.loadPriceAlerts({ force: true });
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(String(global.fetch.mock.calls[0][0])).toContain('/api/lm/price-alerts');
const items = document.querySelectorAll('#priceAlertsList .price-alert-item');
expect(items).toHaveLength(1);
expect(items[0].textContent).toContain('Glorious Art');
expect(items[0].textContent).toContain('250 Buzz');
// The stored list price is shown struck through.
expect(items[0].querySelector('.price-alert-was').textContent).toBe('500 Buzz');
expect(items[0].textContent).toContain('Blue Buzz OK');
expect(items[0].textContent).toContain('Not in library');
// No local file -> no "Open" action, only CivitAI.
expect(items[0].querySelector('[data-price-alert-action="open"]')).toBeNull();
expect(
items[0].querySelector('[data-price-alert-action="civitai"]')
).not.toBeNull();
expect(document.getElementById('priceAlertsDisabled').classList.contains('hidden')).toBe(true);
expect(document.getElementById('priceAlertsEmpty').classList.contains('hidden')).toBe(true);
});
it('separates became-free alerts into their own segment', async () => {
global.fetch = vi
.fn()
.mockResolvedValue(
createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT, BECAME_FREE_ALERT]))
);
await service.loadPriceAlerts({ force: true });
// Below-threshold is the default segment.
expect(document.querySelectorAll('#priceAlertsList .price-alert-item')).toHaveLength(1);
service.priceAlertSegment = 'became_free';
service.renderPriceAlerts();
const items = document.querySelectorAll('#priceAlertsList .price-alert-item');
expect(items).toHaveLength(1);
expect(items[0].textContent).toContain('Free now');
expect(items[0].textContent).toContain('In library');
// In-library row offers both actions.
expect(items[0].querySelector('[data-price-alert-action="open"]')).not.toBeNull();
expect(items[0].querySelector('[data-price-alert-action="civitai"]')).not.toBeNull();
});
it('counts unread alerts and clears the count once viewed', async () => {
global.fetch = vi
.fn()
.mockResolvedValue(
createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT, BECAME_FREE_ALERT]))
);
await service.loadPriceAlerts({ force: true });
expect(service.getUnreadPriceAlertCount()).toBe(2);
const badge = document.getElementById('priceAlertsTabBadge');
expect(badge.classList.contains('visible')).toBe(true);
expect(badge.textContent).toBe('2');
service.markPriceAlertsViewed();
expect(service.getUnreadPriceAlertCount()).toBe(0);
expect(badge.classList.contains('visible')).toBe(false);
// A later alert is unread again.
service.priceAlerts = [
{ ...BELOW_THRESHOLD_ALERT, priceAlertSince: 1791000000 + 1000 },
];
expect(service.refreshUnreadPriceAlertCount()).toBe(1);
});
it('explains itself while price tracking is off', async () => {
global.fetch = vi
.fn()
.mockResolvedValue(
createFetchResponse(
alertPayload([BECAME_FREE_ALERT], { enabled: false, thresholdBuzz: 0 })
)
);
await service.loadPriceAlerts({ force: true });
expect(
document.getElementById('priceAlertsDisabled').classList.contains('hidden')
).toBe(false);
expect(document.getElementById('priceAlertsThreshold').textContent).toBe('');
});
it('shows the empty state when the segment has no matches', async () => {
global.fetch = vi
.fn()
.mockResolvedValue(createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT])));
await service.loadPriceAlerts({ force: true });
service.priceAlertSegment = 'became_free';
service.renderPriceAlerts();
const empty = document.getElementById('priceAlertsEmpty');
expect(empty.classList.contains('hidden')).toBe(false);
expect(document.querySelectorAll('#priceAlertsList .price-alert-item')).toHaveLength(0);
});
it('keeps the last known list when the request fails', async () => {
global.fetch = vi
.fn()
.mockResolvedValueOnce(createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT])))
.mockResolvedValueOnce(createFetchResponse({ success: false, error: 'boom' }, false));
await service.loadPriceAlerts({ force: true });
await service.loadPriceAlerts({ force: true });
expect(document.querySelectorAll('#priceAlertsList .price-alert-item')).toHaveLength(1);
const stale = document.getElementById('priceAlertsStale');
expect(stale.classList.contains('hidden')).toBe(false);
});
it('switches to the tab without closing an already-open bell', () => {
global.fetch = vi.fn().mockResolvedValue(createFetchResponse(alertPayload([])));
const toggleSpy = vi.spyOn(service, 'toggleUpdateModal');
const getModalSpy = vi
.spyOn(modalManager, 'getModal')
.mockReturnValue({ isOpen: true });
service.openPriceAlertsTab();
expect(getModalSpy).toHaveBeenCalledWith('updateModal');
// toggleUpdateModal() would have *closed* the open bell.
expect(toggleSpy).not.toHaveBeenCalled();
expect(service.activeNotificationTab).toBe('priceAlerts');
getModalSpy.mockRestore();
toggleSpy.mockRestore();
});
it('opens the bell when it is closed', () => {
global.fetch = vi.fn().mockResolvedValue(createFetchResponse(alertPayload([])));
const toggleSpy = vi
.spyOn(service, 'toggleUpdateModal')
.mockImplementation(() => {});
const getModalSpy = vi
.spyOn(modalManager, 'getModal')
.mockReturnValue({ isOpen: false });
service.openPriceAlertsTab();
expect(toggleSpy).toHaveBeenCalled();
expect(service.activeNotificationTab).toBe('priceAlerts');
getModalSpy.mockRestore();
toggleSpy.mockRestore();
});
});
+220 -1
View File
@@ -6,7 +6,7 @@ import subprocess
import zipfile import zipfile
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
from unittest.mock import patch, MagicMock from unittest.mock import AsyncMock, patch, MagicMock
import pytest import pytest
from aiohttp import web from aiohttp import web
@@ -16,6 +16,8 @@ from py.routes.handlers import misc_handlers
from py.routes.handlers.misc_handlers import ( from py.routes.handlers.misc_handlers import (
BackupHandler, BackupHandler,
DoctorHandler, DoctorHandler,
MiscHandlerSet,
PriceAlertsHandler,
FileSystemHandler, FileSystemHandler,
HealthCheckHandler, HealthCheckHandler,
LoraCodeHandler, LoraCodeHandler,
@@ -2865,3 +2867,220 @@ async def test_sidecar_migration_handler_relocate_root_requires_old_root():
assert response.status == 400 assert response.status == 400
assert "old_root" in payload["error"] assert "old_root" in payload["error"]
assert use_case.calls == [] assert use_case.calls == []
# --- Global price alerts panel endpoint -------------------------------------
class _AnyHandler:
def __getattr__(self, _name):
return lambda request: None
def _stub_misc_handler_set(**overrides) -> MiscHandlerSet:
names = (
"health",
"settings",
"usage_stats",
"lora_code",
"trained_words",
"model_examples",
"node_registry",
"model_library",
"metadata_archive",
"backup",
"filesystem",
"custom_words",
"wildcards",
"supporters",
"doctor",
"example_workflows",
"base_model",
"price_alerts",
"model_source_handler",
"agent_handler",
"download_routing",
"sidecar_migration",
)
handlers = {name: _AnyHandler() for name in names}
handlers.update(overrides)
return MiscHandlerSet(**handlers)
def test_every_misc_route_definition_resolves_to_a_handler():
"""A route added to the table without a mapping entry 500s only on a live
server, so assert the whole table resolves here."""
mapping = _stub_misc_handler_set().to_route_mapping()
assert [
definition.handler_name
for definition in MISC_ROUTE_DEFINITIONS
if definition.handler_name not in mapping
] == []
def test_price_alerts_route_is_registered_once_app_wide():
definitions = [
definition
for definition in MISC_ROUTE_DEFINITIONS
if definition.handler_name == "get_price_alerts"
]
# Exactly one entry, and no {prefix}: the panel spans every model type.
assert len(definitions) == 1
assert definitions[0].method == "GET"
assert definitions[0].path == "/api/lm/price-alerts"
def _price_alerts_adapter(update_service, scanners=None):
async def _unused_scanner():
raise AssertionError("scanner should not be requested in this test")
async def _get_scanner():
return scanners
return ServiceRegistryAdapter(
get_lora_scanner=_get_scanner,
get_checkpoint_scanner=_unused_scanner,
get_embedding_scanner=_unused_scanner,
get_downloaded_version_history_service=_unused_scanner,
get_model_update_service=AsyncMock(return_value=update_service),
)
class _FakeUpdateService:
def __init__(self, alerts):
self.alerts = alerts
self.calls = []
async def get_price_alerts(self, model_type=None, *, threshold_buzz=None, limit=200):
self.calls.append((model_type, threshold_buzz, limit))
return [dict(alert) for alert in self.alerts]
def newest_price_checked_at(self):
return 1791039694.5
@pytest.mark.asyncio
async def test_price_alerts_handler_returns_the_global_list():
update_service = _FakeUpdateService(
[
{
"modelId": 2981320,
"modelType": "checkpoint",
"versionId": 3379626,
"kind": "below_threshold",
"priceBuzz": 250,
}
]
)
settings = DummySettings(
{
"price_tracking_enabled": True,
"price_alert_threshold_buzz": 300,
"civitai_host": "civitai.com",
}
)
handler = PriceAlertsHandler(
settings_service=settings,
service_registry=_price_alerts_adapter(update_service),
)
response = await handler.get_price_alerts(
FakeRequest(method="GET", query={"limit": "50"}) # pyright: ignore[reportArgumentType]
)
payload = _json_payload(response)
assert response.status == 200
assert payload["success"] is True
assert payload["enabled"] is True
assert payload["thresholdBuzz"] == 300
assert payload["newestCheckedAt"] == 1791039694.5
assert payload["alerts"][0]["civitaiUrl"] == (
"https://civitai.com/models/2981320?modelVersionId=3379626"
)
# All model types in one call, with the threshold resolved from settings.
assert update_service.calls == [(None, 300, 50)]
@pytest.mark.asyncio
async def test_price_alerts_handler_decorates_local_context():
update_service = _FakeUpdateService(
[
{
"modelId": 1,
"modelType": "lora",
"versionId": 12,
"kind": "below_threshold",
"priceBuzz": 250,
}
]
)
cache = SimpleNamespace(
model_id_index={1: [{"model_name": "Glorious Art", "file_name": "glorious.safetensors"}]},
version_index={12: {"file_path": "/models/loras/glorious.safetensors", "file_name": "glorious.safetensors"}},
)
class _Scanner:
async def get_cached_data(self):
return cache
async def _get_scanner():
return _Scanner()
adapter = ServiceRegistryAdapter(
get_lora_scanner=_get_scanner,
get_checkpoint_scanner=_get_scanner,
get_embedding_scanner=_get_scanner,
get_downloaded_version_history_service=_get_scanner,
get_model_update_service=AsyncMock(return_value=update_service),
)
handler = PriceAlertsHandler(
settings_service=DummySettings({"civitai_host": "civitai.red"}),
service_registry=adapter,
)
response = await handler.get_price_alerts(
FakeRequest(method="GET") # pyright: ignore[reportArgumentType]
)
alert = _json_payload(response)["alerts"][0]
assert alert["modelName"] == "Glorious Art"
assert alert["filePath"] == "/models/loras/glorious.safetensors"
assert alert["civitaiUrl"].startswith("https://civitai.red/models/1")
@pytest.mark.asyncio
async def test_price_alerts_handler_survives_a_cold_scanner_cache():
"""Local context is best-effort: the panel must still render."""
update_service = _FakeUpdateService(
[{"modelId": 1, "modelType": "lora", "versionId": 12, "kind": "became_free"}]
)
async def _broken_scanner():
raise RuntimeError("scanner not ready")
adapter = ServiceRegistryAdapter(
get_lora_scanner=_broken_scanner,
get_checkpoint_scanner=_broken_scanner,
get_embedding_scanner=_broken_scanner,
get_downloaded_version_history_service=_broken_scanner,
get_model_update_service=AsyncMock(return_value=update_service),
)
handler = PriceAlertsHandler(
settings_service=DummySettings(), service_registry=adapter
)
response = await handler.get_price_alerts(
FakeRequest(method="GET") # pyright: ignore[reportArgumentType]
)
payload = _json_payload(response)
assert response.status == 200
assert payload["success"] is True
# Tracking is off in this fixture, so the panel explains itself.
assert payload["enabled"] is False
assert "modelName" not in payload["alerts"][0]
assert "filePath" not in payload["alerts"][0]
+161
View File
@@ -1723,3 +1723,164 @@ async def test_get_price_alerts_tolerates_bad_limit(tmp_path):
service = ModelUpdateService(str(tmp_path / "updates.sqlite")) service = ModelUpdateService(str(tmp_path / "updates.sqlite"))
assert await service.get_price_alerts("lora", limit="not-a-number") == [] assert await service.get_price_alerts("lora", limit="not-a-number") == []
# --- Price alerts panel semantics (read-time threshold, across types) ---------
def _gated_response_for(version_id: int, ends_at: str) -> dict:
return {
"modelVersions": [
{
"id": version_id,
"baseModel": "Pony",
"availability": "Public",
"paidAccess": {"permanent": False, "endsAt": ends_at},
"files": [],
"images": [],
}
]
}
def _free_response_for(version_id: int) -> dict:
return {
"modelVersions": [
{
"id": version_id,
"baseModel": "Pony",
"availability": "Public",
"files": [],
"images": [],
}
]
}
@pytest.mark.asyncio
async def test_price_alerts_threshold_is_compared_at_read_time(tmp_path):
"""Changing the threshold must change panel membership without a refresh."""
service = _price_service(
tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=300
)
scanner = DummyScanner(LOCAL_RAW_DATA)
await service.refresh_for_model_type(
"lora", scanner, PriceProvider(GATED_RESPONSE, prices=_prices(250))
)
assert len(await service.get_price_alerts("lora")) == 1
# The stored alert state still says "hit"...
stored = await service.get_record("lora", 1)
assert stored.versions[0].price_alert_state is True
# ...but a tighter threshold excludes it immediately.
assert await service.get_price_alerts("lora", threshold_buzz=100) == []
assert len(await service.get_price_alerts("lora", threshold_buzz=300)) == 1
@pytest.mark.asyncio
async def test_price_alerts_span_all_model_types(tmp_path):
"""model_type=None is what the global panel uses: one list, all types."""
service = _price_service(
tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=1000
)
lora_scanner = DummyScanner([{"civitai": {"modelId": 1, "id": 11}}])
checkpoint_scanner = DummyScanner([{"civitai": {"modelId": 2, "id": 21}}])
await service.refresh_for_model_type(
"lora",
lora_scanner,
PriceProvider(_gated_response_for(12, "2999-01-01T00:00:00.000Z"), prices=_prices(250)),
)
await service.refresh_for_model_type(
"checkpoint",
checkpoint_scanner,
PriceProvider(
_gated_response_for(22, "2999-01-01T00:00:00.000Z"),
prices={22: {"price_buzz": 100}},
),
)
everything = await service.get_price_alerts()
assert {(a["modelType"], a["modelId"], a["versionId"]) for a in everything} == {
("lora", 1, 12),
("checkpoint", 2, 22),
}
# Cheapest first.
assert [a["priceBuzz"] for a in everything] == [100, 250]
assert [a["modelType"] for a in await service.get_price_alerts("checkpoint")] == [
"checkpoint"
]
@pytest.mark.asyncio
async def test_price_alert_since_tracks_the_crossing_and_clears(tmp_path):
service = _price_service(
tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=300
)
scanner = DummyScanner(LOCAL_RAW_DATA)
first = await service.refresh_for_model_type(
"lora",
scanner,
PriceProvider(_gated_response("2999-01-01T00:00:00.000Z"), prices=_prices(250)),
)
since = first[1].versions[0].price_alert_since
assert since is not None
# Still under the threshold: the original crossing time is preserved.
second = await service.refresh_for_model_type(
"lora",
scanner,
PriceProvider(_gated_response("2999-02-01T00:00:00.000Z"), prices=_prices(200)),
)
assert second[1].versions[0].price_alert_since == since
stored = await service.get_record("lora", 1)
assert stored.versions[0].price_alert_since == since
# Back above it: cleared, and the row leaves the panel.
third = await service.refresh_for_model_type(
"lora",
scanner,
PriceProvider(_gated_response("2999-03-01T00:00:00.000Z"), prices=_prices(900)),
)
assert third[1].versions[0].price_alert_since is None
assert await service.get_price_alerts("lora") == []
@pytest.mark.asyncio
async def test_price_alerts_report_became_free_without_price_tracking(tmp_path):
"""Gate transitions need no price data, so the "became free" half of the panel
works even while price tracking is switched off."""
service = _price_service(tmp_path) # tracking off
scanner = DummyScanner(LOCAL_RAW_DATA)
await service.refresh_for_model_type("lora", scanner, DummyProvider(GATED_RESPONSE))
assert await service.get_price_alerts("lora") == []
await service.refresh_for_model_type("lora", scanner, DummyProvider(FREE_RESPONSE))
alerts = await service.get_price_alerts("lora")
assert [alert["kind"] for alert in alerts] == ["became_free"]
assert alerts[0]["priceBuzz"] is None
assert alerts[0]["gateLapsedAt"] is not None
assert alerts[0]["versionId"] == 12
@pytest.mark.asyncio
async def test_newest_price_checked_at_reports_the_latest_fetch(tmp_path):
service = _price_service(tmp_path, price_tracking_enabled=True)
scanner = DummyScanner(LOCAL_RAW_DATA)
assert service.newest_price_checked_at() is None
await service.refresh_for_model_type(
"lora", scanner, PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD)
)
newest = service.newest_price_checked_at()
assert newest is not None
assert newest > 0