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