From e42d649df020d1ccacba7bd417840fae016cc4e8 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Mon, 5 Oct 2026 08:54:08 +0800 Subject: [PATCH] refactor(update)!: make obtainability a property of updates, not a surface The owner could not tell from the UI what "Buzz Price Tracking" enabled, what the "Price alert threshold" number meant, or what "Price Alerts" was alerting about. That was not a copy problem: the implementation exposed our mechanism (a page scrape) and our SQL predicates as the user's concepts. Two concrete defects came from the same root: - the alert population included versions the user already owns (neither the event generator nor the query filtered on is_in_library; in the owner's library 28 of 52 gated versions were already downloaded, so most "alerts" were about files already on disk, which cannot become cheaper *for them*); - a threshold-filtered state list lived in a notification surface, so an empty panel had three indistinguishable causes and read as a broken feature. The information model is now the version plus ownership: cost is shown only where a decision exists. Owned -> nothing. Not owned and free -> nothing. Not owned and gated -> the price when it is known, `Paid` without a number when it is not, and early access keeps its countdown because "free on " decides between waiting and paying. The numeric threshold has no place in that model: every decision is categorical (wait / pay / skip), so the setting, the comparison and the whole alert-state machine are gone. - both alert-state columns are removed from the schema rather than left dead; a database created by an unreleased build has them dropped on open (native ALTER TABLE ... DROP COLUMN, guarded), which is a no-op for everyone else - gate events are emitted only for versions the user does not have, and the price-drop event goes with the threshold it belonged to - both alert endpoints, PriceAlertsHandler and the service-registry adapter field it needed are removed: events already reach the UI through the refresh response - the bell tab, panel, CSS, both entry points, the unread watermark and their locales are removed; the setting keeps only the enable flag and the refresh interval and is framed as plumbing - "Price unavailable" is replaced by `Paid`: the gate is certain from the public API, only the number is best-effort, and that is our plumbing, not the user's problem Verified against a copy of the owner's real database: 52 gated versions -> 28 owned (now silent) + 24 the feature is actually about; the drop migration ran and both removed endpoints 404. --- docs/plans/paid-model-price-tracking.md | 129 +++++ locales/de.json | 45 +- locales/en.json | 57 +-- locales/es.json | 45 +- locales/fr.json | 45 +- locales/he.json | 45 +- locales/ja.json | 45 +- locales/ko.json | 45 +- locales/ru.json | 45 +- locales/zh-CN.json | 45 +- locales/zh-TW.json | 45 +- py/routes/handlers/misc_handlers.py | 160 ------- py/routes/handlers/model_handlers.py | 46 -- py/routes/misc_route_registrar.py | 1 - py/routes/misc_routes.py | 6 - py/routes/model_route_registrar.py | 3 - py/services/model_update_service.py | 325 ++----------- py/services/settings_manager.py | 3 +- static/css/components/modal/update-modal.css | 164 ------- .../ContextMenu/GlobalContextMenu.js | 17 - static/js/components/controls/PageControls.js | 12 - .../js/components/shared/ModelVersionsTab.js | 81 ++-- static/js/managers/SettingsManager.js | 5 - static/js/managers/UpdateService.js | 443 ------------------ static/js/state/index.js | 1 - static/js/utils/updateCheckHelpers.js | 16 +- templates/components/context_menu.html | 3 - templates/components/controls.html | 3 - .../components/modals/settings/library.html | 22 - templates/components/modals/update_modal.html | 32 -- .../modelVersionsTab.downloadButton.test.js | 108 +++-- .../managers/updateServicePriceAlerts.test.js | 300 ------------ tests/routes/test_misc_routes.py | 179 +------ tests/routes/test_model_update_handler.py | 50 +- tests/services/test_model_update_service.py | 326 ++++--------- 35 files changed, 403 insertions(+), 2494 deletions(-) delete mode 100644 tests/frontend/managers/updateServicePriceAlerts.test.js diff --git a/docs/plans/paid-model-price-tracking.md b/docs/plans/paid-model-price-tracking.md index b8d91b7e..a378efbb 100644 --- a/docs/plans/paid-model-price-tracking.md +++ b/docs/plans/paid-model-price-tracking.md @@ -683,3 +683,132 @@ The empty state is now threshold-aware, using a new `pricedCount` in the payload is 0 and prices are known, it says how many paid versions have a price and where to set a threshold. The read-time threshold comparison means changing it takes effect immediately — no re-check needed (measured on a copy of that instance's DB: 0 Buzz -> 0 alerts, 100 -> 44, 500 -> 48, 5000 -> 51). + +## 12. Redesign (locked): obtainability of versions you do not own + +### 12.1 Why the P5a model was wrong + +The owner could not tell, from the UI alone, what "Buzz Price Tracking" enables, what the +"Price alert threshold" number means, or what "Price Alerts" is alerting about. That is not a copy +problem: the implementation exposed **our mechanism** (a page scrape) and **our SQL predicates** +(`price_buzz <= ?`, `gate_lapsed_at IS NOT NULL`) as the user's concepts. Two concrete defects came +out of the same root: + +* **The alert population included versions the user already owns.** Neither the event generator nor + the panel query filtered on `is_in_library` (the payload even carried `isInLibrary`). In the + owner's library **28 of 52 gated versions were already downloaded** — more than half of the + "alerts" were about things already on disk. A version you own cannot become cheaper *for you*. +* **A filtered list in a notification surface.** "Price Alerts" was a state list whose contents were + entirely decided by a number stored in Settings, so an empty panel had three indistinguishable + causes (tracking off / threshold 0 / genuinely nothing), and it read as a broken feature. + +Two measurements shaped the correction: + +* **Permanent prices dominate** (87 permanent vs 3 timed gates in a 600-model sample), so "wait for a + price drop" is a low-frequency proposition, while "wait for early access to end" is high-frequency + and deterministic. +* **Prices cluster at the floor** (44 of the owner's 52 priced versions are 100 Buzz), so + "price <= N" carries little information; the real distinction is *free / soon free / paid*. + +Also corrected during the discussion: the **generation fee** is the cost of generating *on the +CivitAI site*. A locally downloaded model generated in ComfyUI has no such fee, so for this tool it +is not a price axis at all (42 of the owner's 52 gated versions carry one — displaying it would +mislead on 80% of paid rows). It is not displayed today and must stay that way. + +### 12.2 The corrected information model + +The unit is the **version**, and the boundary is **ownership**: information exists only where a +decision exists. + +| State of a version **not in the library** | The user's decision | What is shown | +| --- | --- | --- | +| Downloadable for free | download or not | nothing special (the download action already says it) | +| Early access, free on D, buyable for N | **wait until D / pay N now** | `N Buzz - free on D` | +| Early access, free on D, not buyable | wait | `free on D` | +| Permanently paid, price known (N) | buy or skip | `N Buzz` | +| Permanently paid, price unknown | buy or skip, price unknown | `Paid` (no number) | +| **In the library** | none | **nothing** | + +The numeric threshold has no place in this table: every decision is categorical (wait / pay / skip), +not numeric. + +### 12.3 Locked decisions + +* **D1 - No independent surface.** Obtainability is an attribute of the existing update surfaces: + the model card's update badge, the grid's "models with updates" filter, the post-check toast, and + the model modal's version list. No new bell tab. +* **D2 - No numeric threshold anywhere.** The setting, the comparison, and the tests around it go. +* **D3 - Only versions that are not in the library** produce events or badges (`is_in_library = 0`), + enforced in both the event generator and any query. +* **D4 - Owned versions show no download price** (and carry no alert state). +* **D5 - The generation fee is never displayed.** +* **D6 - Models outside the library are out of scope.** Their decision moment is the download/browse + flow; that is a recorded follow-up, not a background feed (the update service only knows local + models, and there is no follow mechanism to build on). +* **D7 - The plumbing stays**: page fetch, host fallback, attempt markers, both TTLs, storage, + parser, `gate_lapsed_at`, and the price columns. + +### 12.4 What happens to what shipped in P5a + +| Piece | Fate | +| --- | --- | +| `GET /api/lm/price-alerts`, `GET /api/lm/{prefix}/updates/price-alerts`, `PriceAlertsHandler` | **removed** - no surface consumes them; events reach the UI through the refresh response | +| `get_price_alerts`, `newest_price_checked_at`, `count_priced_versions`, `count_unavailable_prices` | **removed** (threshold comparison and the panel's counters) | +| `price_alert_threshold_buzz` (settings, `_price_alert_threshold_buzz`) | **removed** | +| `price_alert_since` | **stop writing**; the column stays (SQLite drops need a table rebuild) and is marked deprecated | +| `price_alert_state` | **kept** - it is the one-shot edge for the "became free" toast | +| Bell tab, panel markup, `.price-alerts*` CSS, `UpdateService` panel code, watermark, the two entry points (controls dropdown, global context menu), their locales | **removed** | +| Version badges | **reworked** to the 12.2 table, ownership-scoped | +| "Price unavailable" badge | **replaced** by `Paid` (the gate is known from the public API; only the number is best-effort, and that is our plumbing, not the user's problem) | +| `priceAttemptedAt` in the version payload | **removed** from the payload (kept internally for the price-TTL retry policy) | +| The "became free" toast | **kept**, reworded, ownership-scoped | + +### 12.5 Tasks + +**P6a - the core correction (no new surface).** +Backend: ownership-scope the event generator; remove the threshold, the alert query, the counters, +the two routes and the handler; stop writing `price_alert_since`. Frontend: rework the version +badges to the 12.2 table; delete the panel, tab, entry points, watermark and their locales; reword +the toast. Tests: replace the panel/alert suites with a badge-state matrix and ownership-scoping +tests; assert the removed routes are gone. + +**P6b - obtainability in the update surfaces.** +Per-model obtainability summary in the model list payload (next to `hasUpdate`) so the card badge can +say `Update - 500 Buzz` / `Update - free on Oct 15`; toast copy that counts the free ones +("3 models have updates, 1 is now free"). + +**P6c - deferred (recorded, not planned).** A price-drop event (rare by measurement); obtainability +in the download/browse flow for models outside the library (D6). + +### 12.6 Verification + +Unit tests for the state matrix and the ownership filter; a sandbox run against a **copy** of the +owner's real update DB, where the population must shrink from 52 gated versions to the **24 that are +not in the library**, with the owned ones showing nothing. + +### 12.7 What shipped (P6a) + +* The two alert-state columns are **gone from the schema** (not merely unused): the dataclass, + `_SCHEMA`, the additive migration table, the primary-key rebuild's column list and defaults, the + bulk `SELECT`, the row mapping and the `INSERT`. A database created by an unreleased build still + has them, so `_apply_migrations` drops them with a native `ALTER TABLE ... DROP COLUMN` (SQLite + 3.35+, guarded) and logs it; for everyone else it is a no-op. +* `price_alert_threshold_buzz`, `_price_alert_threshold_buzz`, `_evaluate_price_alert`, the + threshold-crossing event, `get_price_alerts`, `newest_price_checked_at`, + `count_priced_versions`, `count_unavailable_prices`, both alert routes, `PriceAlertsHandler`, its + handler-set slot, its route mapping entry and the service-registry adapter field it needed: all + removed. +* Gate events (and only gate events) are emitted, and only for versions the user does not have - + `isInLibrary` is now the filter rather than a field in the payload. +* Version badges follow the 12.2 table: cost information only for versions not in the library, the + price replaces the redundant `Paid` badge when it is known, `Paid` appears without a number when + it is not, and `Price unavailable` is gone (the gate is certain; only the number is best-effort). +* The bell tab, panel markup, panel CSS, the controls-dropdown and global-context-menu entries, the + unread watermark and their locales are removed; the toast keeps its gate transitions and drops the + price-drop line. The setting is now framed as plumbing ("Show download prices for paid versions") + and keeps only the enable flag and the refresh interval. + +Verified: `pytest` 3653 passed / 7 skipped, `npm run test:js` 1444 passed. Against a copy of the +owner's real database the population is 52 gated versions -> **28 owned (now silent) + 24 that the +feature is actually about**; the drop migration logged twice and left no `price_alert_*` column, and +both removed endpoints return 404. diff --git a/locales/de.json b/locales/de.json index caf22b45..a762479e 100644 --- a/locales/de.json +++ b/locales/de.json @@ -204,7 +204,6 @@ "error": "Fehler beim Prüfen auf {type}-Updates: {message}", "gateEvents": { "becameFree": "[TODO: Translate] {count} version(s) became free", - "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", "newGate": "[TODO: Translate] {count} version(s) are now paid" } }, @@ -234,9 +233,6 @@ }, "groupByModel": { "label": "Nach Modell gruppieren" - }, - "priceAlerts": { - "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -767,8 +763,6 @@ "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", "enabled": "[TODO: Translate] Track Buzz prices for paid versions", "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "threshold": "[TODO: Translate] Price alert threshold (Buzz)", - "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", "refreshHours": "[TODO: Translate] Price refresh interval (hours)", "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" }, @@ -945,9 +939,7 @@ "action": "Updates", "menuLabel": "Weitere Update-Optionen anzeigen", "check": "Updates prüfen", - "checkTooltip": "Die Aktualisierungssuche kann einige Zeit dauern.", - "priceAlerts": "[TODO: Translate] Price alerts", - "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" + "checkTooltip": "Die Aktualisierungssuche kann einige Zeit dauern." } }, "bulkOperations": { @@ -2031,15 +2023,13 @@ "paidTooltip": "Diese Version erfordert eine Zahlung zum Herunterladen", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", "freeNow": "[TODO: Translate] Free Now", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "Ignoriert", "ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert", "onSiteOnly": "Nur On-Site", "onSiteOnlyTooltip": "Diese Version ist nur für die On-Site-Generierung auf CivitAI verfügbar", - "priceUnavailable": "[TODO: Translate] Price unavailable", - "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" + "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" }, "actions": { "download": "Herunterladen", @@ -2328,8 +2318,7 @@ "notificationsTitle": "Benachrichtigungszentrum", "tabs": { "updates": "Aktualisierungen", - "messages": "Mitteilungen", - "priceAlerts": "[TODO: Translate] Price Alerts" + "messages": "Mitteilungen" }, "updateAvailable": "Update verfügbar", "noChangelogAvailable": "Kein detailliertes Changelog verfügbar. Weitere Informationen auf GitHub.", @@ -2385,34 +2374,6 @@ "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", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", - "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/en.json b/locales/en.json index 0d34f544..6bf9c749 100644 --- a/locales/en.json +++ b/locales/en.json @@ -203,8 +203,7 @@ "none": "All {type}s are up to date", "error": "Failed to check for {type} updates: {message}", "gateEvents": { - "becameFree": "{count} version(s) became free", - "priceDrop": "{count} version(s) dropped below your price threshold", + "becameFree": "{count} version(s) you do not have became free", "newGate": "{count} version(s) are now paid" } }, @@ -234,9 +233,6 @@ }, "groupByModel": { "label": "Group by Model" - }, - "priceAlerts": { - "label": "Price alerts" } }, "header": { @@ -763,14 +759,12 @@ "help": "When enabled, models with only paid updates will not show 'Update available' badge" }, "priceTracking": { - "label": "Buzz Price Tracking", - "help": "CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", - "enabled": "Track Buzz prices for paid versions", - "enabledHelp": "Show the price on paid and early access versions, and alert when a price drops below your threshold", - "threshold": "Price alert threshold (Buzz)", - "thresholdHelp": "Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", + "label": "Buzz Download Prices", + "help": "CivitAI does not publish download prices in its public API, so they are read from the model page - one extra request per paid model, refreshed on the interval below. Prices are shown on versions you do not have yet.", + "enabled": "Show download prices for paid versions", + "enabledHelp": "Reads the download price from the model page so paid and early access versions show what they cost, and when early access ends", "refreshHours": "Price refresh interval (hours)", - "refreshHoursHelp": "How long a stored price stays fresh before it is fetched again" + "refreshHoursHelp": "How long a stored price stays fresh before it is read again" }, "licenseIcons": { "useNewStyle": "Use updated license icons", @@ -945,9 +939,7 @@ "action": "Updates", "menuLabel": "Show update options", "check": "Check updates", - "checkTooltip": "Checking updates may take a while.", - "priceAlerts": "Price alerts", - "priceAlertsTooltip": "Buzz price alerts for paid versions" + "checkTooltip": "Checking updates may take a while." } }, "bulkOperations": { @@ -2031,15 +2023,13 @@ "paidTooltip": "This version requires payment to download", "priceSaleTooltip": "On sale, normally {list} Buzz", "priceBlueBuzzTooltip": "Can also be paid with Blue Buzz", - "priceAlertTooltip": "{price} - below your price alert threshold", "freeNow": "Free Now", "freeNowTooltip": "This version no longer requires payment", "ignored": "Ignored", "ignoredTooltip": "Update notifications are disabled for this version", "onSiteOnly": "On-Site Only", "onSiteOnlyTooltip": "This version is only available for on-site generation on CivitAI", - "priceUnavailable": "Price unavailable", - "priceUnavailableTooltip": "CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" + "earlyAccessTooltipUntil": "Early access until {date}, free to download after that" }, "actions": { "download": "Download", @@ -2328,8 +2318,7 @@ "notificationsTitle": "Notifications", "tabs": { "updates": "Updates", - "messages": "Messages", - "priceAlerts": "Price Alerts" + "messages": "Messages" }, "updateAvailable": "Update Available", "noChangelogAvailable": "No detailed changelog available. Check GitHub for more information.", @@ -2385,34 +2374,6 @@ "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", - "unavailable": "{count} paid version(s) have no readable price (mature models can only be read in a browser)", - "emptyZeroThreshold": "{count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/es.json b/locales/es.json index b75fc19c..2349ec7b 100644 --- a/locales/es.json +++ b/locales/es.json @@ -204,7 +204,6 @@ "error": "Error al buscar actualizaciones de {type}: {message}", "gateEvents": { "becameFree": "[TODO: Translate] {count} version(s) became free", - "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", "newGate": "[TODO: Translate] {count} version(s) are now paid" } }, @@ -234,9 +233,6 @@ }, "groupByModel": { "label": "Agrupar por modelo" - }, - "priceAlerts": { - "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -767,8 +763,6 @@ "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", "enabled": "[TODO: Translate] Track Buzz prices for paid versions", "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "threshold": "[TODO: Translate] Price alert threshold (Buzz)", - "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", "refreshHours": "[TODO: Translate] Price refresh interval (hours)", "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" }, @@ -945,9 +939,7 @@ "action": "Actualizaciones", "menuLabel": "Mostrar opciones de actualización", "check": "Buscar actualizaciones", - "checkTooltip": "Comprobar actualizaciones puede tardar.", - "priceAlerts": "[TODO: Translate] Price alerts", - "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" + "checkTooltip": "Comprobar actualizaciones puede tardar." } }, "bulkOperations": { @@ -2031,15 +2023,13 @@ "paidTooltip": "Esta versión requiere pago para descargarse", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", "freeNow": "[TODO: Translate] Free Now", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "Ignorada", "ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión", "onSiteOnly": "Solo en Sitio", "onSiteOnlyTooltip": "Esta versión solo está disponible para generación en el sitio de CivitAI", - "priceUnavailable": "[TODO: Translate] Price unavailable", - "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" + "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" }, "actions": { "download": "Descargar", @@ -2328,8 +2318,7 @@ "notificationsTitle": "Centro de notificaciones", "tabs": { "updates": "Actualizaciones", - "messages": "Mensajes", - "priceAlerts": "[TODO: Translate] Price Alerts" + "messages": "Mensajes" }, "updateAvailable": "Actualización disponible", "noChangelogAvailable": "No hay registro de cambios detallado disponible. Revisa GitHub para más información.", @@ -2385,34 +2374,6 @@ "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", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", - "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/fr.json b/locales/fr.json index a9699424..f016f7fa 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -204,7 +204,6 @@ "error": "Échec de la vérification des mises à jour pour {type} : {message}", "gateEvents": { "becameFree": "[TODO: Translate] {count} version(s) became free", - "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", "newGate": "[TODO: Translate] {count} version(s) are now paid" } }, @@ -234,9 +233,6 @@ }, "groupByModel": { "label": "Grouper par modèle" - }, - "priceAlerts": { - "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -767,8 +763,6 @@ "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", "enabled": "[TODO: Translate] Track Buzz prices for paid versions", "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "threshold": "[TODO: Translate] Price alert threshold (Buzz)", - "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", "refreshHours": "[TODO: Translate] Price refresh interval (hours)", "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" }, @@ -945,9 +939,7 @@ "action": "Mises à jour", "menuLabel": "Afficher les options de mise à jour", "check": "Rechercher des mises à jour", - "checkTooltip": "La vérification peut prendre du temps.", - "priceAlerts": "[TODO: Translate] Price alerts", - "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" + "checkTooltip": "La vérification peut prendre du temps." } }, "bulkOperations": { @@ -2031,15 +2023,13 @@ "paidTooltip": "Cette version nécessite un paiement pour être téléchargée", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", "freeNow": "[TODO: Translate] Free Now", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "Ignorée", "ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version", "onSiteOnly": "Uniquement sur Site", "onSiteOnlyTooltip": "Cette version n'est disponible que pour la génération sur le site CivitAI", - "priceUnavailable": "[TODO: Translate] Price unavailable", - "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" + "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" }, "actions": { "download": "Télécharger", @@ -2328,8 +2318,7 @@ "notificationsTitle": "Notifications", "tabs": { "updates": "Mises à jour", - "messages": "Messages", - "priceAlerts": "[TODO: Translate] Price Alerts" + "messages": "Messages" }, "updateAvailable": "Mise à jour disponible", "noChangelogAvailable": "Aucun journal des modifications détaillé disponible. Consultez GitHub pour plus d'informations.", @@ -2385,34 +2374,6 @@ "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", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", - "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/he.json b/locales/he.json index 93a6ef6e..2d602130 100644 --- a/locales/he.json +++ b/locales/he.json @@ -204,7 +204,6 @@ "error": "נכשל בבדיקת העדכונים עבור {type}: {message}", "gateEvents": { "becameFree": "[TODO: Translate] {count} version(s) became free", - "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", "newGate": "[TODO: Translate] {count} version(s) are now paid" } }, @@ -234,9 +233,6 @@ }, "groupByModel": { "label": "קיבוץ לפי מודל" - }, - "priceAlerts": { - "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -767,8 +763,6 @@ "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", "enabled": "[TODO: Translate] Track Buzz prices for paid versions", "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "threshold": "[TODO: Translate] Price alert threshold (Buzz)", - "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", "refreshHours": "[TODO: Translate] Price refresh interval (hours)", "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" }, @@ -945,9 +939,7 @@ "action": "עדכונים", "menuLabel": "הצגת אפשרויות עדכון", "check": "בדוק עדכונים", - "checkTooltip": "בדיקת עדכונים עלולה לקחת זמן.", - "priceAlerts": "[TODO: Translate] Price alerts", - "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" + "checkTooltip": "בדיקת עדכונים עלולה לקחת זמן." } }, "bulkOperations": { @@ -2031,15 +2023,13 @@ "paidTooltip": "גרסה זו דורשת תשלום כדי להוריד", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", "freeNow": "[TODO: Translate] Free Now", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "התעלם", "ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו", "onSiteOnly": "רק באתר", "onSiteOnlyTooltip": "גרסה זו זמינה רק ליצירה באתר CivitAI", - "priceUnavailable": "[TODO: Translate] Price unavailable", - "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" + "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" }, "actions": { "download": "הורדה", @@ -2328,8 +2318,7 @@ "notificationsTitle": "מרכז התראות", "tabs": { "updates": "עדכונים", - "messages": "הודעות", - "priceAlerts": "[TODO: Translate] Price Alerts" + "messages": "הודעות" }, "updateAvailable": "עדכון זמין", "noChangelogAvailable": "אין יומן שינויים מפורט זמין. בדוק ב-GitHub למידע נוסף.", @@ -2385,34 +2374,6 @@ "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", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", - "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/ja.json b/locales/ja.json index b5e0f170..aef5fe03 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -204,7 +204,6 @@ "error": "{type} のアップデート確認に失敗しました: {message}", "gateEvents": { "becameFree": "[TODO: Translate] {count} version(s) became free", - "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", "newGate": "[TODO: Translate] {count} version(s) are now paid" } }, @@ -234,9 +233,6 @@ }, "groupByModel": { "label": "モデルでグループ化" - }, - "priceAlerts": { - "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -767,8 +763,6 @@ "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", "enabled": "[TODO: Translate] Track Buzz prices for paid versions", "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "threshold": "[TODO: Translate] Price alert threshold (Buzz)", - "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", "refreshHours": "[TODO: Translate] Price refresh interval (hours)", "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" }, @@ -945,9 +939,7 @@ "action": "アップデート", "menuLabel": "更新オプションを表示", "check": "アップデートを確認", - "checkTooltip": "確認には時間がかかる場合があります。", - "priceAlerts": "[TODO: Translate] Price alerts", - "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" + "checkTooltip": "確認には時間がかかる場合があります。" } }, "bulkOperations": { @@ -2031,15 +2023,13 @@ "paidTooltip": "このバージョンのダウンロードには支払いが必要です", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", "freeNow": "[TODO: Translate] Free Now", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "無視中", "ignoredTooltip": "このバージョンの更新通知は無効です", "onSiteOnly": "サイト内のみ", "onSiteOnlyTooltip": "このバージョンはCivitAIサイト内でのみ利用可能で、ダウンロードはできません", - "priceUnavailable": "[TODO: Translate] Price unavailable", - "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" + "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" }, "actions": { "download": "ダウンロード", @@ -2328,8 +2318,7 @@ "notificationsTitle": "通知センター", "tabs": { "updates": "更新", - "messages": "メッセージ", - "priceAlerts": "[TODO: Translate] Price Alerts" + "messages": "メッセージ" }, "updateAvailable": "更新が利用可能", "noChangelogAvailable": "詳細な変更ログは利用できません。詳細はGitHubでご確認ください。", @@ -2385,34 +2374,6 @@ "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", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", - "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/ko.json b/locales/ko.json index 6914e74f..cf074d32 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -204,7 +204,6 @@ "error": "{type} 업데이트 확인 실패: {message}", "gateEvents": { "becameFree": "[TODO: Translate] {count} version(s) became free", - "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", "newGate": "[TODO: Translate] {count} version(s) are now paid" } }, @@ -234,9 +233,6 @@ }, "groupByModel": { "label": "모델별 그룹화" - }, - "priceAlerts": { - "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -767,8 +763,6 @@ "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", "enabled": "[TODO: Translate] Track Buzz prices for paid versions", "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "threshold": "[TODO: Translate] Price alert threshold (Buzz)", - "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", "refreshHours": "[TODO: Translate] Price refresh interval (hours)", "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" }, @@ -945,9 +939,7 @@ "action": "업데이트", "menuLabel": "업데이트 옵션 표시", "check": "업데이트 확인", - "checkTooltip": "업데이트 확인에는 시간이 걸릴 수 있습니다.", - "priceAlerts": "[TODO: Translate] Price alerts", - "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" + "checkTooltip": "업데이트 확인에는 시간이 걸릴 수 있습니다." } }, "bulkOperations": { @@ -2031,15 +2023,13 @@ "paidTooltip": "이 버전은 다운로드하려면 결제가 필요합니다", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", "freeNow": "[TODO: Translate] Free Now", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "무시됨", "ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다", "onSiteOnly": "사이트 내 전용", "onSiteOnlyTooltip": "이 버전은 CivitAI 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다", - "priceUnavailable": "[TODO: Translate] Price unavailable", - "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" + "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" }, "actions": { "download": "다운로드", @@ -2328,8 +2318,7 @@ "notificationsTitle": "알림 센터", "tabs": { "updates": "업데이트", - "messages": "메시지", - "priceAlerts": "[TODO: Translate] Price Alerts" + "messages": "메시지" }, "updateAvailable": "업데이트 사용 가능", "noChangelogAvailable": "상세한 변경 로그가 없습니다. 더 많은 정보는 GitHub를 확인하세요.", @@ -2385,34 +2374,6 @@ "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", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", - "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/ru.json b/locales/ru.json index b7272b03..9137eb27 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -204,7 +204,6 @@ "error": "Не удалось проверить обновления для {type}: {message}", "gateEvents": { "becameFree": "[TODO: Translate] {count} version(s) became free", - "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", "newGate": "[TODO: Translate] {count} version(s) are now paid" } }, @@ -234,9 +233,6 @@ }, "groupByModel": { "label": "Группировать по модели" - }, - "priceAlerts": { - "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -767,8 +763,6 @@ "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", "enabled": "[TODO: Translate] Track Buzz prices for paid versions", "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "threshold": "[TODO: Translate] Price alert threshold (Buzz)", - "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", "refreshHours": "[TODO: Translate] Price refresh interval (hours)", "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" }, @@ -945,9 +939,7 @@ "action": "Обновления", "menuLabel": "Показать параметры обновления", "check": "Проверить обновления", - "checkTooltip": "Проверка может занять время.", - "priceAlerts": "[TODO: Translate] Price alerts", - "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" + "checkTooltip": "Проверка может занять время." } }, "bulkOperations": { @@ -2031,15 +2023,13 @@ "paidTooltip": "Скачивание этой версии платное", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", "freeNow": "[TODO: Translate] Free Now", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "Игнорируется", "ignoredTooltip": "Уведомления об обновлениях для этой версии отключены", "onSiteOnly": "Только на Сайте", "onSiteOnlyTooltip": "Эта версия доступна только для генерации на сайте CivitAI", - "priceUnavailable": "[TODO: Translate] Price unavailable", - "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" + "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" }, "actions": { "download": "Скачать", @@ -2328,8 +2318,7 @@ "notificationsTitle": "Центр уведомлений", "tabs": { "updates": "Обновления", - "messages": "Сообщения", - "priceAlerts": "[TODO: Translate] Price Alerts" + "messages": "Сообщения" }, "updateAvailable": "Доступно обновление", "noChangelogAvailable": "Подробный список изменений недоступен. Проверьте GitHub для получения дополнительной информации.", @@ -2385,34 +2374,6 @@ "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", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", - "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 16a0fc46..0037373f 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -204,7 +204,6 @@ "error": "检查 {type} 更新失败:{message}", "gateEvents": { "becameFree": "[TODO: Translate] {count} version(s) became free", - "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", "newGate": "[TODO: Translate] {count} version(s) are now paid" } }, @@ -234,9 +233,6 @@ }, "groupByModel": { "label": "按模型分组" - }, - "priceAlerts": { - "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -767,8 +763,6 @@ "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", "enabled": "[TODO: Translate] Track Buzz prices for paid versions", "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "threshold": "[TODO: Translate] Price alert threshold (Buzz)", - "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", "refreshHours": "[TODO: Translate] Price refresh interval (hours)", "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" }, @@ -945,9 +939,7 @@ "action": "更新", "menuLabel": "显示更新选项", "check": "检查更新", - "checkTooltip": "检查更新可能耗时。", - "priceAlerts": "[TODO: Translate] Price alerts", - "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" + "checkTooltip": "检查更新可能耗时。" } }, "bulkOperations": { @@ -2031,15 +2023,13 @@ "paidTooltip": "此版本需要付费后才能下载", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", "freeNow": "[TODO: Translate] Free Now", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "已忽略", "ignoredTooltip": "此版本已关闭更新通知", "onSiteOnly": "仅站内生成", "onSiteOnlyTooltip": "此版本仅在 CivitAI 站内可用,无法下载", - "priceUnavailable": "[TODO: Translate] Price unavailable", - "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" + "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" }, "actions": { "download": "下载", @@ -2328,8 +2318,7 @@ "notificationsTitle": "通知中心", "tabs": { "updates": "更新", - "messages": "消息", - "priceAlerts": "[TODO: Translate] Price Alerts" + "messages": "消息" }, "updateAvailable": "更新可用", "noChangelogAvailable": "没有详细的更新日志可用。请查看 GitHub 以获取更多信息。", @@ -2385,34 +2374,6 @@ "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", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", - "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 8767b248..a9fb564c 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -204,7 +204,6 @@ "error": "檢查 {type} 更新失敗:{message}", "gateEvents": { "becameFree": "[TODO: Translate] {count} version(s) became free", - "priceDrop": "[TODO: Translate] {count} version(s) dropped below your price threshold", "newGate": "[TODO: Translate] {count} version(s) are now paid" } }, @@ -234,9 +233,6 @@ }, "groupByModel": { "label": "按模型分組" - }, - "priceAlerts": { - "label": "[TODO: Translate] Price alerts" } }, "header": { @@ -767,8 +763,6 @@ "help": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page - one extra request per paid model, refreshed on the interval below. Requires an update check to pick up changes.", "enabled": "[TODO: Translate] Track Buzz prices for paid versions", "enabledHelp": "[TODO: Translate] Show the price on paid and early access versions, and alert when a price drops below your threshold", - "threshold": "[TODO: Translate] Price alert threshold (Buzz)", - "thresholdHelp": "[TODO: Translate] Alert when a paid version costs this much or less. Use 0 to only be told when a version becomes free.", "refreshHours": "[TODO: Translate] Price refresh interval (hours)", "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" }, @@ -945,9 +939,7 @@ "action": "更新", "menuLabel": "顯示更新選項", "check": "檢查更新", - "checkTooltip": "檢查更新可能耗時。", - "priceAlerts": "[TODO: Translate] Price alerts", - "priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions" + "checkTooltip": "檢查更新可能耗時。" } }, "bulkOperations": { @@ -2031,15 +2023,13 @@ "paidTooltip": "此版本需要付費才能下載", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue Buzz", - "priceAlertTooltip": "[TODO: Translate] {price} - below your price alert threshold", "freeNow": "[TODO: Translate] Free Now", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "ignored": "已忽略", "ignoredTooltip": "此版本已關閉更新通知", "onSiteOnly": "僅站內生成", "onSiteOnlyTooltip": "此版本僅在 CivitAI 站內可用,無法下載", - "priceUnavailable": "[TODO: Translate] Price unavailable", - "priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser" + "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that" }, "actions": { "download": "下載", @@ -2328,8 +2318,7 @@ "notificationsTitle": "通知中心", "tabs": { "updates": "更新", - "messages": "訊息", - "priceAlerts": "[TODO: Translate] Price Alerts" + "messages": "訊息" }, "updateAvailable": "有新版本可用", "noChangelogAvailable": "無詳細更新日誌。請至 GitHub 查看更多資訊。", @@ -2385,34 +2374,6 @@ "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", - "unavailable": "[TODO: Translate] {count} paid version(s) have no readable price (mature models can only be read in a browser)", - "emptyZeroThreshold": "[TODO: Translate] {count} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops." } }, "support": { diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index 9fa82a62..459a6221 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -2242,11 +2242,6 @@ 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: @@ -4319,157 +4314,6 @@ 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() - unavailable_count = update_service.count_unavailable_prices() - priced_count = update_service.count_priced_versions() - 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, - # Gated versions whose price could not be read (mature models - # are served only by the challenged civitai.red host). - "unavailableCount": unavailable_count, - # Lets the empty state explain a 0 Buzz threshold instead of - # claiming there is simply nothing to show. - "pricedCount": priced_count, - "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.""" @@ -4493,7 +4337,6 @@ 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, @@ -4516,7 +4359,6 @@ 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 @@ -4530,7 +4372,6 @@ 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, @@ -4607,5 +4448,4 @@ 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/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index 4d090821..f7c74c58 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -3070,46 +3070,6 @@ class ModelUpdateHandler: {"success": True, "record": self._serialize_record(record)} ) - async def get_price_alerts(self, request: web.Request) -> web.Response: - """List gated versions whose stored price crosses the alert threshold. - - Backed by the persisted alert state, so it matches the badges; the - threshold (and whether price tracking runs at all) comes from settings. - """ - - try: - limit = int(request.query.get("limit") or 200) - except (TypeError, ValueError): - limit = 200 - - alerts = await self._update_service.get_price_alerts( - self._service.model_type, limit=limit - ) - threshold = 0 - enabled = False - if self._settings is not None: - try: - threshold = int( - self._settings.get("price_alert_threshold_buzz", 0) or 0 - ) - except (TypeError, ValueError): - threshold = 0 - try: - enabled = bool( - self._settings.get("price_tracking_enabled", False) - ) - except Exception: - enabled = False - - return web.json_response( - { - "success": True, - "enabled": enabled, - "thresholdBuzz": threshold, - "alerts": alerts, - } - ) - async def get_model_versions(self, request: web.Request) -> web.Response: model_id = self._normalize_model_id(request.match_info.get("model_id")) if model_id is None: @@ -3511,11 +3471,6 @@ class ModelUpdateHandler: "acceptsBlueBuzz": bool(getattr(version, "accepts_blue_buzz", False)), "priceSaleEndsAt": getattr(version, "price_sale_ends_at", None), "priceCheckedAt": getattr(version, "price_checked_at", None), - # Set when a price fetch was attempted (success or not): a gated - # version with no price and an attempt marker is "unavailable", - # which is the honest state for mature models. - "priceAttemptedAt": getattr(version, "price_check_attempted_at", None), - "priceAlert": bool(getattr(version, "price_alert_state", False)), "filePath": context.get("file_path"), "fileName": context.get("file_name"), # Weight-file variant count (None when unknown); lets the UI hide @@ -3678,6 +3633,5 @@ class ModelHandlerSet: "set_version_update_ignore": self.updates.set_version_update_ignore, "get_model_update_status": self.updates.get_model_update_status, "get_model_versions": self.updates.get_model_versions, - "get_price_alerts": self.updates.get_price_alerts, "cancel_task": self.query.cancel_task, } diff --git a/py/routes/misc_route_registrar.py b/py/routes/misc_route_registrar.py index b479391f..73e5311d 100644 --- a/py/routes/misc_route_registrar.py +++ b/py/routes/misc_route_registrar.py @@ -24,7 +24,6 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( 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 bc1444e3..03b980a0 100644 --- a/py/routes/misc_routes.py +++ b/py/routes/misc_routes.py @@ -31,7 +31,6 @@ from .handlers.misc_handlers import ( ModelLibraryHandler, NodeRegistry, NodeRegistryHandler, - PriceAlertsHandler, SettingsHandler, SidecarMigrationHandler, SupportersHandler, @@ -141,10 +140,6 @@ 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() @@ -168,7 +163,6 @@ 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/routes/model_route_registrar.py b/py/routes/model_route_registrar.py index d62812be..066b3fa3 100644 --- a/py/routes/model_route_registrar.py +++ b/py/routes/model_route_registrar.py @@ -110,9 +110,6 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition( "GET", "/api/lm/{prefix}/updates/versions/{model_id}", "get_model_versions" ), - RouteDefinition( - "GET", "/api/lm/{prefix}/updates/price-alerts", "get_price_alerts" - ), RouteDefinition("POST", "/api/lm/download-model", "download_model"), RouteDefinition("GET", "/api/lm/download-model-get", "download_model_get"), RouteDefinition("GET", "/api/lm/cancel-download-get", "cancel_download_get"), diff --git a/py/services/model_update_service.py b/py/services/model_update_service.py index 2e904535..d90ed9ab 100644 --- a/py/services/model_update_service.py +++ b/py/services/model_update_service.py @@ -121,13 +121,6 @@ class ModelVersionRecord: accepts_blue_buzz: bool = False price_sale_ends_at: Optional[str] = None price_checked_at: Optional[float] = None - # Last computed "price alert threshold hit" state, used to detect the edge - # (0 -> 1) that fires a one-shot price_drop event. - price_alert_state: bool = False - # 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 # When a price fetch was last *attempted* (success or failure). Distinguishes # "never tried" from "tried and no price is readable", which is what lets the # UI say "price unavailable" for mature models instead of showing nothing. @@ -145,9 +138,9 @@ class ModelUpdateRecord: should_ignore_model: bool # Gate-state and price transitions observed during the refresh that produced # this record. Derived, never persisted: a record read back from SQLite has an - # empty list. Each entry is {"versionId", "kind", "versionName", - # "isInLibrary", "priceBuzz"?} with kind in - # {"new_gate", "became_free", "price_drop"}. + # empty list. Each entry is {"versionId", "kind", "versionName", "isInLibrary"} + # with kind in {"new_gate", "became_free"}. Only versions the user does not + # have produce events. events: List[Dict[str, Any]] = field(default_factory=list) @property @@ -381,8 +374,6 @@ class ModelUpdateService: accepts_blue_buzz INTEGER NOT NULL DEFAULT 0, price_sale_ends_at TEXT, price_checked_at REAL, - price_alert_state INTEGER NOT NULL DEFAULT 0, - price_alert_since REAL, price_check_attempted_at REAL, PRIMARY KEY (model_id, version_id), FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE @@ -663,14 +654,6 @@ class ModelUpdateService: "ALTER TABLE model_update_versions " "ADD COLUMN price_checked_at REAL" ), - "price_alert_state": ( - "ALTER TABLE model_update_versions " - "ADD COLUMN price_alert_state INTEGER NOT NULL DEFAULT 0" - ), - "price_alert_since": ( - "ALTER TABLE model_update_versions " - "ADD COLUMN price_alert_since REAL" - ), "price_check_attempted_at": ( "ALTER TABLE model_update_versions " "ADD COLUMN price_check_attempted_at REAL" @@ -681,6 +664,21 @@ class ModelUpdateService: if column not in version_columns: conn.execute(statement) + # Columns this feature carried while it was still in development. They are + # gone from the schema, so only a database created by an unreleased build + # can still have them; for everyone else this is a no-op. `DROP COLUMN` + # needs SQLite 3.35+, hence the guard. + for obsolete in ("price_alert_state", "price_alert_since"): + if obsolete not in version_columns: + continue + try: + conn.execute( + f"ALTER TABLE model_update_versions DROP COLUMN {obsolete}" + ) + logger.info("Dropped obsolete column %s", obsolete) + except sqlite3.OperationalError as exc: # pragma: no cover - old SQLite + logger.debug("Could not drop obsolete column %s: %s", obsolete, exc) + # Refresh column metadata after applying additive migrations. version_columns = self._get_table_columns(conn, "model_update_versions") @@ -786,8 +784,6 @@ class ModelUpdateService: accepts_blue_buzz INTEGER NOT NULL DEFAULT 0, price_sale_ends_at TEXT, price_checked_at REAL, - price_alert_state INTEGER NOT NULL DEFAULT 0, - price_alert_since REAL, price_check_attempted_at REAL, PRIMARY KEY (model_id, version_id), FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE @@ -818,8 +814,6 @@ class ModelUpdateService: "accepts_blue_buzz", "price_sale_ends_at", "price_checked_at", - "price_alert_state", - "price_alert_since", "price_check_attempted_at", ] defaults = { @@ -843,8 +837,6 @@ class ModelUpdateService: "accepts_blue_buzz": "0", "price_sale_ends_at": "NULL", "price_checked_at": "NULL", - "price_alert_state": "0", - "price_alert_since": "NULL", "price_check_attempted_at": "NULL", } @@ -1226,205 +1218,6 @@ class ModelUpdateService: async with self._lock: return self._get_records_bulk(model_type, normalized_ids) - async def get_price_alerts( - self, - model_type: Optional[str] = None, - *, - threshold_buzz: Optional[int] = None, - limit: int = 200, - ) -> List[Dict[str, Any]]: - """Return the versions worth alerting on, for one type or for all of them. - - 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: - normalized_limit = max(1, min(int(limit), 1000)) - 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, - v.price_buzz, - v.list_price_buzz, - 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, - v.is_paid, - v.is_early_access, - s.model_type - FROM model_update_versions v - JOIN model_update_status s ON s.model_id = v.model_id - WHERE v.should_ignore = 0 - AND s.should_ignore_model = 0 - {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 ? - """, - tuple(params), - ).fetchall() - - 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"]) - - def count_priced_versions(self, model_type: Optional[str] = None) -> int: - """Versions with a known price, for the panel's empty state. - - Without it, a user whose threshold is 0 sees "nothing is under your price - threshold" while dozens of paid versions already have a price. - """ - - params: List[Any] = [] - type_filter = "" - if model_type: - type_filter = "AND s.model_type = ?" - params.append(model_type) - - with self._connect() as conn: - row = conn.execute( - f""" - SELECT COUNT(*) AS priced - FROM model_update_versions v - JOIN model_update_status s ON s.model_id = v.model_id - WHERE v.should_ignore = 0 - AND s.should_ignore_model = 0 - {type_filter} - AND v.price_buzz IS NOT NULL - """, - tuple(params), - ).fetchone() - if row is None: - return 0 - return int(row["priced"]) - - def count_unavailable_prices(self, model_type: Optional[str] = None) -> int: - """Gated versions whose price we tried to read and could not. - - Mostly mature models: their pages are served only by ``civitai.red``, which - refuses non-browser clients, while ``civitai.com`` hides them from - anonymous visitors. Reported so the UI can be honest instead of silent. - """ - - params: List[Any] = [] - type_filter = "" - if model_type: - type_filter = "AND s.model_type = ?" - params.append(model_type) - - with self._connect() as conn: - row = conn.execute( - f""" - SELECT COUNT(*) AS unavailable - FROM model_update_versions v - JOIN model_update_status s ON s.model_id = v.model_id - WHERE v.should_ignore = 0 - AND s.should_ignore_model = 0 - {type_filter} - AND v.price_check_attempted_at IS NOT NULL - AND v.price_buzz IS NULL - AND (v.paid_access IS NOT NULL OR v.is_paid = 1 OR v.is_early_access = 1) - """, - tuple(params), - ).fetchone() - if row is None: - return 0 - return int(row["unavailable"]) - async def _refresh_single_model( self, model_type: str, @@ -2027,23 +1820,15 @@ class ModelUpdateService: price_fields = self._price_fields_for( remote_version, existing_version, is_gated=is_gated ) - price_fields["price_alert_state"] = self._evaluate_price_alert( - price_fields.get("price_buzz") - ) - # `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 + # Only the edge fires, and only for a version the user does not have: + # a version already on disk cannot become cheaper *for them*, and a + # version that stays gated must not re-announce itself every refresh. + in_library = version_id in effective_local_set if ( existing_version is not None and not should_ignore + and not in_library and was_gated != is_gated ): events.append( @@ -2051,25 +1836,7 @@ class ModelUpdateService: "versionId": version_id, "kind": "new_gate" if is_gated else "became_free", "versionName": remote_version.name, - "isInLibrary": version_id in effective_local_set, - } - ) - - # Only the edge fires: a version that keeps sitting under the threshold - # must not re-announce itself on every refresh. - if ( - existing_version is not None - and not should_ignore - and price_fields["price_alert_state"] - and not existing_version.price_alert_state - ): - events.append( - { - "versionId": version_id, - "kind": "price_drop", - "versionName": remote_version.name, - "isInLibrary": version_id in effective_local_set, - "priceBuzz": price_fields.get("price_buzz"), + "isInLibrary": False, } ) @@ -2174,8 +1941,6 @@ class ModelUpdateService: "accepts_blue_buzz": False, "price_sale_ends_at": None, "price_checked_at": None, - "price_alert_state": False, - "price_alert_since": None, "price_check_attempted_at": None, } @@ -2191,8 +1956,6 @@ class ModelUpdateService: "accepts_blue_buzz": source.accepts_blue_buzz, "price_sale_ends_at": source.price_sale_ends_at, "price_checked_at": source.price_checked_at, - "price_alert_state": source.price_alert_state, - "price_alert_since": source.price_alert_since, "price_check_attempted_at": source.price_check_attempted_at, } @@ -2204,28 +1967,6 @@ class ModelUpdateService: except Exception: return False - def _price_alert_threshold_buzz(self) -> int: - """Buzz threshold for a price alert; 0 means "free only".""" - - try: - value = int(self._settings.get("price_alert_threshold_buzz", 0) or 0) - except (TypeError, ValueError): - return 0 - return max(0, value) - - def _evaluate_price_alert(self, price_buzz: Optional[int]) -> bool: - """Whether a version's price crosses the user's alert threshold. - - Free versions never carry a price, so a threshold of 0 alerts only via the - ``became_free`` event — which is exactly the "or free" half of the request. - """ - - if not self._price_tracking_enabled(): - return False - if price_buzz is None: - return False - return price_buzz <= self._price_alert_threshold_buzz() - def _price_check_ttl_seconds(self) -> float: try: hours = float(self._settings.get("price_check_ttl_hours", 24)) @@ -2595,8 +2336,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, - price_alert_since, price_check_attempted_at + accepts_blue_buzz, price_sale_ends_at, price_checked_at, + price_check_attempted_at FROM model_update_versions WHERE model_id IN ({placeholders}) ORDER BY model_id ASC, sort_index ASC, version_id ASC @@ -2639,12 +2380,6 @@ class ModelUpdateService: if row["price_checked_at"] is not None 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 - ), price_check_attempted_at=( float(row["price_check_attempted_at"]) if row["price_check_attempted_at"] is not None @@ -2714,9 +2449,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, - price_alert_since, price_check_attempted_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + accepts_blue_buzz, price_sale_ends_at, price_checked_at, + price_check_attempted_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( version.version_id, @@ -2742,8 +2477,6 @@ class ModelUpdateService: 1 if version.accepts_blue_buzz else 0, version.price_sale_ends_at, version.price_checked_at, - 1 if version.price_alert_state else 0, - version.price_alert_since, version.price_check_attempted_at, ), ) diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index bc665810..35f0a5ea 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -131,8 +131,9 @@ DEFAULT_SETTINGS: Dict[str, Any] = { "version_grouping": "same_base", # Buzz price tracking for paid/early-access versions. Opt-in because reading a # price costs one extra (public) model-page request per gated model. + # Plumbing switch: reading a price costs one extra request per paid model, so + # it stays opt-in. Prices decorate the version list; nothing alerts on a number. "price_tracking_enabled": False, - "price_alert_threshold_buzz": 0, # 0 = alert on "became free" only "price_check_ttl_hours": 24, "auto_organize_exclusions": [], "metadata_refresh_skip_paths": [], diff --git a/static/css/components/modal/update-modal.css b/static/css/components/modal/update-modal.css index 37a05196..bcc0b572 100644 --- a/static/css/components/modal/update-modal.css +++ b/static/css/components/modal/update-modal.css @@ -652,167 +652,3 @@ 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/components/ContextMenu/GlobalContextMenu.js b/static/js/components/ContextMenu/GlobalContextMenu.js index c4b037ce..36e5fad4 100644 --- a/static/js/components/ContextMenu/GlobalContextMenu.js +++ b/static/js/components/ContextMenu/GlobalContextMenu.js @@ -4,7 +4,6 @@ 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'; @@ -28,7 +27,6 @@ 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 @@ -37,16 +35,6 @@ 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'); @@ -54,7 +42,6 @@ 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'); @@ -63,7 +50,6 @@ 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'); } @@ -103,9 +89,6 @@ 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 4161a710..294651dd 100644 --- a/static/js/components/controls/PageControls.js +++ b/static/js/components/controls/PageControls.js @@ -4,7 +4,6 @@ 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'; @@ -257,17 +256,6 @@ 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/components/shared/ModelVersionsTab.js b/static/js/components/shared/ModelVersionsTab.js index 6a5fa695..2d7f3664 100644 --- a/static/js/components/shared/ModelVersionsTab.js +++ b/static/js/components/shared/ModelVersionsTab.js @@ -569,65 +569,58 @@ function renderRow(version, options) { })); } - if (isEarlyAccess) { + // Obtainability, only where there is a decision to make: a version already in + // the library cannot become cheaper *for this user*, so it gets no cost badge. + // The download price is the only price axis that matters here — the generation + // fee is what CivitAI charges to generate on its own site, which a local model + // never incurs. + const priceLabel = formatBuzzPrice(version.priceBuzz); + if (!version.isInLibrary && isEarlyAccess) { + // Early access is the one state with a real countdown: "free on " + // decides between waiting and paying. + const eaTime = formatEarlyAccessTime(version.earlyAccessEndsAt); badges.push(buildBadge(earlyAccessBadgeLabel, 'early-access', { title: buildPriceTooltip( version, - translate( - 'modals.model.versions.badges.earlyAccessTooltip', - {}, - 'This version currently requires Civitai early access' - ) + eaTime + ? translate( + 'modals.model.versions.badges.earlyAccessTooltipUntil', + { date: eaTime }, + `Early access until ${eaTime}` + ) + : translate( + 'modals.model.versions.badges.earlyAccessTooltip', + {}, + 'This version currently requires Civitai early access' + ) ), })); } - if (isPaidPermanent(version)) { - badges.push(buildBadge(paidBadgeLabel, 'paid', { - title: buildPriceTooltip( - version, - translate( + if (!version.isInLibrary && isGated(version)) { + if (priceLabel) { + // The number already says "paid", so a separate Paid badge would be + // noise next to it. + badges.push(buildBadge(priceLabel, isEarlyAccess ? 'early-access' : 'paid', { + title: buildPriceTooltip(version, paidBadgeLabel), + })); + } else if (!isEarlyAccess) { + // Paid, but no readable price: the gate is certain (it comes from the + // public API), only the number is best-effort. Saying nothing here + // would read as "free", which is the one thing this version is not. + badges.push(buildBadge(paidBadgeLabel, 'paid', { + title: translate( 'modals.model.versions.badges.paidTooltip', {}, 'This version requires payment to download' - ) - ), - })); - } - - // Known price for a gated version. The price is only captured when the - // optional price tracking is enabled, so this stays hidden otherwise. - const priceLabel = isGated(version) ? formatBuzzPrice(version.priceBuzz) : ''; - if (priceLabel) { - const alertsEnabled = !!version.priceAlert; - badges.push(buildBadge(priceLabel, alertsEnabled ? 'price-alert' : 'paid', { - title: alertsEnabled - ? translate( - 'modals.model.versions.badges.priceAlertTooltip', - { price: priceLabel }, - `${priceLabel} - below your price alert threshold` - ) - : buildPriceTooltip(version, paidBadgeLabel), - })); - } else if (isGated(version) && version.priceAttemptedAt) { - // We looked and could not read a price. Staying silent would read as - // "free", which is the one thing this version is not. - badges.push(buildBadge( - translate('modals.model.versions.badges.priceUnavailable', {}, 'Price unavailable'), - 'muted', - { - title: translate( - 'modals.model.versions.badges.priceUnavailableTooltip', - {}, - 'CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser' ), - } - )); + })); + } } // A version that used to be gated and no longer is. `gateLapsedAt` is // persisted, so the marker survives long after the refresh that saw it. - if (!isGated(version) && version.gateLapsedAt) { + if (!version.isInLibrary && !isGated(version) && version.gateLapsedAt) { const freeBadgeLabel = translate('modals.model.versions.badges.freeNow', {}, 'Free Now'); badges.push(buildBadge(freeBadgeLabel, 'success', { title: translate( diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index 24508dc9..58c6fce6 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -1169,11 +1169,6 @@ export class SettingsManager { priceTrackingEnabledCheckbox.checked = state.global.settings.price_tracking_enabled || false; } - const priceAlertThresholdInput = document.getElementById('priceAlertThresholdBuzz'); - if (priceAlertThresholdInput) { - priceAlertThresholdInput.value = state.global.settings.price_alert_threshold_buzz ?? 0; - } - const priceCheckTtlInput = document.getElementById('priceCheckTtlHours'); if (priceCheckTtlInput) { priceCheckTtlInput.value = state.global.settings.price_check_ttl_hours ?? 24; diff --git a/static/js/managers/UpdateService.js b/static/js/managers/UpdateService.js index f6b83b54..1ddcdade 100644 --- a/static/js/managers/UpdateService.js +++ b/static/js/managers/UpdateService.js @@ -9,79 +9,6 @@ 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, - unavailableCount: payload.unavailableCount ?? 0, - pricedCount: payload.pricedCount ?? 0 - }; -} - -/** 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() { @@ -108,18 +35,6 @@ 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.priceAlertsUnavailableCount = 0; - this.priceAlertsPricedCount = 0; - this.priceAlertsLoading = false; - this.priceAlertSegment = 'below_threshold'; - this.unreadPriceAlertCount = 0; - this.handlePriceAlertSegmentClick = this.handlePriceAlertSegmentClick.bind(this); } initialize() { @@ -335,349 +250,7 @@ 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.priceAlertsUnavailableCount = payload.unavailableCount || 0; - this.priceAlertsPricedCount = payload.pricedCount || 0; - 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; - let 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)}` - ) - : ''; - const unavailable = this.priceAlertsUnavailableCount; - if (unavailable > 0) { - const unavailableText = translate( - 'update.priceAlerts.unavailable', - { count: unavailable }, - `${unavailable} paid version(s) have no readable price (mature models can only be read in a browser)` - ); - staleText = staleText ? `${staleText} · ${unavailableText}` : unavailableText; - } - 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.buildPriceAlertsEmptyText(); - empty.classList.remove('hidden'); - return; - } - - empty.classList.add('hidden'); - alerts.forEach((alert) => { - list.appendChild(this.buildPriceAlertItem(alert)); - }); - } - - /** - * The empty state has to explain a 0 Buzz threshold: "nothing is under your - * threshold" is technically true but useless when dozens of paid versions - * already have a known price. - */ - buildPriceAlertsEmptyText() { - if (this.priceAlertSegment === 'became_free') { - return translate( - 'update.priceAlerts.emptyFree', - {}, - 'No version has become free yet.' - ); - } - if (!this.priceAlertsThreshold && this.priceAlertsPricedCount > 0) { - return translate( - 'update.priceAlerts.emptyZeroThreshold', - { count: this.priceAlertsPricedCount }, - `${this.priceAlertsPricedCount} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops.` - ); - } - return translate( - 'update.priceAlerts.empty', - {}, - 'Nothing is under your price threshold right now.' - ); - } - - 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 } = {}) { @@ -710,14 +283,6 @@ export class UpdateService { } } - if (tabName === 'priceAlerts') { - this.loadPriceAlerts().then(() => { - if (markRead) { - this.markPriceAlertsViewed(); - } - }); - } - this.updateTabBadges(); } @@ -778,12 +343,10 @@ 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); @@ -791,12 +354,6 @@ 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/static/js/state/index.js b/static/js/state/index.js index 54322601..69956ad7 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -58,7 +58,6 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({ hide_early_access_updates: false, hide_paid_updates: false, price_tracking_enabled: false, - price_alert_threshold_buzz: 0, price_check_ttl_hours: 24, auto_organize_exclusions: [], metadata_refresh_skip_paths: [], diff --git a/static/js/utils/updateCheckHelpers.js b/static/js/utils/updateCheckHelpers.js index ee4f69a7..56ad6090 100644 --- a/static/js/utils/updateCheckHelpers.js +++ b/static/js/utils/updateCheckHelpers.js @@ -18,7 +18,6 @@ export function summarizeGateEvents(events) { return { becameFree: list.filter((event) => event?.kind === 'became_free').length, newGate: list.filter((event) => event?.kind === 'new_gate').length, - priceDrop: list.filter((event) => event?.kind === 'price_drop').length, total: list.length }; } @@ -29,8 +28,8 @@ export function summarizeGateEvents(events) { * @param {Array} events */ export function showGateEventsToast(events) { - const { becameFree, newGate, priceDrop } = summarizeGateEvents(events); - if (!becameFree && !newGate && !priceDrop) { + const { becameFree, newGate } = summarizeGateEvents(events); + if (!becameFree && !newGate) { return; } @@ -44,15 +43,6 @@ export function showGateEventsToast(events) { ) ); } - if (priceDrop) { - parts.push( - translate( - 'globalContextMenu.checkModelUpdates.gateEvents.priceDrop', - { count: priceDrop }, - `${priceDrop} version(s) dropped below your price threshold` - ) - ); - } if (newGate) { parts.push( translate( @@ -63,7 +53,7 @@ export function showGateEventsToast(events) { ); } - showToast(parts.join(' · '), {}, becameFree || priceDrop ? 'success' : 'info'); + showToast(parts.join(' · '), {}, becameFree ? 'success' : 'info'); } /** diff --git a/templates/components/context_menu.html b/templates/components/context_menu.html index fe38f287..67e6d080 100644 --- a/templates/components/context_menu.html +++ b/templates/components/context_menu.html @@ -185,9 +185,6 @@
{{ 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 5499b7b3..b542371d 100644 --- a/templates/components/controls.html +++ b/templates/components/controls.html @@ -122,9 +122,6 @@ - {% endif %} diff --git a/templates/components/modals/settings/library.html b/templates/components/modals/settings/library.html index 831d9b10..f3314c77 100644 --- a/templates/components/modals/settings/library.html +++ b/templates/components/modals/settings/library.html @@ -197,28 +197,6 @@ {{ sm.subsection_header('settings.sections.priceTracking') }}
{{ t('settings.priceTracking.help') }}
{{ sm.setting_toggle('priceTrackingEnabled', 'price_tracking_enabled', 'settings.priceTracking.enabled', 'settings.priceTracking.enabledHelp') }} -
-
-
- -
-
-
- -
-
-
-
diff --git a/templates/components/modals/update_modal.html b/templates/components/modals/update_modal.html index d3bc5db1..63ba4904 100644 --- a/templates/components/modals/update_modal.html +++ b/templates/components/modals/update_modal.html @@ -15,10 +15,6 @@ {{ t('update.tabs.messages') }} -
@@ -98,34 +94,6 @@
-
diff --git a/tests/frontend/components/modelVersionsTab.downloadButton.test.js b/tests/frontend/components/modelVersionsTab.downloadButton.test.js index a4ffd5fe..2c89f6f3 100644 --- a/tests/frontend/components/modelVersionsTab.downloadButton.test.js +++ b/tests/frontend/components/modelVersionsTab.downloadButton.test.js @@ -284,7 +284,7 @@ describe('ModelVersionsTab download button visibility', () => { }); }); -describe('ModelVersionsTab price badges', () => { +describe('ModelVersionsTab obtainability badges', () => { let getModelApiClient; let fetchModelUpdateVersions; @@ -316,63 +316,83 @@ describe('ModelVersionsTab price badges', () => { return document.querySelector(`.model-version-row[data-version-id="${versionId}"]`); } - it('shows the price for a gated version whose price was captured', async () => { + function renderOne(version) { fetchModelUpdateVersions.mockResolvedValue(buildRecord([ { - versionId: 20, - name: 'Alpha', + versionId: 1, + name: 'v1', isInLibrary: false, shouldIgnore: false, - isPaid: true, - paidAccess: { permanent: true, endsAt: null }, - priceBuzz: 5000, - listPriceBuzz: 5000, - priceCheckedAt: 1791039694.5, - priceAttemptedAt: 1791039694.5, + ...version, }, ])); + return renderVersions(); + } - await renderVersions(); + const PAID_GATE = { permanent: true, endsAt: null }; - expect(rowFor(20).textContent).toContain('5,000 Buzz'); + it('shows the download price for a paid version the user does not have', async () => { + await renderOne({ isPaid: true, paidAccess: PAID_GATE, priceBuzz: 5000 }); + + expect(rowFor(1).textContent).toContain('5,000 Buzz'); }); - it('marks a gated version with no readable price as unavailable', async () => { - // The mature-model case: we looked, no host would serve the page. - fetchModelUpdateVersions.mockResolvedValue(buildRecord([ - { - versionId: 21, - name: 'Beta', - isInLibrary: false, - shouldIgnore: false, - isPaid: true, - paidAccess: { permanent: true, endsAt: null }, - priceBuzz: null, - priceAttemptedAt: 1791039694.5, - }, - ])); + it('says Paid without a number when the price could not be read', async () => { + // The gate is certain (it comes from the public API); only the number is + // best-effort, and that is our plumbing rather than the user's problem. + await renderOne({ isPaid: true, paidAccess: PAID_GATE, priceBuzz: null }); - await renderVersions(); - - expect(rowFor(21).textContent).toContain('Price unavailable'); + expect(rowFor(1).textContent).toContain('Paid'); + expect(rowFor(1).textContent).not.toContain('Buzz'); }); - it('stays quiet when the price was never looked up', async () => { - fetchModelUpdateVersions.mockResolvedValue(buildRecord([ - { - versionId: 22, - name: 'Gamma', - isInLibrary: false, - shouldIgnore: false, - isPaid: true, - paidAccess: { permanent: true, endsAt: null }, - priceBuzz: null, - priceAttemptedAt: null, - }, - ])); + it('shows the early access countdown alongside the price', async () => { + await renderOne({ + isEarlyAccess: true, + earlyAccessEndsAt: new Date(Date.now() + 3 * 86400000).toISOString(), + paidAccess: { permanent: false, endsAt: '2999-01-01T00:00:00.000Z' }, + priceBuzz: 500, + }); - await renderVersions(); + const text = rowFor(1).textContent; + expect(text).toContain('Early Access'); + expect(text).toContain('500 Buzz'); + }); - expect(rowFor(22).textContent).not.toContain('Price unavailable'); + it('shows nothing about cost for a version already in the library', async () => { + // A version on disk cannot become cheaper *for this user*. + await renderOne({ + isInLibrary: true, + isPaid: true, + paidAccess: PAID_GATE, + priceBuzz: 5000, + gateLapsedAt: '2026-09-28T00:00:00.000Z', + }); + + const text = rowFor(1).textContent; + expect(text).not.toContain('Buzz'); + expect(text).not.toContain('Paid'); + expect(text).not.toContain('Free Now'); + expect(text).toContain('In Library'); + }); + + it('marks a version that became free and is not in the library', async () => { + await renderOne({ + isInLibrary: false, + isPaid: false, + priceBuzz: null, + gateLapsedAt: '2026-09-28T00:00:00.000Z', + }); + + expect(rowFor(1).textContent).toContain('Free Now'); + }); + + it('shows nothing at all for a plain free version', async () => { + await renderOne({ isInLibrary: false, isPaid: false, priceBuzz: null }); + + const text = rowFor(1).textContent; + expect(text).not.toContain('Buzz'); + expect(text).not.toContain('Paid'); + expect(text).not.toContain('Free Now'); }); }); diff --git a/tests/frontend/managers/updateServicePriceAlerts.test.js b/tests/frontend/managers/updateServicePriceAlerts.test.js deleted file mode 100644 index fee8dd36..00000000 --- a/tests/frontend/managers/updateServicePriceAlerts.test.js +++ /dev/null @@ -1,300 +0,0 @@ -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('reports how many gated versions have no readable price', async () => { - global.fetch = vi.fn().mockResolvedValue( - createFetchResponse( - alertPayload([BELOW_THRESHOLD_ALERT], { unavailableCount: 3 }) - ) - ); - - await service.loadPriceAlerts({ force: true }); - - expect(service.priceAlertsUnavailableCount).toBe(3); - const note = document.getElementById('priceAlertsStale'); - expect(note.classList.contains('hidden')).toBe(false); - expect(note.textContent).toContain('3 paid version(s) have no readable price'); - }); - - it('explains a 0 Buzz threshold instead of claiming there is nothing', async () => { - // The exact confusion a real user hit: 52 priced versions, threshold 0, - // and an empty panel saying "nothing is under your threshold". - global.fetch = vi.fn().mockResolvedValue( - createFetchResponse( - alertPayload([], { thresholdBuzz: 0, pricedCount: 52 }) - ) - ); - - await service.loadPriceAlerts({ force: true }); - - const empty = document.getElementById('priceAlertsEmpty'); - expect(empty.classList.contains('hidden')).toBe(false); - expect(empty.textContent).toContain('52 paid version(s) have a known price'); - expect(empty.textContent).toContain('threshold is 0 Buzz'); - }); - - it('keeps the plain empty message when a real threshold is set', async () => { - global.fetch = vi.fn().mockResolvedValue( - createFetchResponse( - alertPayload([], { thresholdBuzz: 500, pricedCount: 52 }) - ) - ); - - await service.loadPriceAlerts({ force: true }); - - const empty = document.getElementById('priceAlertsEmpty'); - expect(empty.textContent).toBe('Nothing is under your price threshold right now.'); - }); - - 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 50d4178a..2c0a0974 100644 --- a/tests/routes/test_misc_routes.py +++ b/tests/routes/test_misc_routes.py @@ -17,7 +17,6 @@ from py.routes.handlers.misc_handlers import ( BackupHandler, DoctorHandler, MiscHandlerSet, - PriceAlertsHandler, FileSystemHandler, HealthCheckHandler, LoraCodeHandler, @@ -2896,7 +2895,6 @@ def _stub_misc_handler_set(**overrides) -> MiscHandlerSet: "doctor", "example_workflows", "base_model", - "price_alerts", "model_source_handler", "agent_handler", "download_routing", @@ -2920,177 +2918,14 @@ def test_every_misc_route_definition_resolves_to_a_handler(): ] == [] -def test_price_alerts_route_is_registered_once_app_wide(): - definitions = [ +def test_price_alert_endpoints_are_gone(): + """The redesign removed the standalone alerts surface: obtainability is an + attribute of the update surfaces, so no route may serve an alert list.""" + + leftovers = [ definition for definition in MISC_ROUTE_DEFINITIONS - if definition.handler_name == "get_price_alerts" + if "price-alert" in definition.path or "price_alert" in definition.handler_name ] - # 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, *, unavailable_count=0, priced_count=0): - self.alerts = alerts - self.calls = [] - self.unavailable_count = unavailable_count - self.priced_count = priced_count - - 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 - - def count_unavailable_prices(self, model_type=None): - return self.unavailable_count - - def count_priced_versions(self, model_type=None): - return self.priced_count - - -@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["unavailableCount"] == 0 - assert payload["pricedCount"] == 0 - 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] + assert leftovers == [] diff --git a/tests/routes/test_model_update_handler.py b/tests/routes/test_model_update_handler.py index fda73c9f..77f17479 100644 --- a/tests/routes/test_model_update_handler.py +++ b/tests/routes/test_model_update_handler.py @@ -326,39 +326,6 @@ async def test_refresh_model_updates_filters_records_without_updates(): assert call["target_model_ids"] is None -@pytest.mark.asyncio -async def test_get_price_alerts_returns_flagged_versions(): - """The price alert list is a GET (companion-extension friendly) and reports - the threshold it was computed with.""" - - class AlertsService: - def __init__(self): - self.calls = [] - - async def get_price_alerts(self, model_type, limit=200): - self.calls.append((model_type, limit)) - return [{"modelId": 1, "versionId": 12, "priceBuzz": 250}] - - update_service = AlertsService() - settings = {"price_tracking_enabled": True, "price_alert_threshold_buzz": 300} - handler = ModelUpdateHandler( - service=DummyService(SimpleNamespace(version_index={})), - update_service=update_service, - metadata_provider_selector=lambda *_: None, - settings_service=SimpleNamespace(get=lambda key, default=None: settings.get(key, default)), - logger=logging.getLogger(__name__), - ) - request = SimpleNamespace(query={"limit": "50"}) - - response = await handler.get_price_alerts(request) # pyright: ignore[reportArgumentType] - - assert response.status == 200 - payload = json.loads(response.text) - assert payload["success"] is True - assert payload["enabled"] is True - assert payload["thresholdBuzz"] == 300 - assert payload["alerts"] == [{"modelId": 1, "versionId": 12, "priceBuzz": 250}] - assert update_service.calls == [("lora", 50)] @pytest.mark.asyncio @@ -1216,13 +1183,12 @@ def test_every_common_route_definition_resolves_to_a_handler(): assert missing == [] -def test_get_price_alerts_is_registered_as_a_route(): - """The alerts endpoint must be declared and reachable via GET.""" +def test_price_alert_endpoints_are_not_registered(): + """The redesign dropped the standalone alerts surface: obtainability rides on + the update surfaces, so no per-type alert route may remain.""" - definition = next( - d - for d in COMMON_ROUTE_DEFINITIONS - if d.handler_name == "get_price_alerts" - ) - assert definition.method == "GET" - assert definition.build_path("loras") == "/api/lm/loras/updates/price-alerts" + assert [ + definition.path_template + for definition in COMMON_ROUTE_DEFINITIONS + if "price-alert" in definition.path_template + ] == [] diff --git a/tests/services/test_model_update_service.py b/tests/services/test_model_update_service.py index 37e30b3f..83b60ce7 100644 --- a/tests/services/test_model_update_service.py +++ b/tests/services/test_model_update_service.py @@ -1153,9 +1153,9 @@ def test_build_record_emits_became_free_event(tmp_path): assert version.gate_lapsed_at is not None assert version.price_buzz is None assert version.price_checked_at is None - assert version.price_alert_state is False assert [event["kind"] for event in record.events] == ["became_free"] assert record.events[0]["versionId"] == 7 + assert record.events[0]["isInLibrary"] is False def test_build_record_emits_new_gate_event(tmp_path): @@ -1318,9 +1318,12 @@ def test_migration_adds_price_columns_to_legacy_db(tmp_path): "accepts_blue_buzz", "price_sale_ends_at", "price_checked_at", - "price_alert_state", + "price_check_attempted_at", ): assert column in columns + # The alert-state columns were removed together with the threshold they served. + assert "price_alert_state" not in columns + assert "price_alert_since" not in columns record = service._get_record("lora", 999) assert record is not None @@ -1343,7 +1346,6 @@ def test_price_fields_round_trip_through_sqlite(tmp_path): generation_price_buzz=100, accepts_blue_buzz=True, price_sale_ends_at="2026-10-01T00:00:00.000Z", - price_alert_state=True, is_in_library=True, gate_lapsed_at=None, ) @@ -1358,7 +1360,7 @@ def test_price_fields_round_trip_through_sqlite(tmp_path): assert loaded.accepts_blue_buzz is True assert loaded.price_sale_ends_at == "2026-10-01T00:00:00.000Z" assert loaded.price_checked_at == 123.5 - assert loaded.price_alert_state is True + assert loaded.price_check_attempted_at is None # --- Optional price capture -------------------------------------------------- @@ -1625,107 +1627,14 @@ def _prices(price_buzz: int) -> dict: return {12: {"price_buzz": price_buzz, "list_price_buzz": price_buzz}} -@pytest.mark.asyncio -async def test_price_alert_state_persists_and_fires_on_crossing(tmp_path): - service = _price_service( - tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=300 - ) - scanner = DummyScanner(LOCAL_RAW_DATA) - - # First sight: the state is recorded (so the alerts list shows it) but nothing - # is announced - otherwise enabling the feature would toast every cheap - # version in the library at once. - first_refresh = await service.refresh_for_model_type( - "lora", - scanner, - PriceProvider(_gated_response("2999-01-01T00:00:00.000Z"), prices=_prices(250)), - ) - version = next(v for v in first_refresh[1].versions if v.version_id == 12) - assert version.price_alert_state is True - assert first_refresh[1].events == [] - - alerts = await service.get_price_alerts("lora") - assert [alert["versionId"] for alert in alerts] == [12] - assert alerts[0]["priceBuzz"] == 250 - assert alerts[0]["modelId"] == 1 - - # Price rises above the threshold: the state resets silently. - raised = await service.refresh_for_model_type( - "lora", - scanner, - PriceProvider(_gated_response("2999-02-01T00:00:00.000Z"), prices=_prices(900)), - ) - assert raised[1].versions[0].price_alert_state is False - assert raised[1].events == [] - assert await service.get_price_alerts("lora") == [] - - # …and drops back under it: now it is news. - dropped = await service.refresh_for_model_type( - "lora", - scanner, - PriceProvider(_gated_response("2999-03-01T00:00:00.000Z"), prices=_prices(200)), - ) - assert dropped[1].versions[0].price_alert_state is True - assert [event["kind"] for event in dropped[1].events] == ["price_drop"] - assert dropped[1].events[0]["priceBuzz"] == 200 - # Events are derived, never persisted: a later read reports none. - stored = await service.get_record("lora", 1) - assert stored.events == [] - assert stored.versions[0].price_alert_state is True -@pytest.mark.asyncio -async def test_price_alert_not_fired_above_threshold(tmp_path): - service = _price_service( - tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=100 - ) - scanner = DummyScanner(LOCAL_RAW_DATA) - provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD) - - await service.refresh_for_model_type("lora", scanner, provider) - record = await service.get_record("lora", 1) - - assert record.versions[0].price_alert_state is False - assert record.events == [] - assert await service.get_price_alerts("lora") == [] -@pytest.mark.asyncio -async def test_price_alert_requires_price_tracking(tmp_path): - """With tracking off there are no prices, so no price alert either.""" - - service = _price_service(tmp_path, price_alert_threshold_buzz=100000) - scanner = DummyScanner(LOCAL_RAW_DATA) - provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD) - - await service.refresh_for_model_type("lora", scanner, provider) - record = await service.get_record("lora", 1) - - assert record.versions[0].price_alert_state is False - assert await service.get_price_alerts("lora") == [] -@pytest.mark.asyncio -async def test_price_alerts_respect_ignores_and_model_type(tmp_path): - service = _price_service( - tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=1000 - ) - scanner = DummyScanner(LOCAL_RAW_DATA) - provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD) - - await service.refresh_for_model_type("lora", scanner, provider) - assert len(await service.get_price_alerts("lora")) == 1 - - await service.set_version_should_ignore("lora", 1, 12, True) - assert await service.get_price_alerts("lora") == [] - assert await service.get_price_alerts("checkpoint") == [] -@pytest.mark.asyncio -async def test_get_price_alerts_tolerates_bad_limit(tmp_path): - service = ModelUpdateService(str(tmp_path / "updates.sqlite")) - - assert await service.get_price_alerts("lora", limit="not-a-number") == [] # --- Price alerts panel semantics (read-time threshold, across types) --------- @@ -1760,133 +1669,14 @@ def _free_response_for(version_id: int) -> dict: } -@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 @pytest.mark.asyncio @@ -1907,11 +1697,6 @@ async def test_failed_price_attempt_is_recorded_as_unavailable(tmp_path): assert version.price_buzz is None assert version.price_checked_at is None assert version.price_check_attempted_at is not None - assert service.count_unavailable_prices("lora") == 1 - assert service.count_unavailable_prices("checkpoint") == 0 - # Nothing to alert on, and no price alert state. - assert await service.get_price_alerts("lora") == [] - assert version.price_alert_state is False @pytest.mark.asyncio @@ -1928,7 +1713,6 @@ async def test_successful_price_attempt_sets_both_markers(tmp_path): assert version.price_buzz == 250 assert version.price_checked_at is not None assert version.price_check_attempted_at is not None - assert service.count_unavailable_prices("lora") == 0 @pytest.mark.asyncio @@ -1940,7 +1724,6 @@ async def test_no_price_attempt_is_recorded_while_tracking_is_off(tmp_path): record = await service.get_record("lora", 1) assert record.versions[0].price_check_attempted_at is None - assert service.count_unavailable_prices("lora") == 0 @pytest.mark.asyncio @@ -1951,13 +1734,11 @@ async def test_unavailable_marker_clears_when_the_version_becomes_free(tmp_path) await service.refresh_for_model_type( "lora", scanner, PriceProvider(GATED_RESPONSE, prices=None) ) - assert service.count_unavailable_prices("lora") == 1 await service.refresh_for_model_type("lora", scanner, DummyProvider(FREE_RESPONSE)) record = await service.get_record("lora", 1) assert record.versions[0].price_check_attempted_at is None - assert service.count_unavailable_prices("lora") == 0 def _long_ttl_service(tmp_path, **settings): @@ -2020,21 +1801,8 @@ async def test_failed_price_attempt_is_not_retried_within_the_ttl(tmp_path): await service.refresh_for_model_type("lora", scanner, provider) assert provider.price_calls == 1 - assert service.count_unavailable_prices("lora") == 1 -@pytest.mark.asyncio -async def test_priced_count_reports_known_prices(tmp_path): - service = _price_service(tmp_path, price_tracking_enabled=True) - scanner = DummyScanner(LOCAL_RAW_DATA) - provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD) - - assert service.count_priced_versions("lora") == 0 - - await service.refresh_for_model_type("lora", scanner, provider) - - assert service.count_priced_versions("lora") == 1 - assert service.count_priced_versions("checkpoint") == 0 @pytest.mark.asyncio @@ -2051,3 +1819,85 @@ async def test_forced_refresh_reprices_within_the_ttl(tmp_path): ) assert provider.price_calls == 2 + + +# --- Ownership scoping and the removed threshold ----------------------------- + + +def test_gate_event_is_suppressed_for_a_version_the_user_owns(tmp_path): + """A version already on disk cannot become cheaper *for this user*, so it must + not produce an event. More than half of a real library's gated versions are + owned, so this is the common case, not an edge case.""" + + service = ModelUpdateService(str(tmp_path / "updates.sqlite")) + existing = make_record(replace(_remote_gated(7), is_in_library=True)) + + record = service._build_record_from_remote( + model_type="lora", + model_id=999, + local_versions=[7], + remote_versions=[_remote_free(7)], + existing=existing, + timestamp=1000.0, + ) + + assert record.events == [] + # The lapse itself is still recorded - it is the *notification* that is + # pointless for a version the user already has. + assert record.versions[0].gate_lapsed_at is not None + + +def test_new_gate_event_is_suppressed_for_a_version_the_user_owns(tmp_path): + service = ModelUpdateService(str(tmp_path / "updates.sqlite")) + existing = make_record(replace(_remote_free(7), is_in_library=True)) + + record = service._build_record_from_remote( + model_type="lora", + model_id=999, + local_versions=[7], + remote_versions=[_remote_gated(7)], + existing=existing, + timestamp=1000.0, + ) + + assert record.events == [] + + +def test_threshold_setting_is_gone_from_the_defaults(): + """The redesign removed the numeric threshold: every obtainability decision is + categorical (wait / pay / skip), so no number is stored or compared.""" + + from py.services.settings_manager import DEFAULT_SETTINGS + + assert "price_alert_threshold_buzz" not in DEFAULT_SETTINGS + assert "price_tracking_enabled" in DEFAULT_SETTINGS + assert "price_check_ttl_hours" in DEFAULT_SETTINGS + + +def test_obsolete_alert_columns_are_dropped_from_a_development_database(tmp_path): + """A database created while the feature was unreleased still carries the two + alert-state columns; opening it must drop them rather than leave dead schema.""" + + db_path = tmp_path / "updates.sqlite" + service = ModelUpdateService(str(db_path)) + with service._connect() as conn: + conn.execute( + "ALTER TABLE model_update_versions " + "ADD COLUMN price_alert_state INTEGER NOT NULL DEFAULT 0" + ) + conn.execute( + "ALTER TABLE model_update_versions ADD COLUMN price_alert_since REAL" + ) + conn.commit() + assert "price_alert_state" in service._get_table_columns( + conn, "model_update_versions" + ) + + # A fresh service instance runs the migration on open. + reopened = ModelUpdateService(str(db_path)) + columns = reopened._get_table_columns( + reopened._connect(), "model_update_versions" + ) + + assert "price_alert_state" not in columns + assert "price_alert_since" not in columns