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