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

+
    +
    + + + +
    + `; +} + +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(); + }); +}); diff --git a/tests/routes/test_misc_routes.py b/tests/routes/test_misc_routes.py index 8453ed44..adae23d6 100644 --- a/tests/routes/test_misc_routes.py +++ b/tests/routes/test_misc_routes.py @@ -6,7 +6,7 @@ import subprocess import zipfile from types import SimpleNamespace from typing import Any -from unittest.mock import patch, MagicMock +from unittest.mock import AsyncMock, patch, MagicMock import pytest from aiohttp import web @@ -16,6 +16,8 @@ from py.routes.handlers import misc_handlers from py.routes.handlers.misc_handlers import ( BackupHandler, DoctorHandler, + MiscHandlerSet, + PriceAlertsHandler, FileSystemHandler, HealthCheckHandler, LoraCodeHandler, @@ -2865,3 +2867,220 @@ async def test_sidecar_migration_handler_relocate_root_requires_old_root(): assert response.status == 400 assert "old_root" in payload["error"] assert use_case.calls == [] + + +# --- Global price alerts panel endpoint ------------------------------------- + + +class _AnyHandler: + def __getattr__(self, _name): + return lambda request: None + + +def _stub_misc_handler_set(**overrides) -> MiscHandlerSet: + names = ( + "health", + "settings", + "usage_stats", + "lora_code", + "trained_words", + "model_examples", + "node_registry", + "model_library", + "metadata_archive", + "backup", + "filesystem", + "custom_words", + "wildcards", + "supporters", + "doctor", + "example_workflows", + "base_model", + "price_alerts", + "model_source_handler", + "agent_handler", + "download_routing", + "sidecar_migration", + ) + handlers = {name: _AnyHandler() for name in names} + handlers.update(overrides) + return MiscHandlerSet(**handlers) + + +def test_every_misc_route_definition_resolves_to_a_handler(): + """A route added to the table without a mapping entry 500s only on a live + server, so assert the whole table resolves here.""" + + mapping = _stub_misc_handler_set().to_route_mapping() + + assert [ + definition.handler_name + for definition in MISC_ROUTE_DEFINITIONS + if definition.handler_name not in mapping + ] == [] + + +def test_price_alerts_route_is_registered_once_app_wide(): + definitions = [ + definition + for definition in MISC_ROUTE_DEFINITIONS + if definition.handler_name == "get_price_alerts" + ] + + # Exactly one entry, and no {prefix}: the panel spans every model type. + assert len(definitions) == 1 + assert definitions[0].method == "GET" + assert definitions[0].path == "/api/lm/price-alerts" + + +def _price_alerts_adapter(update_service, scanners=None): + async def _unused_scanner(): + raise AssertionError("scanner should not be requested in this test") + + async def _get_scanner(): + return scanners + + return ServiceRegistryAdapter( + get_lora_scanner=_get_scanner, + get_checkpoint_scanner=_unused_scanner, + get_embedding_scanner=_unused_scanner, + get_downloaded_version_history_service=_unused_scanner, + get_model_update_service=AsyncMock(return_value=update_service), + ) + + +class _FakeUpdateService: + def __init__(self, alerts): + self.alerts = alerts + self.calls = [] + + async def get_price_alerts(self, model_type=None, *, threshold_buzz=None, limit=200): + self.calls.append((model_type, threshold_buzz, limit)) + return [dict(alert) for alert in self.alerts] + + def newest_price_checked_at(self): + return 1791039694.5 + + +@pytest.mark.asyncio +async def test_price_alerts_handler_returns_the_global_list(): + update_service = _FakeUpdateService( + [ + { + "modelId": 2981320, + "modelType": "checkpoint", + "versionId": 3379626, + "kind": "below_threshold", + "priceBuzz": 250, + } + ] + ) + settings = DummySettings( + { + "price_tracking_enabled": True, + "price_alert_threshold_buzz": 300, + "civitai_host": "civitai.com", + } + ) + handler = PriceAlertsHandler( + settings_service=settings, + service_registry=_price_alerts_adapter(update_service), + ) + + response = await handler.get_price_alerts( + FakeRequest(method="GET", query={"limit": "50"}) # pyright: ignore[reportArgumentType] + ) + payload = _json_payload(response) + + assert response.status == 200 + assert payload["success"] is True + assert payload["enabled"] is True + assert payload["thresholdBuzz"] == 300 + assert payload["newestCheckedAt"] == 1791039694.5 + assert payload["alerts"][0]["civitaiUrl"] == ( + "https://civitai.com/models/2981320?modelVersionId=3379626" + ) + # All model types in one call, with the threshold resolved from settings. + assert update_service.calls == [(None, 300, 50)] + + +@pytest.mark.asyncio +async def test_price_alerts_handler_decorates_local_context(): + update_service = _FakeUpdateService( + [ + { + "modelId": 1, + "modelType": "lora", + "versionId": 12, + "kind": "below_threshold", + "priceBuzz": 250, + } + ] + ) + cache = SimpleNamespace( + model_id_index={1: [{"model_name": "Glorious Art", "file_name": "glorious.safetensors"}]}, + version_index={12: {"file_path": "/models/loras/glorious.safetensors", "file_name": "glorious.safetensors"}}, + ) + + class _Scanner: + async def get_cached_data(self): + return cache + + async def _get_scanner(): + return _Scanner() + + adapter = ServiceRegistryAdapter( + get_lora_scanner=_get_scanner, + get_checkpoint_scanner=_get_scanner, + get_embedding_scanner=_get_scanner, + get_downloaded_version_history_service=_get_scanner, + get_model_update_service=AsyncMock(return_value=update_service), + ) + handler = PriceAlertsHandler( + settings_service=DummySettings({"civitai_host": "civitai.red"}), + service_registry=adapter, + ) + + response = await handler.get_price_alerts( + FakeRequest(method="GET") # pyright: ignore[reportArgumentType] + ) + alert = _json_payload(response)["alerts"][0] + + assert alert["modelName"] == "Glorious Art" + assert alert["filePath"] == "/models/loras/glorious.safetensors" + assert alert["civitaiUrl"].startswith("https://civitai.red/models/1") + + +@pytest.mark.asyncio +async def test_price_alerts_handler_survives_a_cold_scanner_cache(): + """Local context is best-effort: the panel must still render.""" + + update_service = _FakeUpdateService( + [{"modelId": 1, "modelType": "lora", "versionId": 12, "kind": "became_free"}] + ) + + async def _broken_scanner(): + raise RuntimeError("scanner not ready") + + adapter = ServiceRegistryAdapter( + get_lora_scanner=_broken_scanner, + get_checkpoint_scanner=_broken_scanner, + get_embedding_scanner=_broken_scanner, + get_downloaded_version_history_service=_broken_scanner, + get_model_update_service=AsyncMock(return_value=update_service), + ) + handler = PriceAlertsHandler( + settings_service=DummySettings(), service_registry=adapter + ) + + response = await handler.get_price_alerts( + FakeRequest(method="GET") # pyright: ignore[reportArgumentType] + ) + payload = _json_payload(response) + + assert response.status == 200 + assert payload["success"] is True + # Tracking is off in this fixture, so the panel explains itself. + assert payload["enabled"] is False + assert "modelName" not in payload["alerts"][0] + assert "filePath" not in payload["alerts"][0] diff --git a/tests/services/test_model_update_service.py b/tests/services/test_model_update_service.py index 728fd5da..733edca0 100644 --- a/tests/services/test_model_update_service.py +++ b/tests/services/test_model_update_service.py @@ -1723,3 +1723,164 @@ async def test_get_price_alerts_tolerates_bad_limit(tmp_path): service = ModelUpdateService(str(tmp_path / "updates.sqlite")) assert await service.get_price_alerts("lora", limit="not-a-number") == [] + + +# --- Price alerts panel semantics (read-time threshold, across types) --------- + + +def _gated_response_for(version_id: int, ends_at: str) -> dict: + return { + "modelVersions": [ + { + "id": version_id, + "baseModel": "Pony", + "availability": "Public", + "paidAccess": {"permanent": False, "endsAt": ends_at}, + "files": [], + "images": [], + } + ] + } + + +def _free_response_for(version_id: int) -> dict: + return { + "modelVersions": [ + { + "id": version_id, + "baseModel": "Pony", + "availability": "Public", + "files": [], + "images": [], + } + ] + } + + +@pytest.mark.asyncio +async def test_price_alerts_threshold_is_compared_at_read_time(tmp_path): + """Changing the threshold must change panel membership without a refresh.""" + + service = _price_service( + tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=300 + ) + scanner = DummyScanner(LOCAL_RAW_DATA) + await service.refresh_for_model_type( + "lora", scanner, PriceProvider(GATED_RESPONSE, prices=_prices(250)) + ) + + assert len(await service.get_price_alerts("lora")) == 1 + # The stored alert state still says "hit"... + stored = await service.get_record("lora", 1) + assert stored.versions[0].price_alert_state is True + # ...but a tighter threshold excludes it immediately. + assert await service.get_price_alerts("lora", threshold_buzz=100) == [] + assert len(await service.get_price_alerts("lora", threshold_buzz=300)) == 1 + + +@pytest.mark.asyncio +async def test_price_alerts_span_all_model_types(tmp_path): + """model_type=None is what the global panel uses: one list, all types.""" + + service = _price_service( + tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=1000 + ) + lora_scanner = DummyScanner([{"civitai": {"modelId": 1, "id": 11}}]) + checkpoint_scanner = DummyScanner([{"civitai": {"modelId": 2, "id": 21}}]) + + await service.refresh_for_model_type( + "lora", + lora_scanner, + PriceProvider(_gated_response_for(12, "2999-01-01T00:00:00.000Z"), prices=_prices(250)), + ) + await service.refresh_for_model_type( + "checkpoint", + checkpoint_scanner, + PriceProvider( + _gated_response_for(22, "2999-01-01T00:00:00.000Z"), + prices={22: {"price_buzz": 100}}, + ), + ) + + everything = await service.get_price_alerts() + assert {(a["modelType"], a["modelId"], a["versionId"]) for a in everything} == { + ("lora", 1, 12), + ("checkpoint", 2, 22), + } + # Cheapest first. + assert [a["priceBuzz"] for a in everything] == [100, 250] + + assert [a["modelType"] for a in await service.get_price_alerts("checkpoint")] == [ + "checkpoint" + ] + + +@pytest.mark.asyncio +async def test_price_alert_since_tracks_the_crossing_and_clears(tmp_path): + service = _price_service( + tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=300 + ) + scanner = DummyScanner(LOCAL_RAW_DATA) + + first = await service.refresh_for_model_type( + "lora", + scanner, + PriceProvider(_gated_response("2999-01-01T00:00:00.000Z"), prices=_prices(250)), + ) + since = first[1].versions[0].price_alert_since + assert since is not None + + # Still under the threshold: the original crossing time is preserved. + second = await service.refresh_for_model_type( + "lora", + scanner, + PriceProvider(_gated_response("2999-02-01T00:00:00.000Z"), prices=_prices(200)), + ) + assert second[1].versions[0].price_alert_since == since + stored = await service.get_record("lora", 1) + assert stored.versions[0].price_alert_since == since + + # Back above it: cleared, and the row leaves the panel. + third = await service.refresh_for_model_type( + "lora", + scanner, + PriceProvider(_gated_response("2999-03-01T00:00:00.000Z"), prices=_prices(900)), + ) + assert third[1].versions[0].price_alert_since is None + assert await service.get_price_alerts("lora") == [] + + +@pytest.mark.asyncio +async def test_price_alerts_report_became_free_without_price_tracking(tmp_path): + """Gate transitions need no price data, so the "became free" half of the panel + works even while price tracking is switched off.""" + + service = _price_service(tmp_path) # tracking off + scanner = DummyScanner(LOCAL_RAW_DATA) + + await service.refresh_for_model_type("lora", scanner, DummyProvider(GATED_RESPONSE)) + assert await service.get_price_alerts("lora") == [] + + await service.refresh_for_model_type("lora", scanner, DummyProvider(FREE_RESPONSE)) + alerts = await service.get_price_alerts("lora") + + assert [alert["kind"] for alert in alerts] == ["became_free"] + assert alerts[0]["priceBuzz"] is None + assert alerts[0]["gateLapsedAt"] is not None + assert alerts[0]["versionId"] == 12 + + +@pytest.mark.asyncio +async def test_newest_price_checked_at_reports_the_latest_fetch(tmp_path): + service = _price_service(tmp_path, price_tracking_enabled=True) + scanner = DummyScanner(LOCAL_RAW_DATA) + + assert service.newest_price_checked_at() is None + + await service.refresh_for_model_type( + "lora", scanner, PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD) + ) + newest = service.newest_price_checked_at() + + assert newest is not None + assert newest > 0