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