refactor(update)!: make obtainability a property of updates, not a surface

The owner could not tell from the UI what "Buzz Price Tracking" enabled, what the
"Price alert threshold" number meant, or what "Price Alerts" was alerting about.
That was not a copy problem: the implementation exposed our mechanism (a page
scrape) and our SQL predicates as the user's concepts. Two concrete defects came
from the same root:

- the alert population included versions the user already owns (neither the event
  generator nor the query filtered on is_in_library; in the owner's library 28 of
  52 gated versions were already downloaded, so most "alerts" were about files
  already on disk, which cannot become cheaper *for them*);
- a threshold-filtered state list lived in a notification surface, so an empty
  panel had three indistinguishable causes and read as a broken feature.

The information model is now the version plus ownership: cost is shown only where
a decision exists. Owned -> nothing. Not owned and free -> nothing. Not owned and
gated -> the price when it is known, `Paid` without a number when it is not, and
early access keeps its countdown because "free on <date>" decides between waiting
and paying. The numeric threshold has no place in that model: every decision is
categorical (wait / pay / skip), so the setting, the comparison and the whole
alert-state machine are gone.

- both alert-state columns are removed from the schema rather than left dead; a
  database created by an unreleased build has them dropped on open (native
  ALTER TABLE ... DROP COLUMN, guarded), which is a no-op for everyone else
- gate events are emitted only for versions the user does not have, and the
  price-drop event goes with the threshold it belonged to
- both alert endpoints, PriceAlertsHandler and the service-registry adapter field
  it needed are removed: events already reach the UI through the refresh response
- the bell tab, panel, CSS, both entry points, the unread watermark and their
  locales are removed; the setting keeps only the enable flag and the refresh
  interval and is framed as plumbing
- "Price unavailable" is replaced by `Paid`: the gate is certain from the public
  API, only the number is best-effort, and that is our plumbing, not the user's
  problem

Verified against a copy of the owner's real database: 52 gated versions ->
28 owned (now silent) + 24 the feature is actually about; the drop migration ran
and both removed endpoints 404.
This commit is contained in:
Will Miao
2026-10-05 08:54:08 +08:00
parent 156d2e5eb9
commit e42d649df0
35 changed files with 403 additions and 2494 deletions
+129
View File
@@ -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. 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 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). (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.
+3 -42
View File
@@ -204,7 +204,6 @@
"error": "Fehler beim Prüfen auf {type}-Updates: {message}", "error": "Fehler beim Prüfen auf {type}-Updates: {message}",
"gateEvents": { "gateEvents": {
"becameFree": "[TODO: Translate] {count} version(s) became free", "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" "newGate": "[TODO: Translate] {count} version(s) are now paid"
} }
}, },
@@ -234,9 +233,6 @@
}, },
"groupByModel": { "groupByModel": {
"label": "Nach Modell gruppieren" "label": "Nach Modell gruppieren"
},
"priceAlerts": {
"label": "[TODO: Translate] Price alerts"
} }
}, },
"header": { "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.", "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", "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", "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)", "refreshHours": "[TODO: Translate] Price refresh interval (hours)",
"refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again"
}, },
@@ -945,9 +939,7 @@
"action": "Updates", "action": "Updates",
"menuLabel": "Weitere Update-Optionen anzeigen", "menuLabel": "Weitere Update-Optionen anzeigen",
"check": "Updates prüfen", "check": "Updates prüfen",
"checkTooltip": "Die Aktualisierungssuche kann einige Zeit dauern.", "checkTooltip": "Die Aktualisierungssuche kann einige Zeit dauern."
"priceAlerts": "[TODO: Translate] Price alerts",
"priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions"
} }
}, },
"bulkOperations": { "bulkOperations": {
@@ -2031,15 +2023,13 @@
"paidTooltip": "Diese Version erfordert eine Zahlung zum Herunterladen", "paidTooltip": "Diese Version erfordert eine Zahlung zum Herunterladen",
"priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz",
"priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue 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", "freeNow": "[TODO: Translate] Free Now",
"freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment",
"ignored": "Ignoriert", "ignored": "Ignoriert",
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert", "ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert",
"onSiteOnly": "Nur On-Site", "onSiteOnly": "Nur On-Site",
"onSiteOnlyTooltip": "Diese Version ist nur für die On-Site-Generierung auf CivitAI verfügbar", "onSiteOnlyTooltip": "Diese Version ist nur für die On-Site-Generierung auf CivitAI verfügbar",
"priceUnavailable": "[TODO: Translate] Price unavailable", "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that"
"priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser"
}, },
"actions": { "actions": {
"download": "Herunterladen", "download": "Herunterladen",
@@ -2328,8 +2318,7 @@
"notificationsTitle": "Benachrichtigungszentrum", "notificationsTitle": "Benachrichtigungszentrum",
"tabs": { "tabs": {
"updates": "Aktualisierungen", "updates": "Aktualisierungen",
"messages": "Mitteilungen", "messages": "Mitteilungen"
"priceAlerts": "[TODO: Translate] Price Alerts"
}, },
"updateAvailable": "Update verfügbar", "updateAvailable": "Update verfügbar",
"noChangelogAvailable": "Kein detailliertes Changelog verfügbar. Weitere Informationen auf GitHub.", "noChangelogAvailable": "Kein detailliertes Changelog verfügbar. Weitere Informationen auf GitHub.",
@@ -2385,34 +2374,6 @@
"shown": "{time} angezeigt", "shown": "{time} angezeigt",
"dismissed": "{time} geschlossen", "dismissed": "{time} geschlossen",
"active": "Aktiv" "active": "Aktiv"
},
"priceAlerts": {
"disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.",
"openSettings": "[TODO: Translate] Open settings",
"segmentsLabel": "[TODO: Translate] Alert kind",
"segments": {
"belowThreshold": "[TODO: Translate] Under threshold",
"becameFree": "[TODO: Translate] Became free"
},
"thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz",
"empty": "[TODO: Translate] Nothing is under your price threshold right now.",
"emptyFree": "[TODO: Translate] No version has become free yet.",
"loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.",
"lastChecked": "[TODO: Translate] Prices last checked {when}",
"justNow": "[TODO: Translate] just now",
"minutesAgo": "[TODO: Translate] {count} min ago",
"hoursAgo": "[TODO: Translate] {count} h ago",
"daysAgo": "[TODO: Translate] {count} d ago",
"freeNow": "[TODO: Translate] Free now",
"blueBuzz": "[TODO: Translate] Blue Buzz OK",
"earlyAccessUntil": "[TODO: Translate] Early access until {date}",
"inLibrary": "[TODO: Translate] In library",
"notInLibrary": "[TODO: Translate] Not in library",
"droppedAgo": "[TODO: Translate] dropped {when}",
"openCivitai": "[TODO: Translate] Open on CivitAI",
"openLocal": "[TODO: Translate] Open",
"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": { "support": {
+9 -48
View File
@@ -203,8 +203,7 @@
"none": "All {type}s are up to date", "none": "All {type}s are up to date",
"error": "Failed to check for {type} updates: {message}", "error": "Failed to check for {type} updates: {message}",
"gateEvents": { "gateEvents": {
"becameFree": "{count} version(s) became free", "becameFree": "{count} version(s) you do not have became free",
"priceDrop": "{count} version(s) dropped below your price threshold",
"newGate": "{count} version(s) are now paid" "newGate": "{count} version(s) are now paid"
} }
}, },
@@ -234,9 +233,6 @@
}, },
"groupByModel": { "groupByModel": {
"label": "Group by Model" "label": "Group by Model"
},
"priceAlerts": {
"label": "Price alerts"
} }
}, },
"header": { "header": {
@@ -763,14 +759,12 @@
"help": "When enabled, models with only paid updates will not show 'Update available' badge" "help": "When enabled, models with only paid updates will not show 'Update available' badge"
}, },
"priceTracking": { "priceTracking": {
"label": "Buzz Price Tracking", "label": "Buzz Download Prices",
"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.", "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": "Track Buzz prices for paid versions", "enabled": "Show download prices for paid versions",
"enabledHelp": "Show the price on paid and early access versions, and alert when a price drops below your threshold", "enabledHelp": "Reads the download price from the model page so paid and early access versions show what they cost, and when early access ends",
"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.",
"refreshHours": "Price refresh interval (hours)", "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": { "licenseIcons": {
"useNewStyle": "Use updated license icons", "useNewStyle": "Use updated license icons",
@@ -945,9 +939,7 @@
"action": "Updates", "action": "Updates",
"menuLabel": "Show update options", "menuLabel": "Show update options",
"check": "Check updates", "check": "Check updates",
"checkTooltip": "Checking updates may take a while.", "checkTooltip": "Checking updates may take a while."
"priceAlerts": "Price alerts",
"priceAlertsTooltip": "Buzz price alerts for paid versions"
} }
}, },
"bulkOperations": { "bulkOperations": {
@@ -2031,15 +2023,13 @@
"paidTooltip": "This version requires payment to download", "paidTooltip": "This version requires payment to download",
"priceSaleTooltip": "On sale, normally {list} Buzz", "priceSaleTooltip": "On sale, normally {list} Buzz",
"priceBlueBuzzTooltip": "Can also be paid with Blue Buzz", "priceBlueBuzzTooltip": "Can also be paid with Blue Buzz",
"priceAlertTooltip": "{price} - below your price alert threshold",
"freeNow": "Free Now", "freeNow": "Free Now",
"freeNowTooltip": "This version no longer requires payment", "freeNowTooltip": "This version no longer requires payment",
"ignored": "Ignored", "ignored": "Ignored",
"ignoredTooltip": "Update notifications are disabled for this version", "ignoredTooltip": "Update notifications are disabled for this version",
"onSiteOnly": "On-Site Only", "onSiteOnly": "On-Site Only",
"onSiteOnlyTooltip": "This version is only available for on-site generation on CivitAI", "onSiteOnlyTooltip": "This version is only available for on-site generation on CivitAI",
"priceUnavailable": "Price unavailable", "earlyAccessTooltipUntil": "Early access until {date}, free to download after that"
"priceUnavailableTooltip": "CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser"
}, },
"actions": { "actions": {
"download": "Download", "download": "Download",
@@ -2328,8 +2318,7 @@
"notificationsTitle": "Notifications", "notificationsTitle": "Notifications",
"tabs": { "tabs": {
"updates": "Updates", "updates": "Updates",
"messages": "Messages", "messages": "Messages"
"priceAlerts": "Price Alerts"
}, },
"updateAvailable": "Update Available", "updateAvailable": "Update Available",
"noChangelogAvailable": "No detailed changelog available. Check GitHub for more information.", "noChangelogAvailable": "No detailed changelog available. Check GitHub for more information.",
@@ -2385,34 +2374,6 @@
"shown": "Shown {time}", "shown": "Shown {time}",
"dismissed": "Dismissed {time}", "dismissed": "Dismissed {time}",
"active": "Active" "active": "Active"
},
"priceAlerts": {
"disabled": "CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.",
"openSettings": "Open settings",
"segmentsLabel": "Alert kind",
"segments": {
"belowThreshold": "Under threshold",
"becameFree": "Became free"
},
"thresholdLabel": "Alert threshold: {buzz} Buzz",
"empty": "Nothing is under your price threshold right now.",
"emptyFree": "No version has become free yet.",
"loadFailed": "Could not refresh the alerts; showing the last known list.",
"lastChecked": "Prices last checked {when}",
"justNow": "just now",
"minutesAgo": "{count} min ago",
"hoursAgo": "{count} h ago",
"daysAgo": "{count} d ago",
"freeNow": "Free now",
"blueBuzz": "Blue Buzz OK",
"earlyAccessUntil": "Early access until {date}",
"inLibrary": "In library",
"notInLibrary": "Not in library",
"droppedAgo": "dropped {when}",
"openCivitai": "Open on CivitAI",
"openLocal": "Open",
"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": { "support": {
+3 -42
View File
@@ -204,7 +204,6 @@
"error": "Error al buscar actualizaciones de {type}: {message}", "error": "Error al buscar actualizaciones de {type}: {message}",
"gateEvents": { "gateEvents": {
"becameFree": "[TODO: Translate] {count} version(s) became free", "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" "newGate": "[TODO: Translate] {count} version(s) are now paid"
} }
}, },
@@ -234,9 +233,6 @@
}, },
"groupByModel": { "groupByModel": {
"label": "Agrupar por modelo" "label": "Agrupar por modelo"
},
"priceAlerts": {
"label": "[TODO: Translate] Price alerts"
} }
}, },
"header": { "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.", "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", "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", "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)", "refreshHours": "[TODO: Translate] Price refresh interval (hours)",
"refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again"
}, },
@@ -945,9 +939,7 @@
"action": "Actualizaciones", "action": "Actualizaciones",
"menuLabel": "Mostrar opciones de actualización", "menuLabel": "Mostrar opciones de actualización",
"check": "Buscar actualizaciones", "check": "Buscar actualizaciones",
"checkTooltip": "Comprobar actualizaciones puede tardar.", "checkTooltip": "Comprobar actualizaciones puede tardar."
"priceAlerts": "[TODO: Translate] Price alerts",
"priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions"
} }
}, },
"bulkOperations": { "bulkOperations": {
@@ -2031,15 +2023,13 @@
"paidTooltip": "Esta versión requiere pago para descargarse", "paidTooltip": "Esta versión requiere pago para descargarse",
"priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz",
"priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue 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", "freeNow": "[TODO: Translate] Free Now",
"freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment",
"ignored": "Ignorada", "ignored": "Ignorada",
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión", "ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión",
"onSiteOnly": "Solo en Sitio", "onSiteOnly": "Solo en Sitio",
"onSiteOnlyTooltip": "Esta versión solo está disponible para generación en el sitio de CivitAI", "onSiteOnlyTooltip": "Esta versión solo está disponible para generación en el sitio de CivitAI",
"priceUnavailable": "[TODO: Translate] Price unavailable", "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that"
"priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser"
}, },
"actions": { "actions": {
"download": "Descargar", "download": "Descargar",
@@ -2328,8 +2318,7 @@
"notificationsTitle": "Centro de notificaciones", "notificationsTitle": "Centro de notificaciones",
"tabs": { "tabs": {
"updates": "Actualizaciones", "updates": "Actualizaciones",
"messages": "Mensajes", "messages": "Mensajes"
"priceAlerts": "[TODO: Translate] Price Alerts"
}, },
"updateAvailable": "Actualización disponible", "updateAvailable": "Actualización disponible",
"noChangelogAvailable": "No hay registro de cambios detallado disponible. Revisa GitHub para más información.", "noChangelogAvailable": "No hay registro de cambios detallado disponible. Revisa GitHub para más información.",
@@ -2385,34 +2374,6 @@
"shown": "Mostrado {time}", "shown": "Mostrado {time}",
"dismissed": "Descartado {time}", "dismissed": "Descartado {time}",
"active": "Activo" "active": "Activo"
},
"priceAlerts": {
"disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.",
"openSettings": "[TODO: Translate] Open settings",
"segmentsLabel": "[TODO: Translate] Alert kind",
"segments": {
"belowThreshold": "[TODO: Translate] Under threshold",
"becameFree": "[TODO: Translate] Became free"
},
"thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz",
"empty": "[TODO: Translate] Nothing is under your price threshold right now.",
"emptyFree": "[TODO: Translate] No version has become free yet.",
"loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.",
"lastChecked": "[TODO: Translate] Prices last checked {when}",
"justNow": "[TODO: Translate] just now",
"minutesAgo": "[TODO: Translate] {count} min ago",
"hoursAgo": "[TODO: Translate] {count} h ago",
"daysAgo": "[TODO: Translate] {count} d ago",
"freeNow": "[TODO: Translate] Free now",
"blueBuzz": "[TODO: Translate] Blue Buzz OK",
"earlyAccessUntil": "[TODO: Translate] Early access until {date}",
"inLibrary": "[TODO: Translate] In library",
"notInLibrary": "[TODO: Translate] Not in library",
"droppedAgo": "[TODO: Translate] dropped {when}",
"openCivitai": "[TODO: Translate] Open on CivitAI",
"openLocal": "[TODO: Translate] Open",
"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": { "support": {
+3 -42
View File
@@ -204,7 +204,6 @@
"error": "Échec de la vérification des mises à jour pour {type} : {message}", "error": "Échec de la vérification des mises à jour pour {type} : {message}",
"gateEvents": { "gateEvents": {
"becameFree": "[TODO: Translate] {count} version(s) became free", "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" "newGate": "[TODO: Translate] {count} version(s) are now paid"
} }
}, },
@@ -234,9 +233,6 @@
}, },
"groupByModel": { "groupByModel": {
"label": "Grouper par modèle" "label": "Grouper par modèle"
},
"priceAlerts": {
"label": "[TODO: Translate] Price alerts"
} }
}, },
"header": { "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.", "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", "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", "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)", "refreshHours": "[TODO: Translate] Price refresh interval (hours)",
"refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again"
}, },
@@ -945,9 +939,7 @@
"action": "Mises à jour", "action": "Mises à jour",
"menuLabel": "Afficher les options de mise à jour", "menuLabel": "Afficher les options de mise à jour",
"check": "Rechercher des mises à jour", "check": "Rechercher des mises à jour",
"checkTooltip": "La vérification peut prendre du temps.", "checkTooltip": "La vérification peut prendre du temps."
"priceAlerts": "[TODO: Translate] Price alerts",
"priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions"
} }
}, },
"bulkOperations": { "bulkOperations": {
@@ -2031,15 +2023,13 @@
"paidTooltip": "Cette version nécessite un paiement pour être téléchargée", "paidTooltip": "Cette version nécessite un paiement pour être téléchargée",
"priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz",
"priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue 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", "freeNow": "[TODO: Translate] Free Now",
"freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment",
"ignored": "Ignorée", "ignored": "Ignorée",
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version", "ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version",
"onSiteOnly": "Uniquement sur Site", "onSiteOnly": "Uniquement sur Site",
"onSiteOnlyTooltip": "Cette version n'est disponible que pour la génération sur le site CivitAI", "onSiteOnlyTooltip": "Cette version n'est disponible que pour la génération sur le site CivitAI",
"priceUnavailable": "[TODO: Translate] Price unavailable", "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that"
"priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser"
}, },
"actions": { "actions": {
"download": "Télécharger", "download": "Télécharger",
@@ -2328,8 +2318,7 @@
"notificationsTitle": "Notifications", "notificationsTitle": "Notifications",
"tabs": { "tabs": {
"updates": "Mises à jour", "updates": "Mises à jour",
"messages": "Messages", "messages": "Messages"
"priceAlerts": "[TODO: Translate] Price Alerts"
}, },
"updateAvailable": "Mise à jour disponible", "updateAvailable": "Mise à jour disponible",
"noChangelogAvailable": "Aucun journal des modifications détaillé disponible. Consultez GitHub pour plus d'informations.", "noChangelogAvailable": "Aucun journal des modifications détaillé disponible. Consultez GitHub pour plus d'informations.",
@@ -2385,34 +2374,6 @@
"shown": "Affiché {time}", "shown": "Affiché {time}",
"dismissed": "Ignoré {time}", "dismissed": "Ignoré {time}",
"active": "Actif" "active": "Actif"
},
"priceAlerts": {
"disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.",
"openSettings": "[TODO: Translate] Open settings",
"segmentsLabel": "[TODO: Translate] Alert kind",
"segments": {
"belowThreshold": "[TODO: Translate] Under threshold",
"becameFree": "[TODO: Translate] Became free"
},
"thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz",
"empty": "[TODO: Translate] Nothing is under your price threshold right now.",
"emptyFree": "[TODO: Translate] No version has become free yet.",
"loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.",
"lastChecked": "[TODO: Translate] Prices last checked {when}",
"justNow": "[TODO: Translate] just now",
"minutesAgo": "[TODO: Translate] {count} min ago",
"hoursAgo": "[TODO: Translate] {count} h ago",
"daysAgo": "[TODO: Translate] {count} d ago",
"freeNow": "[TODO: Translate] Free now",
"blueBuzz": "[TODO: Translate] Blue Buzz OK",
"earlyAccessUntil": "[TODO: Translate] Early access until {date}",
"inLibrary": "[TODO: Translate] In library",
"notInLibrary": "[TODO: Translate] Not in library",
"droppedAgo": "[TODO: Translate] dropped {when}",
"openCivitai": "[TODO: Translate] Open on CivitAI",
"openLocal": "[TODO: Translate] Open",
"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": { "support": {
+3 -42
View File
@@ -204,7 +204,6 @@
"error": "נכשל בבדיקת העדכונים עבור {type}: {message}", "error": "נכשל בבדיקת העדכונים עבור {type}: {message}",
"gateEvents": { "gateEvents": {
"becameFree": "[TODO: Translate] {count} version(s) became free", "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" "newGate": "[TODO: Translate] {count} version(s) are now paid"
} }
}, },
@@ -234,9 +233,6 @@
}, },
"groupByModel": { "groupByModel": {
"label": "קיבוץ לפי מודל" "label": "קיבוץ לפי מודל"
},
"priceAlerts": {
"label": "[TODO: Translate] Price alerts"
} }
}, },
"header": { "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.", "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", "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", "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)", "refreshHours": "[TODO: Translate] Price refresh interval (hours)",
"refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again"
}, },
@@ -945,9 +939,7 @@
"action": "עדכונים", "action": "עדכונים",
"menuLabel": "הצגת אפשרויות עדכון", "menuLabel": "הצגת אפשרויות עדכון",
"check": "בדוק עדכונים", "check": "בדוק עדכונים",
"checkTooltip": "בדיקת עדכונים עלולה לקחת זמן.", "checkTooltip": "בדיקת עדכונים עלולה לקחת זמן."
"priceAlerts": "[TODO: Translate] Price alerts",
"priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions"
} }
}, },
"bulkOperations": { "bulkOperations": {
@@ -2031,15 +2023,13 @@
"paidTooltip": "גרסה זו דורשת תשלום כדי להוריד", "paidTooltip": "גרסה זו דורשת תשלום כדי להוריד",
"priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz",
"priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue 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", "freeNow": "[TODO: Translate] Free Now",
"freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment",
"ignored": "התעלם", "ignored": "התעלם",
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו", "ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו",
"onSiteOnly": "רק באתר", "onSiteOnly": "רק באתר",
"onSiteOnlyTooltip": "גרסה זו זמינה רק ליצירה באתר CivitAI", "onSiteOnlyTooltip": "גרסה זו זמינה רק ליצירה באתר CivitAI",
"priceUnavailable": "[TODO: Translate] Price unavailable", "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that"
"priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser"
}, },
"actions": { "actions": {
"download": "הורדה", "download": "הורדה",
@@ -2328,8 +2318,7 @@
"notificationsTitle": "מרכז התראות", "notificationsTitle": "מרכז התראות",
"tabs": { "tabs": {
"updates": "עדכונים", "updates": "עדכונים",
"messages": "הודעות", "messages": "הודעות"
"priceAlerts": "[TODO: Translate] Price Alerts"
}, },
"updateAvailable": "עדכון זמין", "updateAvailable": "עדכון זמין",
"noChangelogAvailable": "אין יומן שינויים מפורט זמין. בדוק ב-GitHub למידע נוסף.", "noChangelogAvailable": "אין יומן שינויים מפורט זמין. בדוק ב-GitHub למידע נוסף.",
@@ -2385,34 +2374,6 @@
"shown": "הוצג {time}", "shown": "הוצג {time}",
"dismissed": "הוסר {time}", "dismissed": "הוסר {time}",
"active": "פעיל" "active": "פעיל"
},
"priceAlerts": {
"disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.",
"openSettings": "[TODO: Translate] Open settings",
"segmentsLabel": "[TODO: Translate] Alert kind",
"segments": {
"belowThreshold": "[TODO: Translate] Under threshold",
"becameFree": "[TODO: Translate] Became free"
},
"thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz",
"empty": "[TODO: Translate] Nothing is under your price threshold right now.",
"emptyFree": "[TODO: Translate] No version has become free yet.",
"loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.",
"lastChecked": "[TODO: Translate] Prices last checked {when}",
"justNow": "[TODO: Translate] just now",
"minutesAgo": "[TODO: Translate] {count} min ago",
"hoursAgo": "[TODO: Translate] {count} h ago",
"daysAgo": "[TODO: Translate] {count} d ago",
"freeNow": "[TODO: Translate] Free now",
"blueBuzz": "[TODO: Translate] Blue Buzz OK",
"earlyAccessUntil": "[TODO: Translate] Early access until {date}",
"inLibrary": "[TODO: Translate] In library",
"notInLibrary": "[TODO: Translate] Not in library",
"droppedAgo": "[TODO: Translate] dropped {when}",
"openCivitai": "[TODO: Translate] Open on CivitAI",
"openLocal": "[TODO: Translate] Open",
"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": { "support": {
+3 -42
View File
@@ -204,7 +204,6 @@
"error": "{type} のアップデート確認に失敗しました: {message}", "error": "{type} のアップデート確認に失敗しました: {message}",
"gateEvents": { "gateEvents": {
"becameFree": "[TODO: Translate] {count} version(s) became free", "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" "newGate": "[TODO: Translate] {count} version(s) are now paid"
} }
}, },
@@ -234,9 +233,6 @@
}, },
"groupByModel": { "groupByModel": {
"label": "モデルでグループ化" "label": "モデルでグループ化"
},
"priceAlerts": {
"label": "[TODO: Translate] Price alerts"
} }
}, },
"header": { "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.", "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", "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", "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)", "refreshHours": "[TODO: Translate] Price refresh interval (hours)",
"refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again"
}, },
@@ -945,9 +939,7 @@
"action": "アップデート", "action": "アップデート",
"menuLabel": "更新オプションを表示", "menuLabel": "更新オプションを表示",
"check": "アップデートを確認", "check": "アップデートを確認",
"checkTooltip": "確認には時間がかかる場合があります。", "checkTooltip": "確認には時間がかかる場合があります。"
"priceAlerts": "[TODO: Translate] Price alerts",
"priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions"
} }
}, },
"bulkOperations": { "bulkOperations": {
@@ -2031,15 +2023,13 @@
"paidTooltip": "このバージョンのダウンロードには支払いが必要です", "paidTooltip": "このバージョンのダウンロードには支払いが必要です",
"priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz",
"priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue 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", "freeNow": "[TODO: Translate] Free Now",
"freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment",
"ignored": "無視中", "ignored": "無視中",
"ignoredTooltip": "このバージョンの更新通知は無効です", "ignoredTooltip": "このバージョンの更新通知は無効です",
"onSiteOnly": "サイト内のみ", "onSiteOnly": "サイト内のみ",
"onSiteOnlyTooltip": "このバージョンはCivitAIサイト内でのみ利用可能で、ダウンロードはできません", "onSiteOnlyTooltip": "このバージョンはCivitAIサイト内でのみ利用可能で、ダウンロードはできません",
"priceUnavailable": "[TODO: Translate] Price unavailable", "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that"
"priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser"
}, },
"actions": { "actions": {
"download": "ダウンロード", "download": "ダウンロード",
@@ -2328,8 +2318,7 @@
"notificationsTitle": "通知センター", "notificationsTitle": "通知センター",
"tabs": { "tabs": {
"updates": "更新", "updates": "更新",
"messages": "メッセージ", "messages": "メッセージ"
"priceAlerts": "[TODO: Translate] Price Alerts"
}, },
"updateAvailable": "更新が利用可能", "updateAvailable": "更新が利用可能",
"noChangelogAvailable": "詳細な変更ログは利用できません。詳細はGitHubでご確認ください。", "noChangelogAvailable": "詳細な変更ログは利用できません。詳細はGitHubでご確認ください。",
@@ -2385,34 +2374,6 @@
"shown": "{time} に表示", "shown": "{time} に表示",
"dismissed": "{time} に非表示", "dismissed": "{time} に非表示",
"active": "アクティブ" "active": "アクティブ"
},
"priceAlerts": {
"disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.",
"openSettings": "[TODO: Translate] Open settings",
"segmentsLabel": "[TODO: Translate] Alert kind",
"segments": {
"belowThreshold": "[TODO: Translate] Under threshold",
"becameFree": "[TODO: Translate] Became free"
},
"thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz",
"empty": "[TODO: Translate] Nothing is under your price threshold right now.",
"emptyFree": "[TODO: Translate] No version has become free yet.",
"loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.",
"lastChecked": "[TODO: Translate] Prices last checked {when}",
"justNow": "[TODO: Translate] just now",
"minutesAgo": "[TODO: Translate] {count} min ago",
"hoursAgo": "[TODO: Translate] {count} h ago",
"daysAgo": "[TODO: Translate] {count} d ago",
"freeNow": "[TODO: Translate] Free now",
"blueBuzz": "[TODO: Translate] Blue Buzz OK",
"earlyAccessUntil": "[TODO: Translate] Early access until {date}",
"inLibrary": "[TODO: Translate] In library",
"notInLibrary": "[TODO: Translate] Not in library",
"droppedAgo": "[TODO: Translate] dropped {when}",
"openCivitai": "[TODO: Translate] Open on CivitAI",
"openLocal": "[TODO: Translate] Open",
"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": { "support": {
+3 -42
View File
@@ -204,7 +204,6 @@
"error": "{type} 업데이트 확인 실패: {message}", "error": "{type} 업데이트 확인 실패: {message}",
"gateEvents": { "gateEvents": {
"becameFree": "[TODO: Translate] {count} version(s) became free", "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" "newGate": "[TODO: Translate] {count} version(s) are now paid"
} }
}, },
@@ -234,9 +233,6 @@
}, },
"groupByModel": { "groupByModel": {
"label": "모델별 그룹화" "label": "모델별 그룹화"
},
"priceAlerts": {
"label": "[TODO: Translate] Price alerts"
} }
}, },
"header": { "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.", "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", "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", "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)", "refreshHours": "[TODO: Translate] Price refresh interval (hours)",
"refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again"
}, },
@@ -945,9 +939,7 @@
"action": "업데이트", "action": "업데이트",
"menuLabel": "업데이트 옵션 표시", "menuLabel": "업데이트 옵션 표시",
"check": "업데이트 확인", "check": "업데이트 확인",
"checkTooltip": "업데이트 확인에는 시간이 걸릴 수 있습니다.", "checkTooltip": "업데이트 확인에는 시간이 걸릴 수 있습니다."
"priceAlerts": "[TODO: Translate] Price alerts",
"priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions"
} }
}, },
"bulkOperations": { "bulkOperations": {
@@ -2031,15 +2023,13 @@
"paidTooltip": "이 버전은 다운로드하려면 결제가 필요합니다", "paidTooltip": "이 버전은 다운로드하려면 결제가 필요합니다",
"priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz",
"priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue 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", "freeNow": "[TODO: Translate] Free Now",
"freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment",
"ignored": "무시됨", "ignored": "무시됨",
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다", "ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
"onSiteOnly": "사이트 내 전용", "onSiteOnly": "사이트 내 전용",
"onSiteOnlyTooltip": "이 버전은 CivitAI 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다", "onSiteOnlyTooltip": "이 버전은 CivitAI 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
"priceUnavailable": "[TODO: Translate] Price unavailable", "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that"
"priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser"
}, },
"actions": { "actions": {
"download": "다운로드", "download": "다운로드",
@@ -2328,8 +2318,7 @@
"notificationsTitle": "알림 센터", "notificationsTitle": "알림 센터",
"tabs": { "tabs": {
"updates": "업데이트", "updates": "업데이트",
"messages": "메시지", "messages": "메시지"
"priceAlerts": "[TODO: Translate] Price Alerts"
}, },
"updateAvailable": "업데이트 사용 가능", "updateAvailable": "업데이트 사용 가능",
"noChangelogAvailable": "상세한 변경 로그가 없습니다. 더 많은 정보는 GitHub를 확인하세요.", "noChangelogAvailable": "상세한 변경 로그가 없습니다. 더 많은 정보는 GitHub를 확인하세요.",
@@ -2385,34 +2374,6 @@
"shown": "{time}에 표시", "shown": "{time}에 표시",
"dismissed": "{time}에 닫힘", "dismissed": "{time}에 닫힘",
"active": "활성" "active": "활성"
},
"priceAlerts": {
"disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.",
"openSettings": "[TODO: Translate] Open settings",
"segmentsLabel": "[TODO: Translate] Alert kind",
"segments": {
"belowThreshold": "[TODO: Translate] Under threshold",
"becameFree": "[TODO: Translate] Became free"
},
"thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz",
"empty": "[TODO: Translate] Nothing is under your price threshold right now.",
"emptyFree": "[TODO: Translate] No version has become free yet.",
"loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.",
"lastChecked": "[TODO: Translate] Prices last checked {when}",
"justNow": "[TODO: Translate] just now",
"minutesAgo": "[TODO: Translate] {count} min ago",
"hoursAgo": "[TODO: Translate] {count} h ago",
"daysAgo": "[TODO: Translate] {count} d ago",
"freeNow": "[TODO: Translate] Free now",
"blueBuzz": "[TODO: Translate] Blue Buzz OK",
"earlyAccessUntil": "[TODO: Translate] Early access until {date}",
"inLibrary": "[TODO: Translate] In library",
"notInLibrary": "[TODO: Translate] Not in library",
"droppedAgo": "[TODO: Translate] dropped {when}",
"openCivitai": "[TODO: Translate] Open on CivitAI",
"openLocal": "[TODO: Translate] Open",
"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": { "support": {
+3 -42
View File
@@ -204,7 +204,6 @@
"error": "Не удалось проверить обновления для {type}: {message}", "error": "Не удалось проверить обновления для {type}: {message}",
"gateEvents": { "gateEvents": {
"becameFree": "[TODO: Translate] {count} version(s) became free", "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" "newGate": "[TODO: Translate] {count} version(s) are now paid"
} }
}, },
@@ -234,9 +233,6 @@
}, },
"groupByModel": { "groupByModel": {
"label": "Группировать по модели" "label": "Группировать по модели"
},
"priceAlerts": {
"label": "[TODO: Translate] Price alerts"
} }
}, },
"header": { "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.", "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", "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", "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)", "refreshHours": "[TODO: Translate] Price refresh interval (hours)",
"refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again"
}, },
@@ -945,9 +939,7 @@
"action": "Обновления", "action": "Обновления",
"menuLabel": "Показать параметры обновления", "menuLabel": "Показать параметры обновления",
"check": "Проверить обновления", "check": "Проверить обновления",
"checkTooltip": "Проверка может занять время.", "checkTooltip": "Проверка может занять время."
"priceAlerts": "[TODO: Translate] Price alerts",
"priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions"
} }
}, },
"bulkOperations": { "bulkOperations": {
@@ -2031,15 +2023,13 @@
"paidTooltip": "Скачивание этой версии платное", "paidTooltip": "Скачивание этой версии платное",
"priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz",
"priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue 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", "freeNow": "[TODO: Translate] Free Now",
"freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment",
"ignored": "Игнорируется", "ignored": "Игнорируется",
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены", "ignoredTooltip": "Уведомления об обновлениях для этой версии отключены",
"onSiteOnly": "Только на Сайте", "onSiteOnly": "Только на Сайте",
"onSiteOnlyTooltip": "Эта версия доступна только для генерации на сайте CivitAI", "onSiteOnlyTooltip": "Эта версия доступна только для генерации на сайте CivitAI",
"priceUnavailable": "[TODO: Translate] Price unavailable", "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that"
"priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser"
}, },
"actions": { "actions": {
"download": "Скачать", "download": "Скачать",
@@ -2328,8 +2318,7 @@
"notificationsTitle": "Центр уведомлений", "notificationsTitle": "Центр уведомлений",
"tabs": { "tabs": {
"updates": "Обновления", "updates": "Обновления",
"messages": "Сообщения", "messages": "Сообщения"
"priceAlerts": "[TODO: Translate] Price Alerts"
}, },
"updateAvailable": "Доступно обновление", "updateAvailable": "Доступно обновление",
"noChangelogAvailable": "Подробный список изменений недоступен. Проверьте GitHub для получения дополнительной информации.", "noChangelogAvailable": "Подробный список изменений недоступен. Проверьте GitHub для получения дополнительной информации.",
@@ -2385,34 +2374,6 @@
"shown": "Показано {time}", "shown": "Показано {time}",
"dismissed": "Закрыто {time}", "dismissed": "Закрыто {time}",
"active": "Активно" "active": "Активно"
},
"priceAlerts": {
"disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.",
"openSettings": "[TODO: Translate] Open settings",
"segmentsLabel": "[TODO: Translate] Alert kind",
"segments": {
"belowThreshold": "[TODO: Translate] Under threshold",
"becameFree": "[TODO: Translate] Became free"
},
"thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz",
"empty": "[TODO: Translate] Nothing is under your price threshold right now.",
"emptyFree": "[TODO: Translate] No version has become free yet.",
"loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.",
"lastChecked": "[TODO: Translate] Prices last checked {when}",
"justNow": "[TODO: Translate] just now",
"minutesAgo": "[TODO: Translate] {count} min ago",
"hoursAgo": "[TODO: Translate] {count} h ago",
"daysAgo": "[TODO: Translate] {count} d ago",
"freeNow": "[TODO: Translate] Free now",
"blueBuzz": "[TODO: Translate] Blue Buzz OK",
"earlyAccessUntil": "[TODO: Translate] Early access until {date}",
"inLibrary": "[TODO: Translate] In library",
"notInLibrary": "[TODO: Translate] Not in library",
"droppedAgo": "[TODO: Translate] dropped {when}",
"openCivitai": "[TODO: Translate] Open on CivitAI",
"openLocal": "[TODO: Translate] Open",
"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": { "support": {
+3 -42
View File
@@ -204,7 +204,6 @@
"error": "检查 {type} 更新失败:{message}", "error": "检查 {type} 更新失败:{message}",
"gateEvents": { "gateEvents": {
"becameFree": "[TODO: Translate] {count} version(s) became free", "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" "newGate": "[TODO: Translate] {count} version(s) are now paid"
} }
}, },
@@ -234,9 +233,6 @@
}, },
"groupByModel": { "groupByModel": {
"label": "按模型分组" "label": "按模型分组"
},
"priceAlerts": {
"label": "[TODO: Translate] Price alerts"
} }
}, },
"header": { "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.", "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", "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", "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)", "refreshHours": "[TODO: Translate] Price refresh interval (hours)",
"refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again"
}, },
@@ -945,9 +939,7 @@
"action": "更新", "action": "更新",
"menuLabel": "显示更新选项", "menuLabel": "显示更新选项",
"check": "检查更新", "check": "检查更新",
"checkTooltip": "检查更新可能耗时。", "checkTooltip": "检查更新可能耗时。"
"priceAlerts": "[TODO: Translate] Price alerts",
"priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions"
} }
}, },
"bulkOperations": { "bulkOperations": {
@@ -2031,15 +2023,13 @@
"paidTooltip": "此版本需要付费后才能下载", "paidTooltip": "此版本需要付费后才能下载",
"priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz",
"priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue 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", "freeNow": "[TODO: Translate] Free Now",
"freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment",
"ignored": "已忽略", "ignored": "已忽略",
"ignoredTooltip": "此版本已关闭更新通知", "ignoredTooltip": "此版本已关闭更新通知",
"onSiteOnly": "仅站内生成", "onSiteOnly": "仅站内生成",
"onSiteOnlyTooltip": "此版本仅在 CivitAI 站内可用,无法下载", "onSiteOnlyTooltip": "此版本仅在 CivitAI 站内可用,无法下载",
"priceUnavailable": "[TODO: Translate] Price unavailable", "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that"
"priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser"
}, },
"actions": { "actions": {
"download": "下载", "download": "下载",
@@ -2328,8 +2318,7 @@
"notificationsTitle": "通知中心", "notificationsTitle": "通知中心",
"tabs": { "tabs": {
"updates": "更新", "updates": "更新",
"messages": "消息", "messages": "消息"
"priceAlerts": "[TODO: Translate] Price Alerts"
}, },
"updateAvailable": "更新可用", "updateAvailable": "更新可用",
"noChangelogAvailable": "没有详细的更新日志可用。请查看 GitHub 以获取更多信息。", "noChangelogAvailable": "没有详细的更新日志可用。请查看 GitHub 以获取更多信息。",
@@ -2385,34 +2374,6 @@
"shown": "{time} 显示", "shown": "{time} 显示",
"dismissed": "{time} 关闭", "dismissed": "{time} 关闭",
"active": "仍在显示" "active": "仍在显示"
},
"priceAlerts": {
"disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.",
"openSettings": "[TODO: Translate] Open settings",
"segmentsLabel": "[TODO: Translate] Alert kind",
"segments": {
"belowThreshold": "[TODO: Translate] Under threshold",
"becameFree": "[TODO: Translate] Became free"
},
"thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz",
"empty": "[TODO: Translate] Nothing is under your price threshold right now.",
"emptyFree": "[TODO: Translate] No version has become free yet.",
"loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.",
"lastChecked": "[TODO: Translate] Prices last checked {when}",
"justNow": "[TODO: Translate] just now",
"minutesAgo": "[TODO: Translate] {count} min ago",
"hoursAgo": "[TODO: Translate] {count} h ago",
"daysAgo": "[TODO: Translate] {count} d ago",
"freeNow": "[TODO: Translate] Free now",
"blueBuzz": "[TODO: Translate] Blue Buzz OK",
"earlyAccessUntil": "[TODO: Translate] Early access until {date}",
"inLibrary": "[TODO: Translate] In library",
"notInLibrary": "[TODO: Translate] Not in library",
"droppedAgo": "[TODO: Translate] dropped {when}",
"openCivitai": "[TODO: Translate] Open on CivitAI",
"openLocal": "[TODO: Translate] Open",
"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": { "support": {
+3 -42
View File
@@ -204,7 +204,6 @@
"error": "檢查 {type} 更新失敗:{message}", "error": "檢查 {type} 更新失敗:{message}",
"gateEvents": { "gateEvents": {
"becameFree": "[TODO: Translate] {count} version(s) became free", "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" "newGate": "[TODO: Translate] {count} version(s) are now paid"
} }
}, },
@@ -234,9 +233,6 @@
}, },
"groupByModel": { "groupByModel": {
"label": "按模型分組" "label": "按模型分組"
},
"priceAlerts": {
"label": "[TODO: Translate] Price alerts"
} }
}, },
"header": { "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.", "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", "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", "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)", "refreshHours": "[TODO: Translate] Price refresh interval (hours)",
"refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again" "refreshHoursHelp": "[TODO: Translate] How long a stored price stays fresh before it is fetched again"
}, },
@@ -945,9 +939,7 @@
"action": "更新", "action": "更新",
"menuLabel": "顯示更新選項", "menuLabel": "顯示更新選項",
"check": "檢查更新", "check": "檢查更新",
"checkTooltip": "檢查更新可能耗時。", "checkTooltip": "檢查更新可能耗時。"
"priceAlerts": "[TODO: Translate] Price alerts",
"priceAlertsTooltip": "[TODO: Translate] Buzz price alerts for paid versions"
} }
}, },
"bulkOperations": { "bulkOperations": {
@@ -2031,15 +2023,13 @@
"paidTooltip": "此版本需要付費才能下載", "paidTooltip": "此版本需要付費才能下載",
"priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz", "priceSaleTooltip": "[TODO: Translate] On sale, normally {list} Buzz",
"priceBlueBuzzTooltip": "[TODO: Translate] Can also be paid with Blue 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", "freeNow": "[TODO: Translate] Free Now",
"freeNowTooltip": "[TODO: Translate] This version no longer requires payment", "freeNowTooltip": "[TODO: Translate] This version no longer requires payment",
"ignored": "已忽略", "ignored": "已忽略",
"ignoredTooltip": "此版本已關閉更新通知", "ignoredTooltip": "此版本已關閉更新通知",
"onSiteOnly": "僅站內生成", "onSiteOnly": "僅站內生成",
"onSiteOnlyTooltip": "此版本僅在 CivitAI 站內可用,無法下載", "onSiteOnlyTooltip": "此版本僅在 CivitAI 站內可用,無法下載",
"priceUnavailable": "[TODO: Translate] Price unavailable", "earlyAccessTooltipUntil": "[TODO: Translate] Early access until {date}, free to download after that"
"priceUnavailableTooltip": "[TODO: Translate] CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser"
}, },
"actions": { "actions": {
"download": "下載", "download": "下載",
@@ -2328,8 +2318,7 @@
"notificationsTitle": "通知中心", "notificationsTitle": "通知中心",
"tabs": { "tabs": {
"updates": "更新", "updates": "更新",
"messages": "訊息", "messages": "訊息"
"priceAlerts": "[TODO: Translate] Price Alerts"
}, },
"updateAvailable": "有新版本可用", "updateAvailable": "有新版本可用",
"noChangelogAvailable": "無詳細更新日誌。請至 GitHub 查看更多資訊。", "noChangelogAvailable": "無詳細更新日誌。請至 GitHub 查看更多資訊。",
@@ -2385,34 +2374,6 @@
"shown": "{time} 顯示", "shown": "{time} 顯示",
"dismissed": "{time} 關閉", "dismissed": "{time} 關閉",
"active": "仍在顯示" "active": "仍在顯示"
},
"priceAlerts": {
"disabled": "[TODO: Translate] CivitAI does not publish prices in its public API, so prices are read from the model page. Turn on price tracking to see them here.",
"openSettings": "[TODO: Translate] Open settings",
"segmentsLabel": "[TODO: Translate] Alert kind",
"segments": {
"belowThreshold": "[TODO: Translate] Under threshold",
"becameFree": "[TODO: Translate] Became free"
},
"thresholdLabel": "[TODO: Translate] Alert threshold: {buzz} Buzz",
"empty": "[TODO: Translate] Nothing is under your price threshold right now.",
"emptyFree": "[TODO: Translate] No version has become free yet.",
"loadFailed": "[TODO: Translate] Could not refresh the alerts; showing the last known list.",
"lastChecked": "[TODO: Translate] Prices last checked {when}",
"justNow": "[TODO: Translate] just now",
"minutesAgo": "[TODO: Translate] {count} min ago",
"hoursAgo": "[TODO: Translate] {count} h ago",
"daysAgo": "[TODO: Translate] {count} d ago",
"freeNow": "[TODO: Translate] Free now",
"blueBuzz": "[TODO: Translate] Blue Buzz OK",
"earlyAccessUntil": "[TODO: Translate] Early access until {date}",
"inLibrary": "[TODO: Translate] In library",
"notInLibrary": "[TODO: Translate] Not in library",
"droppedAgo": "[TODO: Translate] dropped {when}",
"openCivitai": "[TODO: Translate] Open on CivitAI",
"openLocal": "[TODO: Translate] Open",
"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": { "support": {
-160
View File
@@ -2242,11 +2242,6 @@ class ServiceRegistryAdapter:
get_downloaded_version_history_service: Callable[[], Awaitable[Any]] get_downloaded_version_history_service: Callable[[], Awaitable[Any]]
get_backup_service: Callable[[], Awaitable[Any]] = _noop_backup_service get_backup_service: Callable[[], Awaitable[Any]] = _noop_backup_service
get_other_scanner: Callable[[], Awaitable[Any]] = ServiceRegistry.get_other_scanner get_other_scanner: Callable[[], Awaitable[Any]] = ServiceRegistry.get_other_scanner
# Shared across model types (the update DB is per library), which is what lets
# the global price-alerts endpoint cover every type in one query.
get_model_update_service: Callable[[], Awaitable[Any]] = (
ServiceRegistry.get_model_update_service
)
class ModelLibraryHandler: class ModelLibraryHandler:
@@ -4319,157 +4314,6 @@ class SidecarMigrationHandler:
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
class PriceAlertsHandler:
"""Data for the global Buzz price-alerts panel.
App-wide on purpose: the update DB is one file per library shared by every
model type, and ``ServiceRegistry.get_model_update_service()`` hands out one
instance, so the whole panel is a single query. Registered once (see
``MISC_ROUTE_DEFINITIONS``) rather than once per model type.
"""
# Model type -> adapter attribute, for the best-effort local name/path lookup.
_SCANNER_ATTRS = {
"lora": "get_lora_scanner",
"checkpoint": "get_checkpoint_scanner",
"embedding": "get_embedding_scanner",
"other": "get_other_scanner",
}
def __init__(
self,
*,
settings_service,
service_registry: ServiceRegistryAdapter,
) -> None:
self._settings = settings_service
self._service_registry = service_registry
def _setting(self, key: str, default: Any) -> Any:
if self._settings is None:
return default
try:
value = self._settings.get(key, default)
except Exception:
return default
return default if value is None else value
async def get_price_alerts(self, request: web.Request) -> web.Response:
"""Versions under the alert threshold, plus the ones that became free.
GET with an optional ``limit`` so the companion extension can call it too.
"""
try:
limit = int(request.query.get("limit") or 200)
except (TypeError, ValueError):
limit = 200
threshold = 0
try:
threshold = max(0, int(self._setting("price_alert_threshold_buzz", 0)))
except (TypeError, ValueError):
threshold = 0
try:
update_service = await self._service_registry.get_model_update_service()
except Exception as exc: # pragma: no cover - defensive
logger.error("Price alerts unavailable: %s", exc, exc_info=True)
return web.json_response(
{"success": False, "error": "Update service unavailable"}, status=503
)
try:
alerts = await update_service.get_price_alerts(
None, threshold_buzz=threshold, limit=limit
)
newest_checked_at = update_service.newest_price_checked_at()
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: class MiscHandlerSet:
"""Aggregate handlers into a lookup compatible with the registrar.""" """Aggregate handlers into a lookup compatible with the registrar."""
@@ -4493,7 +4337,6 @@ class MiscHandlerSet:
doctor: DoctorHandler, doctor: DoctorHandler,
example_workflows: ExampleWorkflowsHandler, example_workflows: ExampleWorkflowsHandler,
base_model: BaseModelHandlerSet, base_model: BaseModelHandlerSet,
price_alerts: PriceAlertsHandler,
model_source_handler: Any = None, model_source_handler: Any = None,
agent_handler: Any = None, agent_handler: Any = None,
download_routing: Any = None, download_routing: Any = None,
@@ -4516,7 +4359,6 @@ class MiscHandlerSet:
self.doctor = doctor self.doctor = doctor
self.example_workflows = example_workflows self.example_workflows = example_workflows
self.base_model = base_model self.base_model = base_model
self.price_alerts = price_alerts
self.model_source_handler = model_source_handler self.model_source_handler = model_source_handler
self.agent_handler = agent_handler self.agent_handler = agent_handler
self.download_routing = download_routing self.download_routing = download_routing
@@ -4530,7 +4372,6 @@ class MiscHandlerSet:
"get_init_status": self.health.get_init_status, "get_init_status": self.health.get_init_status,
"get_settings": self.settings.get_settings, "get_settings": self.settings.get_settings,
"update_settings": self.settings.update_settings, "update_settings": self.settings.update_settings,
"get_price_alerts": self.price_alerts.get_price_alerts,
"get_doctor_diagnostics": self.doctor.get_doctor_diagnostics, "get_doctor_diagnostics": self.doctor.get_doctor_diagnostics,
"repair_doctor_cache": self.doctor.repair_doctor_cache, "repair_doctor_cache": self.doctor.repair_doctor_cache,
"resolve_doctor_filename_conflicts": self.doctor.resolve_filename_conflicts, "resolve_doctor_filename_conflicts": self.doctor.resolve_filename_conflicts,
@@ -4607,5 +4448,4 @@ def build_service_registry_adapter() -> ServiceRegistryAdapter:
get_other_scanner=ServiceRegistry.get_other_scanner, get_other_scanner=ServiceRegistry.get_other_scanner,
get_downloaded_version_history_service=ServiceRegistry.get_downloaded_version_history_service, get_downloaded_version_history_service=ServiceRegistry.get_downloaded_version_history_service,
get_backup_service=ServiceRegistry.get_backup_service, get_backup_service=ServiceRegistry.get_backup_service,
get_model_update_service=ServiceRegistry.get_model_update_service,
) )
-46
View File
@@ -3070,46 +3070,6 @@ class ModelUpdateHandler:
{"success": True, "record": self._serialize_record(record)} {"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: async def get_model_versions(self, request: web.Request) -> web.Response:
model_id = self._normalize_model_id(request.match_info.get("model_id")) model_id = self._normalize_model_id(request.match_info.get("model_id"))
if model_id is None: if model_id is None:
@@ -3511,11 +3471,6 @@ class ModelUpdateHandler:
"acceptsBlueBuzz": bool(getattr(version, "accepts_blue_buzz", False)), "acceptsBlueBuzz": bool(getattr(version, "accepts_blue_buzz", False)),
"priceSaleEndsAt": getattr(version, "price_sale_ends_at", None), "priceSaleEndsAt": getattr(version, "price_sale_ends_at", None),
"priceCheckedAt": getattr(version, "price_checked_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"), "filePath": context.get("file_path"),
"fileName": context.get("file_name"), "fileName": context.get("file_name"),
# Weight-file variant count (None when unknown); lets the UI hide # 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, "set_version_update_ignore": self.updates.set_version_update_ignore,
"get_model_update_status": self.updates.get_model_update_status, "get_model_update_status": self.updates.get_model_update_status,
"get_model_versions": self.updates.get_model_versions, "get_model_versions": self.updates.get_model_versions,
"get_price_alerts": self.updates.get_price_alerts,
"cancel_task": self.query.cancel_task, "cancel_task": self.query.cancel_task,
} }
-1
View File
@@ -24,7 +24,6 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/settings", "update_settings"), RouteDefinition("POST", "/api/lm/settings", "update_settings"),
# App-wide and registered once: the alerts panel spans every model type, and # 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. # the update DB is shared, so there is nothing per-type about it.
RouteDefinition("GET", "/api/lm/price-alerts", "get_price_alerts"),
RouteDefinition("GET", "/api/lm/llm/models", "get_llm_models"), RouteDefinition("GET", "/api/lm/llm/models", "get_llm_models"),
RouteDefinition("GET", "/api/lm/llm/provider-models", "get_provider_models"), RouteDefinition("GET", "/api/lm/llm/provider-models", "get_provider_models"),
RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"), RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"),
-6
View File
@@ -31,7 +31,6 @@ from .handlers.misc_handlers import (
ModelLibraryHandler, ModelLibraryHandler,
NodeRegistry, NodeRegistry,
NodeRegistryHandler, NodeRegistryHandler,
PriceAlertsHandler,
SettingsHandler, SettingsHandler,
SidecarMigrationHandler, SidecarMigrationHandler,
SupportersHandler, SupportersHandler,
@@ -141,10 +140,6 @@ class MiscRoutes:
doctor = DoctorHandler(settings_service=self._settings) doctor = DoctorHandler(settings_service=self._settings)
example_workflows = ExampleWorkflowsHandler() example_workflows = ExampleWorkflowsHandler()
base_model = BaseModelHandlerSet() base_model = BaseModelHandlerSet()
price_alerts = PriceAlertsHandler(
settings_service=self._settings,
service_registry=self._service_registry_adapter,
)
model_source_handler = ModelSourceHandler() model_source_handler = ModelSourceHandler()
agent_handler = AgentHandler() agent_handler = AgentHandler()
download_routing = DownloadRoutingHandler() download_routing = DownloadRoutingHandler()
@@ -168,7 +163,6 @@ class MiscRoutes:
doctor=doctor, doctor=doctor,
example_workflows=example_workflows, example_workflows=example_workflows,
base_model=base_model, base_model=base_model,
price_alerts=price_alerts,
model_source_handler=model_source_handler, model_source_handler=model_source_handler,
agent_handler=agent_handler, agent_handler=agent_handler,
download_routing=download_routing, download_routing=download_routing,
-3
View File
@@ -110,9 +110,6 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition( RouteDefinition(
"GET", "/api/lm/{prefix}/updates/versions/{model_id}", "get_model_versions" "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("POST", "/api/lm/download-model", "download_model"),
RouteDefinition("GET", "/api/lm/download-model-get", "download_model_get"), RouteDefinition("GET", "/api/lm/download-model-get", "download_model_get"),
RouteDefinition("GET", "/api/lm/cancel-download-get", "cancel_download_get"), RouteDefinition("GET", "/api/lm/cancel-download-get", "cancel_download_get"),
+29 -296
View File
@@ -121,13 +121,6 @@ class ModelVersionRecord:
accepts_blue_buzz: bool = False accepts_blue_buzz: bool = False
price_sale_ends_at: Optional[str] = None price_sale_ends_at: Optional[str] = None
price_checked_at: Optional[float] = 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 # 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 # "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. # UI say "price unavailable" for mature models instead of showing nothing.
@@ -145,9 +138,9 @@ class ModelUpdateRecord:
should_ignore_model: bool should_ignore_model: bool
# Gate-state and price transitions observed during the refresh that produced # Gate-state and price transitions observed during the refresh that produced
# this record. Derived, never persisted: a record read back from SQLite has an # this record. Derived, never persisted: a record read back from SQLite has an
# empty list. Each entry is {"versionId", "kind", "versionName", # empty list. Each entry is {"versionId", "kind", "versionName", "isInLibrary"}
# "isInLibrary", "priceBuzz"?} with kind in # with kind in {"new_gate", "became_free"}. Only versions the user does not
# {"new_gate", "became_free", "price_drop"}. # have produce events.
events: List[Dict[str, Any]] = field(default_factory=list) events: List[Dict[str, Any]] = field(default_factory=list)
@property @property
@@ -381,8 +374,6 @@ class ModelUpdateService:
accepts_blue_buzz INTEGER NOT NULL DEFAULT 0, accepts_blue_buzz INTEGER NOT NULL DEFAULT 0,
price_sale_ends_at TEXT, price_sale_ends_at TEXT,
price_checked_at REAL, price_checked_at REAL,
price_alert_state INTEGER NOT NULL DEFAULT 0,
price_alert_since REAL,
price_check_attempted_at REAL, price_check_attempted_at REAL,
PRIMARY KEY (model_id, version_id), PRIMARY KEY (model_id, version_id),
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
@@ -663,14 +654,6 @@ class ModelUpdateService:
"ALTER TABLE model_update_versions " "ALTER TABLE model_update_versions "
"ADD COLUMN price_checked_at REAL" "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": ( "price_check_attempted_at": (
"ALTER TABLE model_update_versions " "ALTER TABLE model_update_versions "
"ADD COLUMN price_check_attempted_at REAL" "ADD COLUMN price_check_attempted_at REAL"
@@ -681,6 +664,21 @@ class ModelUpdateService:
if column not in version_columns: if column not in version_columns:
conn.execute(statement) 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. # Refresh column metadata after applying additive migrations.
version_columns = self._get_table_columns(conn, "model_update_versions") version_columns = self._get_table_columns(conn, "model_update_versions")
@@ -786,8 +784,6 @@ class ModelUpdateService:
accepts_blue_buzz INTEGER NOT NULL DEFAULT 0, accepts_blue_buzz INTEGER NOT NULL DEFAULT 0,
price_sale_ends_at TEXT, price_sale_ends_at TEXT,
price_checked_at REAL, price_checked_at REAL,
price_alert_state INTEGER NOT NULL DEFAULT 0,
price_alert_since REAL,
price_check_attempted_at REAL, price_check_attempted_at REAL,
PRIMARY KEY (model_id, version_id), PRIMARY KEY (model_id, version_id),
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
@@ -818,8 +814,6 @@ class ModelUpdateService:
"accepts_blue_buzz", "accepts_blue_buzz",
"price_sale_ends_at", "price_sale_ends_at",
"price_checked_at", "price_checked_at",
"price_alert_state",
"price_alert_since",
"price_check_attempted_at", "price_check_attempted_at",
] ]
defaults = { defaults = {
@@ -843,8 +837,6 @@ class ModelUpdateService:
"accepts_blue_buzz": "0", "accepts_blue_buzz": "0",
"price_sale_ends_at": "NULL", "price_sale_ends_at": "NULL",
"price_checked_at": "NULL", "price_checked_at": "NULL",
"price_alert_state": "0",
"price_alert_since": "NULL",
"price_check_attempted_at": "NULL", "price_check_attempted_at": "NULL",
} }
@@ -1226,205 +1218,6 @@ class ModelUpdateService:
async with self._lock: async with self._lock:
return self._get_records_bulk(model_type, normalized_ids) 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( async def _refresh_single_model(
self, self,
model_type: str, model_type: str,
@@ -2027,23 +1820,15 @@ class ModelUpdateService:
price_fields = self._price_fields_for( price_fields = self._price_fields_for(
remote_version, existing_version, is_gated=is_gated 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 ( if (
existing_version is not None existing_version is not None
and not should_ignore and not should_ignore
and not in_library
and was_gated != is_gated and was_gated != is_gated
): ):
events.append( events.append(
@@ -2051,25 +1836,7 @@ class ModelUpdateService:
"versionId": version_id, "versionId": version_id,
"kind": "new_gate" if is_gated else "became_free", "kind": "new_gate" if is_gated else "became_free",
"versionName": remote_version.name, "versionName": remote_version.name,
"isInLibrary": version_id in effective_local_set, "isInLibrary": False,
}
)
# 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"),
} }
) )
@@ -2174,8 +1941,6 @@ class ModelUpdateService:
"accepts_blue_buzz": False, "accepts_blue_buzz": False,
"price_sale_ends_at": None, "price_sale_ends_at": None,
"price_checked_at": None, "price_checked_at": None,
"price_alert_state": False,
"price_alert_since": None,
"price_check_attempted_at": None, "price_check_attempted_at": None,
} }
@@ -2191,8 +1956,6 @@ class ModelUpdateService:
"accepts_blue_buzz": source.accepts_blue_buzz, "accepts_blue_buzz": source.accepts_blue_buzz,
"price_sale_ends_at": source.price_sale_ends_at, "price_sale_ends_at": source.price_sale_ends_at,
"price_checked_at": source.price_checked_at, "price_checked_at": source.price_checked_at,
"price_alert_state": source.price_alert_state,
"price_alert_since": source.price_alert_since,
"price_check_attempted_at": source.price_check_attempted_at, "price_check_attempted_at": source.price_check_attempted_at,
} }
@@ -2204,28 +1967,6 @@ class ModelUpdateService:
except Exception: except Exception:
return False 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: def _price_check_ttl_seconds(self) -> float:
try: try:
hours = float(self._settings.get("price_check_ttl_hours", 24)) 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, size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
is_early_access, usage_control, paid_access, is_paid, file_count, is_early_access, usage_control, paid_access, is_paid, file_count,
gate_lapsed_at, price_buzz, list_price_buzz, generation_price_buzz, gate_lapsed_at, price_buzz, list_price_buzz, generation_price_buzz,
accepts_blue_buzz, price_sale_ends_at, price_checked_at, price_alert_state, accepts_blue_buzz, price_sale_ends_at, price_checked_at,
price_alert_since, price_check_attempted_at price_check_attempted_at
FROM model_update_versions FROM model_update_versions
WHERE model_id IN ({placeholders}) WHERE model_id IN ({placeholders})
ORDER BY model_id ASC, sort_index ASC, version_id ASC ORDER BY model_id ASC, sort_index ASC, version_id ASC
@@ -2639,12 +2380,6 @@ class ModelUpdateService:
if row["price_checked_at"] is not None if row["price_checked_at"] is not None
else 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=( price_check_attempted_at=(
float(row["price_check_attempted_at"]) float(row["price_check_attempted_at"])
if row["price_check_attempted_at"] is not None 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, size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
is_early_access, usage_control, paid_access, is_paid, file_count, is_early_access, usage_control, paid_access, is_paid, file_count,
gate_lapsed_at, price_buzz, list_price_buzz, generation_price_buzz, gate_lapsed_at, price_buzz, list_price_buzz, generation_price_buzz,
accepts_blue_buzz, price_sale_ends_at, price_checked_at, price_alert_state, accepts_blue_buzz, price_sale_ends_at, price_checked_at,
price_alert_since, price_check_attempted_at price_check_attempted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
version.version_id, version.version_id,
@@ -2742,8 +2477,6 @@ class ModelUpdateService:
1 if version.accepts_blue_buzz else 0, 1 if version.accepts_blue_buzz else 0,
version.price_sale_ends_at, version.price_sale_ends_at,
version.price_checked_at, version.price_checked_at,
1 if version.price_alert_state else 0,
version.price_alert_since,
version.price_check_attempted_at, version.price_check_attempted_at,
), ),
) )
+2 -1
View File
@@ -131,8 +131,9 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"version_grouping": "same_base", "version_grouping": "same_base",
# Buzz price tracking for paid/early-access versions. Opt-in because reading a # Buzz price tracking for paid/early-access versions. Opt-in because reading a
# price costs one extra (public) model-page request per gated model. # 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_tracking_enabled": False,
"price_alert_threshold_buzz": 0, # 0 = alert on "became free" only
"price_check_ttl_hours": 24, "price_check_ttl_hours": 24,
"auto_organize_exclusions": [], "auto_organize_exclusions": [],
"metadata_refresh_skip_paths": [], "metadata_refresh_skip_paths": [],
@@ -652,167 +652,3 @@
margin-top: 5px; margin-top: 5px;
} }
} }
/* --- Buzz price alerts panel --------------------------------------------- */
.price-alerts {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.price-alerts-disabled {
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
background: var(--lora-surface);
padding: var(--space-2);
display: flex;
flex-direction: column;
gap: var(--space-2);
align-items: flex-start;
}
.price-alerts-disabled-text {
margin: 0;
color: var(--text-color);
opacity: 0.85;
font-size: 0.9rem;
}
.price-alerts-settings-btn {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 0.4rem 0.7rem;
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
background: var(--card-bg, #fff);
color: var(--text-color);
cursor: pointer;
}
.price-alerts-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
flex-wrap: wrap;
}
.price-alerts-segments {
display: inline-flex;
gap: var(--space-1);
}
.price-alerts-segment {
padding: 0.35rem 0.65rem;
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
background: var(--lora-surface);
color: var(--text-color);
cursor: pointer;
font-size: 0.85rem;
}
.price-alerts-segment.active {
background: var(--lora-accent, #845ef7);
border-color: var(--lora-accent, #845ef7);
color: #fff;
}
.price-alerts-threshold {
font-size: 0.85rem;
opacity: 0.8;
}
.price-alerts-note {
margin: 0;
font-size: 0.8rem;
opacity: 0.75;
}
.price-alerts-empty {
margin: var(--space-2) 0 0;
opacity: 0.7;
font-size: 0.9rem;
}
.price-alerts-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
max-height: 46vh;
overflow-y: auto;
}
.price-alert-item {
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
padding: var(--space-2);
background: var(--card-bg, #fff);
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.price-alert-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
flex-wrap: wrap;
}
.price-alert-model {
font-weight: 600;
}
.price-alert-price {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.price-alert-price.is-alert {
color: #2b8a3e;
}
[data-theme="dark"] .price-alert-price.is-alert {
color: #69db7c;
}
.price-alert-was {
text-decoration: line-through;
opacity: 0.6;
font-weight: 400;
margin-left: 0.35rem;
}
.price-alert-meta {
font-size: 0.8rem;
opacity: 0.75;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.price-alert-actions {
display: flex;
gap: var(--space-1);
flex-wrap: wrap;
}
.price-alert-action {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0.55rem;
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
background: var(--lora-surface);
color: var(--text-color);
cursor: pointer;
font-size: 0.8rem;
}
@@ -4,7 +4,6 @@ import { translate } from '../../utils/i18nHelpers.js';
import { state } from '../../state/index.js'; import { state } from '../../state/index.js';
import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js'; import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js'; import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { openPriceAlertsPanel, updateService } from '../../managers/UpdateService.js';
import { rematchModalManager } from '../../managers/RematchModalManager.js'; import { rematchModalManager } from '../../managers/RematchModalManager.js';
import { showRematchSummary } from '../RematchSummaryModal.js'; import { showRematchSummary } from '../RematchSummaryModal.js';
@@ -28,7 +27,6 @@ export class GlobalContextMenu extends BaseContextMenu {
const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]'); const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]');
const rematchRecipesItem = this.menu.querySelector('[data-action="rematch-recipes"]'); const rematchRecipesItem = this.menu.querySelector('[data-action="rematch-recipes"]');
const groupByModelItem = this.menu.querySelector('[data-action="toggle-group-by-model"]'); const groupByModelItem = this.menu.querySelector('[data-action="toggle-group-by-model"]');
const priceAlertsItem = this.menu.querySelector('[data-action="price-alerts"]');
const groupByModelCheck = groupByModelItem?.querySelector('.check-indicator'); const groupByModelCheck = groupByModelItem?.querySelector('.check-indicator');
// Update check indicator for group-by-model // Update check indicator for group-by-model
@@ -37,16 +35,6 @@ export class GlobalContextMenu extends BaseContextMenu {
groupByModelCheck.style.display = isEnabled ? 'block' : 'none'; groupByModelCheck.style.display = isEnabled ? 'block' : 'none';
} }
// Append the unread count when it is already known (loaded once on init,
// and only when price tracking is on), so the label stays a plain label
// for everyone else.
const priceAlertsLabel = priceAlertsItem?.querySelector('#globalPriceAlertsLabel');
if (priceAlertsLabel) {
const unread = updateService?.getUnreadPriceAlertCount?.() || 0;
const base = translate('globalContextMenu.priceAlerts.label', {}, 'Price alerts');
priceAlertsLabel.textContent = unread > 0 ? `${base} (${unread})` : base;
}
if (isRecipesPage) { if (isRecipesPage) {
modelUpdateItem?.classList.add('hidden'); modelUpdateItem?.classList.add('hidden');
licenseRefreshItem?.classList.add('hidden'); licenseRefreshItem?.classList.add('hidden');
@@ -54,7 +42,6 @@ export class GlobalContextMenu extends BaseContextMenu {
cleanupExamplesItem?.classList.add('hidden'); cleanupExamplesItem?.classList.add('hidden');
excludedModelsItem?.classList.add('hidden'); excludedModelsItem?.classList.add('hidden');
groupByModelItem?.classList.add('hidden'); groupByModelItem?.classList.add('hidden');
priceAlertsItem?.classList.add('hidden');
rematchRecipesItem?.classList.remove('hidden'); rematchRecipesItem?.classList.remove('hidden');
} else { } else {
modelUpdateItem?.classList.remove('hidden'); modelUpdateItem?.classList.remove('hidden');
@@ -63,7 +50,6 @@ export class GlobalContextMenu extends BaseContextMenu {
cleanupExamplesItem?.classList.remove('hidden'); cleanupExamplesItem?.classList.remove('hidden');
excludedModelsItem?.classList.remove('hidden'); excludedModelsItem?.classList.remove('hidden');
groupByModelItem?.classList.remove('hidden'); groupByModelItem?.classList.remove('hidden');
priceAlertsItem?.classList.remove('hidden');
rematchRecipesItem?.classList.add('hidden'); rematchRecipesItem?.classList.add('hidden');
} }
@@ -103,9 +89,6 @@ export class GlobalContextMenu extends BaseContextMenu {
console.error('Failed to check model updates:', error); console.error('Failed to check model updates:', error);
}); });
break; break;
case 'price-alerts':
openPriceAlertsPanel();
break;
case 'fetch-missing-licenses': case 'fetch-missing-licenses':
this.fetchMissingLicenses(menuItem).catch((error) => { this.fetchMissingLicenses(menuItem).catch((error) => {
console.error('Failed to refresh missing license metadata:', error); console.error('Failed to refresh missing license metadata:', error);
@@ -4,7 +4,6 @@ import { getStorageItem, setStorageItem, removeStorageItem, getSessionItem, setS
import { showToast, openCivitaiByMetadata, isTypingContext } from '../../utils/uiHelpers.js'; import { showToast, openCivitaiByMetadata, isTypingContext } from '../../utils/uiHelpers.js';
import { eventManager } from '../../utils/EventManager.js'; import { eventManager } from '../../utils/EventManager.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js'; import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { openPriceAlertsPanel } from '../../managers/UpdateService.js';
import { sidebarManager } from '../SidebarManager.js'; import { sidebarManager } from '../SidebarManager.js';
import { initSortDropdown, applySortToSelect, randomizeSortValue } from './SortDropdown.js'; import { initSortDropdown, applySortToSelect, randomizeSortValue } from './SortDropdown.js';
@@ -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 // Close dropdowns when clicking outside
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (!e.target.closest('.dropdown-group')) { if (!e.target.closest('.dropdown-group')) {
+30 -37
View File
@@ -569,11 +569,26 @@ 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 <date>"
// decides between waiting and paying.
const eaTime = formatEarlyAccessTime(version.earlyAccessEndsAt);
badges.push(buildBadge(earlyAccessBadgeLabel, 'early-access', { badges.push(buildBadge(earlyAccessBadgeLabel, 'early-access', {
title: buildPriceTooltip( title: buildPriceTooltip(
version, version,
translate( eaTime
? translate(
'modals.model.versions.badges.earlyAccessTooltipUntil',
{ date: eaTime },
`Early access until ${eaTime}`
)
: translate(
'modals.model.versions.badges.earlyAccessTooltip', 'modals.model.versions.badges.earlyAccessTooltip',
{}, {},
'This version currently requires Civitai early access' 'This version currently requires Civitai early access'
@@ -582,52 +597,30 @@ function renderRow(version, options) {
})); }));
} }
if (isPaidPermanent(version)) { 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', { badges.push(buildBadge(paidBadgeLabel, 'paid', {
title: buildPriceTooltip( title: translate(
version,
translate(
'modals.model.versions.badges.paidTooltip', 'modals.model.versions.badges.paidTooltip',
{}, {},
'This version requires payment to download' '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 // 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. // 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'); const freeBadgeLabel = translate('modals.model.versions.badges.freeNow', {}, 'Free Now');
badges.push(buildBadge(freeBadgeLabel, 'success', { badges.push(buildBadge(freeBadgeLabel, 'success', {
title: translate( title: translate(
-5
View File
@@ -1169,11 +1169,6 @@ export class SettingsManager {
priceTrackingEnabledCheckbox.checked = state.global.settings.price_tracking_enabled || false; 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'); const priceCheckTtlInput = document.getElementById('priceCheckTtlHours');
if (priceCheckTtlInput) { if (priceCheckTtlInput) {
priceCheckTtlInput.value = state.global.settings.price_check_ttl_hours ?? 24; priceCheckTtlInput.value = state.global.settings.price_check_ttl_hours ?? 24;
-443
View File
@@ -9,79 +9,6 @@ import {
import { state } from '../state/index.js'; import { state } from '../state/index.js';
import { bannerService } from './BannerService.js'; import { bannerService } from './BannerService.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { showModelModal } from '../components/shared/ModelModal.js';
/** Global endpoint: the alerts panel spans every model type. */
const PRICE_ALERTS_ENDPOINT = '/api/lm/price-alerts';
/** localStorage watermark of the newest alert the user has already seen. */
const PRICE_ALERTS_VIEWED_KEY = 'lm_price_alerts_viewed_at';
async function fetchPriceAlerts(limit = 200) {
const response = await fetch(
`${PRICE_ALERTS_ENDPOINT}?limit=${encodeURIComponent(limit)}`
);
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.success !== true) {
throw new Error(payload?.error || response.statusText || 'Failed to load price alerts');
}
return {
alerts: Array.isArray(payload.alerts) ? payload.alerts : [],
enabled: payload.enabled === true,
thresholdBuzz: payload.thresholdBuzz ?? 0,
newestCheckedAt: payload.newestCheckedAt ?? null,
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 { export class UpdateService {
constructor() { constructor() {
@@ -108,18 +35,6 @@ export class UpdateService {
this.activeNotificationTab = 'updates'; this.activeNotificationTab = 'updates';
this.handleBannerHistoryUpdated = this.handleBannerHistoryUpdated.bind(this); this.handleBannerHistoryUpdated = this.handleBannerHistoryUpdated.bind(this);
this.handleNotificationTabKeydown = this.handleNotificationTabKeydown.bind(this); this.handleNotificationTabKeydown = this.handleNotificationTabKeydown.bind(this);
// Buzz price alerts (panel data, loaded on demand; the count is cached so
// the bell badge and the context-menu label can share it).
this.priceAlerts = null;
this.priceAlertsEnabled = false;
this.priceAlertsThreshold = 0;
this.priceAlertsNewestCheckedAt = null;
this.priceAlertsUnavailableCount = 0;
this.priceAlertsPricedCount = 0;
this.priceAlertsLoading = false;
this.priceAlertSegment = 'below_threshold';
this.unreadPriceAlertCount = 0;
this.handlePriceAlertSegmentClick = this.handlePriceAlertSegmentClick.bind(this);
} }
initialize() { initialize() {
@@ -335,349 +250,7 @@ export class UpdateService {
}); });
this.renderRecentBanners(); this.renderRecentBanners();
this.wirePriceAlertsPanel();
this.switchNotificationTab(this.activeNotificationTab); this.switchNotificationTab(this.activeNotificationTab);
// One request, and only for users who turned price tracking on. It feeds
// the tab badge and the global-context-menu count.
if (state?.global?.settings?.price_tracking_enabled) {
this.loadPriceAlerts().catch((error) => {
console.warn('Failed to preload price alerts:', error);
});
}
}
wirePriceAlertsPanel() {
const modal = document.getElementById('updateModal');
if (!modal) return;
this.priceAlertSegments = Array.from(
modal.querySelectorAll('[data-price-alert-segment]')
);
this.priceAlertSegments.forEach((segment) => {
segment.addEventListener('click', this.handlePriceAlertSegmentClick);
});
const settingsBtn = document.getElementById('priceAlertsSettingsBtn');
if (settingsBtn) {
settingsBtn.addEventListener('click', () => {
this.toggleUpdateModal();
if (window.settingsManager) {
window.settingsManager.toggleSettings();
}
});
}
}
handlePriceAlertSegmentClick(event) {
const segment = event?.currentTarget?.getAttribute('data-price-alert-segment');
if (!segment) return;
this.priceAlertSegment = segment;
this.renderPriceAlerts();
}
/**
* Open the bell on the price alerts tab.
*
* ``toggleUpdateModal()`` *closes* an open bell, so an entry point that called
* it unconditionally would dismiss the modal instead of switching to the tab.
*/
openPriceAlertsTab() {
const modal = modalManager.getModal('updateModal');
if (!modal || !modal.isOpen) {
this.toggleUpdateModal();
}
this.switchNotificationTab('priceAlerts', { markRead: true });
}
/**
* Load the global alerts payload (all model types in one query).
* @param {{force?: boolean}} [options]
*/
async loadPriceAlerts({ force = false } = {}) {
if (this.priceAlertsLoading) return this.priceAlerts;
if (!force && this.priceAlerts) {
this.renderPriceAlerts();
return this.priceAlerts;
}
this.priceAlertsLoading = true;
try {
const payload = await fetchPriceAlerts();
this.priceAlerts = payload.alerts;
this.priceAlertsEnabled = payload.enabled;
this.priceAlertsThreshold = payload.thresholdBuzz;
this.priceAlertsNewestCheckedAt = payload.newestCheckedAt;
this.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 } = {}) { switchNotificationTab(tabName, { markRead = false } = {}) {
@@ -710,14 +283,6 @@ export class UpdateService {
} }
} }
if (tabName === 'priceAlerts') {
this.loadPriceAlerts().then(() => {
if (markRead) {
this.markPriceAlertsViewed();
}
});
}
this.updateTabBadges(); this.updateTabBadges();
} }
@@ -778,12 +343,10 @@ export class UpdateService {
updateTabBadges() { updateTabBadges() {
const updatesBadge = document.getElementById('updatesTabBadge'); const updatesBadge = document.getElementById('updatesTabBadge');
const bannerBadge = document.getElementById('bannerTabBadge'); const bannerBadge = document.getElementById('bannerTabBadge');
const priceAlertsBadge = document.getElementById('priceAlertsTabBadge');
const hasUpdate = this.updateNotificationsEnabled && this.updateAvailable; const hasUpdate = this.updateNotificationsEnabled && this.updateAvailable;
const unreadBanners = typeof bannerService.getUnreadBannerCount === 'function' const unreadBanners = typeof bannerService.getUnreadBannerCount === 'function'
? bannerService.getUnreadBannerCount() ? bannerService.getUnreadBannerCount()
: 0; : 0;
const unreadAlerts = this.getUnreadPriceAlertCount();
if (updatesBadge) { if (updatesBadge) {
updatesBadge.classList.toggle('visible', hasUpdate); updatesBadge.classList.toggle('visible', hasUpdate);
@@ -791,12 +354,6 @@ export class UpdateService {
updatesBadge.textContent = ''; updatesBadge.textContent = '';
} }
if (priceAlertsBadge) {
priceAlertsBadge.textContent = unreadAlerts > 9 ? '9+' : unreadAlerts ? String(unreadAlerts) : '';
priceAlertsBadge.classList.toggle('visible', unreadAlerts > 0);
priceAlertsBadge.classList.remove('is-dot');
}
if (bannerBadge) { if (bannerBadge) {
if (unreadBanners > 0) { if (unreadBanners > 0) {
bannerBadge.textContent = unreadBanners > 9 ? '9+' : unreadBanners.toString(); bannerBadge.textContent = unreadBanners > 9 ? '9+' : unreadBanners.toString();
-1
View File
@@ -58,7 +58,6 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
hide_early_access_updates: false, hide_early_access_updates: false,
hide_paid_updates: false, hide_paid_updates: false,
price_tracking_enabled: false, price_tracking_enabled: false,
price_alert_threshold_buzz: 0,
price_check_ttl_hours: 24, price_check_ttl_hours: 24,
auto_organize_exclusions: [], auto_organize_exclusions: [],
metadata_refresh_skip_paths: [], metadata_refresh_skip_paths: [],
+3 -13
View File
@@ -18,7 +18,6 @@ export function summarizeGateEvents(events) {
return { return {
becameFree: list.filter((event) => event?.kind === 'became_free').length, becameFree: list.filter((event) => event?.kind === 'became_free').length,
newGate: list.filter((event) => event?.kind === 'new_gate').length, newGate: list.filter((event) => event?.kind === 'new_gate').length,
priceDrop: list.filter((event) => event?.kind === 'price_drop').length,
total: list.length total: list.length
}; };
} }
@@ -29,8 +28,8 @@ export function summarizeGateEvents(events) {
* @param {Array} events * @param {Array} events
*/ */
export function showGateEventsToast(events) { export function showGateEventsToast(events) {
const { becameFree, newGate, priceDrop } = summarizeGateEvents(events); const { becameFree, newGate } = summarizeGateEvents(events);
if (!becameFree && !newGate && !priceDrop) { if (!becameFree && !newGate) {
return; 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) { if (newGate) {
parts.push( parts.push(
translate( translate(
@@ -63,7 +53,7 @@ export function showGateEventsToast(events) {
); );
} }
showToast(parts.join(' · '), {}, becameFree || priceDrop ? 'success' : 'info'); showToast(parts.join(' · '), {}, becameFree ? 'success' : 'info');
} }
/** /**
-3
View File
@@ -185,9 +185,6 @@
<div class="context-menu-item" data-action="check-model-updates"> <div class="context-menu-item" data-action="check-model-updates">
<i class="fas fa-sync-alt"></i> <span>{{ t('globalContextMenu.checkModelUpdates.label') }}</span> <i class="fas fa-sync-alt"></i> <span>{{ t('globalContextMenu.checkModelUpdates.label') }}</span>
</div> </div>
<div class="context-menu-item" data-action="price-alerts">
<i class="fas fa-tags"></i> <span id="globalPriceAlertsLabel">{{ t('globalContextMenu.priceAlerts.label') }}</span>
</div>
<div class="context-menu-item" data-action="fetch-missing-licenses"> <div class="context-menu-item" data-action="fetch-missing-licenses">
<i class="fas fa-shield-alt"></i> <span>{{ t('globalContextMenu.fetchMissingLicenses.label') }}</span> <i class="fas fa-shield-alt"></i> <span>{{ t('globalContextMenu.fetchMissingLicenses.label') }}</span>
</div> </div>
-3
View File
@@ -122,9 +122,6 @@
<div id="checkUpdatesMenuItem" class="dropdown-item" data-action="check-updates" title="{{ t('loras.controls.updates.checkTooltip') }}"> <div id="checkUpdatesMenuItem" class="dropdown-item" data-action="check-updates" title="{{ t('loras.controls.updates.checkTooltip') }}">
<i class="fas fa-sync-alt"></i> <span>{{ t('loras.controls.updates.check') }}</span> <i class="fas fa-sync-alt"></i> <span>{{ t('loras.controls.updates.check') }}</span>
</div> </div>
<div id="priceAlertsMenuItem" class="dropdown-item" data-action="price-alerts" title="{{ t('loras.controls.updates.priceAlertsTooltip') }}">
<i class="fas fa-tags"></i> <span>{{ t('loras.controls.updates.priceAlerts') }}</span>
</div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
@@ -197,28 +197,6 @@
{{ sm.subsection_header('settings.sections.priceTracking') }} {{ sm.subsection_header('settings.sections.priceTracking') }}
<div class="settings-help-text subtle">{{ t('settings.priceTracking.help') }}</div> <div class="settings-help-text subtle">{{ t('settings.priceTracking.help') }}</div>
{{ sm.setting_toggle('priceTrackingEnabled', 'price_tracking_enabled', 'settings.priceTracking.enabled', 'settings.priceTracking.enabledHelp') }} {{ sm.setting_toggle('priceTrackingEnabled', 'price_tracking_enabled', 'settings.priceTracking.enabled', 'settings.priceTracking.enabledHelp') }}
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="priceAlertThresholdBuzz">
{{ t('settings.priceTracking.threshold') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.priceTracking.thresholdHelp') }}"></i>
</label>
</div>
<div class="setting-control">
<div class="text-input-wrapper">
<input
type="number"
id="priceAlertThresholdBuzz"
min="0"
step="1"
onblur="settingsManager.saveInputSetting('priceAlertThresholdBuzz', 'price_alert_threshold_buzz')"
onkeydown="if(event.key === 'Enter') { this.blur(); }"
/>
</div>
</div>
</div>
</div>
<div class="setting-item"> <div class="setting-item">
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
@@ -15,10 +15,6 @@
<span>{{ t('update.tabs.messages') }}</span> <span>{{ t('update.tabs.messages') }}</span>
<span class="notification-tab-badge" id="bannerTabBadge"></span> <span class="notification-tab-badge" id="bannerTabBadge"></span>
</button> </button>
<button type="button" class="notification-tab" id="priceAlertsTab" role="tab" aria-selected="false" aria-controls="priceAlertsPanel" tabindex="-1" data-notification-tab="priceAlerts">
<span>{{ t('update.tabs.priceAlerts') }}</span>
<span class="notification-tab-badge" id="priceAlertsTabBadge"></span>
</button>
</div> </div>
<div class="notification-panels"> <div class="notification-panels">
<div class="notification-panel active" id="updatesPanel" role="tabpanel" aria-labelledby="updatesTab" aria-hidden="false" tabindex="0" data-notification-panel="updates"> <div class="notification-panel active" id="updatesPanel" role="tabpanel" aria-labelledby="updatesTab" aria-hidden="false" tabindex="0" data-notification-panel="updates">
@@ -98,34 +94,6 @@
<ul class="banner-history-list" id="bannerHistoryList"></ul> <ul class="banner-history-list" id="bannerHistoryList"></ul>
</div> </div>
</div> </div>
<div class="notification-panel" id="priceAlertsPanel" role="tabpanel" aria-labelledby="priceAlertsTab" aria-hidden="true" tabindex="-1" data-notification-panel="priceAlerts">
<div class="price-alerts">
<!-- Shown while price tracking is off: the panel still lists
versions that became free, but says why prices are missing. -->
<div class="price-alerts-disabled hidden" id="priceAlertsDisabled">
<p class="price-alerts-disabled-text">{{ t('update.priceAlerts.disabled') }}</p>
<button type="button" class="price-alerts-settings-btn" id="priceAlertsSettingsBtn">
<i class="fas fa-sliders-h"></i> {{ t('update.priceAlerts.openSettings') }}
</button>
</div>
<div class="price-alerts-toolbar">
<div class="price-alerts-segments" role="tablist" aria-label="{{ t('update.priceAlerts.segmentsLabel') }}">
<button type="button" class="price-alerts-segment active" data-price-alert-segment="below_threshold" role="tab" aria-selected="true">
{{ t('update.priceAlerts.segments.belowThreshold') }}
</button>
<button type="button" class="price-alerts-segment" data-price-alert-segment="became_free" role="tab" aria-selected="false">
{{ t('update.priceAlerts.segments.becameFree') }}
</button>
</div>
<span class="price-alerts-threshold" id="priceAlertsThreshold"></span>
</div>
<p class="price-alerts-note hidden" id="priceAlertsStale"></p>
<p class="price-alerts-empty" id="priceAlertsEmpty">{{ t('update.priceAlerts.empty') }}</p>
<ul class="price-alerts-list" id="priceAlertsList"></ul>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -284,7 +284,7 @@ describe('ModelVersionsTab download button visibility', () => {
}); });
}); });
describe('ModelVersionsTab price badges', () => { describe('ModelVersionsTab obtainability badges', () => {
let getModelApiClient; let getModelApiClient;
let fetchModelUpdateVersions; let fetchModelUpdateVersions;
@@ -316,63 +316,83 @@ describe('ModelVersionsTab price badges', () => {
return document.querySelector(`.model-version-row[data-version-id="${versionId}"]`); 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([ fetchModelUpdateVersions.mockResolvedValue(buildRecord([
{ {
versionId: 20, versionId: 1,
name: 'Alpha', name: 'v1',
isInLibrary: false, isInLibrary: false,
shouldIgnore: false, shouldIgnore: false,
...version,
},
]));
return renderVersions();
}
const PAID_GATE = { permanent: true, endsAt: null };
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('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 });
expect(rowFor(1).textContent).toContain('Paid');
expect(rowFor(1).textContent).not.toContain('Buzz');
});
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,
});
const text = rowFor(1).textContent;
expect(text).toContain('Early Access');
expect(text).toContain('500 Buzz');
});
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, isPaid: true,
paidAccess: { permanent: true, endsAt: null }, paidAccess: PAID_GATE,
priceBuzz: 5000, priceBuzz: 5000,
listPriceBuzz: 5000, gateLapsedAt: '2026-09-28T00:00:00.000Z',
priceCheckedAt: 1791039694.5,
priceAttemptedAt: 1791039694.5,
},
]));
await renderVersions();
expect(rowFor(20).textContent).toContain('5,000 Buzz');
}); });
it('marks a gated version with no readable price as unavailable', async () => { const text = rowFor(1).textContent;
// The mature-model case: we looked, no host would serve the page. expect(text).not.toContain('Buzz');
fetchModelUpdateVersions.mockResolvedValue(buildRecord([ expect(text).not.toContain('Paid');
{ expect(text).not.toContain('Free Now');
versionId: 21, expect(text).toContain('In Library');
name: 'Beta', });
it('marks a version that became free and is not in the library', async () => {
await renderOne({
isInLibrary: false, isInLibrary: false,
shouldIgnore: false, isPaid: false,
isPaid: true,
paidAccess: { permanent: true, endsAt: null },
priceBuzz: null, priceBuzz: null,
priceAttemptedAt: 1791039694.5, gateLapsedAt: '2026-09-28T00:00:00.000Z',
},
]));
await renderVersions();
expect(rowFor(21).textContent).toContain('Price unavailable');
}); });
it('stays quiet when the price was never looked up', async () => { expect(rowFor(1).textContent).toContain('Free Now');
fetchModelUpdateVersions.mockResolvedValue(buildRecord([ });
{
versionId: 22,
name: 'Gamma',
isInLibrary: false,
shouldIgnore: false,
isPaid: true,
paidAccess: { permanent: true, endsAt: null },
priceBuzz: null,
priceAttemptedAt: null,
},
]));
await renderVersions(); it('shows nothing at all for a plain free version', async () => {
await renderOne({ isInLibrary: false, isPaid: false, priceBuzz: null });
expect(rowFor(22).textContent).not.toContain('Price unavailable'); const text = rowFor(1).textContent;
expect(text).not.toContain('Buzz');
expect(text).not.toContain('Paid');
expect(text).not.toContain('Free Now');
}); });
}); });
@@ -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 = `
<div id="updateModal">
<button data-notification-tab="updates"></button>
<button data-notification-tab="priceAlerts"></button>
<div data-notification-panel="updates"></div>
<div data-notification-panel="priceAlerts">
<div id="priceAlertsDisabled" class="hidden"></div>
<button id="priceAlertsSettingsBtn"></button>
<button data-price-alert-segment="below_threshold"></button>
<button data-price-alert-segment="became_free"></button>
<span id="priceAlertsThreshold"></span>
<p id="priceAlertsStale" class="hidden"></p>
<p id="priceAlertsEmpty"></p>
<ul id="priceAlertsList"></ul>
</div>
<span id="updatesTabBadge"></span>
<span id="bannerTabBadge"></span>
<span id="priceAlertsTabBadge"></span>
</div>
`;
}
function alertPayload(alerts, overrides = {}) {
return {
success: true,
enabled: true,
thresholdBuzz: 500,
newestCheckedAt: 1791039694.5,
alerts,
...overrides,
};
}
const BELOW_THRESHOLD_ALERT = {
modelId: 2981320,
modelType: 'checkpoint',
modelName: 'Glorious Art',
versionId: 3379626,
versionName: 'Alpha',
kind: 'below_threshold',
priceBuzz: 250,
listPriceBuzz: 500,
acceptsBlueBuzz: true,
priceAlertSince: 1791000000,
isInLibrary: false,
filePath: null,
civitaiUrl: 'https://civitai.com/models/2981320?modelVersionId=3379626',
};
const BECAME_FREE_ALERT = {
modelId: 1802980,
modelType: 'lora',
modelName: 'Eira Kishida',
versionId: 3262917,
versionName: 'v3',
kind: 'became_free',
priceBuzz: null,
priceAlertSince: null,
gateLapsedAt: '2026-09-28T00:00:00.000Z',
isInLibrary: true,
filePath: '/models/loras/eira.safetensors',
civitaiUrl: 'https://civitai.com/models/1802980?modelVersionId=3262917',
};
function createFetchResponse(payload, ok = true) {
return { json: vi.fn().mockResolvedValue(payload), ok, statusText: '' };
}
describe('UpdateService price alerts panel', () => {
let service;
beforeEach(() => {
buildPanelDom();
localStorage.clear();
state.global = state.global || {};
state.global.settings = state.global.settings || {};
service = new UpdateService();
service.setupNotificationCenter();
});
afterEach(() => {
delete global.fetch;
document.body.innerHTML = '';
localStorage.clear();
});
it('loads the global endpoint and renders a below-threshold row', async () => {
global.fetch = vi
.fn()
.mockResolvedValue(createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT])));
await service.loadPriceAlerts({ force: true });
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(String(global.fetch.mock.calls[0][0])).toContain('/api/lm/price-alerts');
const items = document.querySelectorAll('#priceAlertsList .price-alert-item');
expect(items).toHaveLength(1);
expect(items[0].textContent).toContain('Glorious Art');
expect(items[0].textContent).toContain('250 Buzz');
// The stored list price is shown struck through.
expect(items[0].querySelector('.price-alert-was').textContent).toBe('500 Buzz');
expect(items[0].textContent).toContain('Blue Buzz OK');
expect(items[0].textContent).toContain('Not in library');
// No local file -> no "Open" action, only CivitAI.
expect(items[0].querySelector('[data-price-alert-action="open"]')).toBeNull();
expect(
items[0].querySelector('[data-price-alert-action="civitai"]')
).not.toBeNull();
expect(document.getElementById('priceAlertsDisabled').classList.contains('hidden')).toBe(true);
expect(document.getElementById('priceAlertsEmpty').classList.contains('hidden')).toBe(true);
});
it('separates became-free alerts into their own segment', async () => {
global.fetch = vi
.fn()
.mockResolvedValue(
createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT, BECAME_FREE_ALERT]))
);
await service.loadPriceAlerts({ force: true });
// Below-threshold is the default segment.
expect(document.querySelectorAll('#priceAlertsList .price-alert-item')).toHaveLength(1);
service.priceAlertSegment = 'became_free';
service.renderPriceAlerts();
const items = document.querySelectorAll('#priceAlertsList .price-alert-item');
expect(items).toHaveLength(1);
expect(items[0].textContent).toContain('Free now');
expect(items[0].textContent).toContain('In library');
// In-library row offers both actions.
expect(items[0].querySelector('[data-price-alert-action="open"]')).not.toBeNull();
expect(items[0].querySelector('[data-price-alert-action="civitai"]')).not.toBeNull();
});
it('counts unread alerts and clears the count once viewed', async () => {
global.fetch = vi
.fn()
.mockResolvedValue(
createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT, BECAME_FREE_ALERT]))
);
await service.loadPriceAlerts({ force: true });
expect(service.getUnreadPriceAlertCount()).toBe(2);
const badge = document.getElementById('priceAlertsTabBadge');
expect(badge.classList.contains('visible')).toBe(true);
expect(badge.textContent).toBe('2');
service.markPriceAlertsViewed();
expect(service.getUnreadPriceAlertCount()).toBe(0);
expect(badge.classList.contains('visible')).toBe(false);
// A later alert is unread again.
service.priceAlerts = [
{ ...BELOW_THRESHOLD_ALERT, priceAlertSince: 1791000000 + 1000 },
];
expect(service.refreshUnreadPriceAlertCount()).toBe(1);
});
it('explains itself while price tracking is off', async () => {
global.fetch = vi
.fn()
.mockResolvedValue(
createFetchResponse(
alertPayload([BECAME_FREE_ALERT], { enabled: false, thresholdBuzz: 0 })
)
);
await service.loadPriceAlerts({ force: true });
expect(
document.getElementById('priceAlertsDisabled').classList.contains('hidden')
).toBe(false);
expect(document.getElementById('priceAlertsThreshold').textContent).toBe('');
});
it('shows the empty state when the segment has no matches', async () => {
global.fetch = vi
.fn()
.mockResolvedValue(createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT])));
await service.loadPriceAlerts({ force: true });
service.priceAlertSegment = 'became_free';
service.renderPriceAlerts();
const empty = document.getElementById('priceAlertsEmpty');
expect(empty.classList.contains('hidden')).toBe(false);
expect(document.querySelectorAll('#priceAlertsList .price-alert-item')).toHaveLength(0);
});
it('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();
});
});
+7 -172
View File
@@ -17,7 +17,6 @@ from py.routes.handlers.misc_handlers import (
BackupHandler, BackupHandler,
DoctorHandler, DoctorHandler,
MiscHandlerSet, MiscHandlerSet,
PriceAlertsHandler,
FileSystemHandler, FileSystemHandler,
HealthCheckHandler, HealthCheckHandler,
LoraCodeHandler, LoraCodeHandler,
@@ -2896,7 +2895,6 @@ def _stub_misc_handler_set(**overrides) -> MiscHandlerSet:
"doctor", "doctor",
"example_workflows", "example_workflows",
"base_model", "base_model",
"price_alerts",
"model_source_handler", "model_source_handler",
"agent_handler", "agent_handler",
"download_routing", "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(): def test_price_alert_endpoints_are_gone():
definitions = [ """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 definition
for definition in MISC_ROUTE_DEFINITIONS 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 leftovers == []
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]
+8 -42
View File
@@ -326,39 +326,6 @@ async def test_refresh_model_updates_filters_records_without_updates():
assert call["target_model_ids"] is None 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 @pytest.mark.asyncio
@@ -1216,13 +1183,12 @@ def test_every_common_route_definition_resolves_to_a_handler():
assert missing == [] assert missing == []
def test_get_price_alerts_is_registered_as_a_route(): def test_price_alert_endpoints_are_not_registered():
"""The alerts endpoint must be declared and reachable via GET.""" """The redesign dropped the standalone alerts surface: obtainability rides on
the update surfaces, so no per-type alert route may remain."""
definition = next( assert [
d definition.path_template
for d in COMMON_ROUTE_DEFINITIONS for definition in COMMON_ROUTE_DEFINITIONS
if d.handler_name == "get_price_alerts" if "price-alert" in definition.path_template
) ] == []
assert definition.method == "GET"
assert definition.build_path("loras") == "/api/lm/loras/updates/price-alerts"
+88 -238
View File
@@ -1153,9 +1153,9 @@ def test_build_record_emits_became_free_event(tmp_path):
assert version.gate_lapsed_at is not None assert version.gate_lapsed_at is not None
assert version.price_buzz is None assert version.price_buzz is None
assert version.price_checked_at 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 [event["kind"] for event in record.events] == ["became_free"]
assert record.events[0]["versionId"] == 7 assert record.events[0]["versionId"] == 7
assert record.events[0]["isInLibrary"] is False
def test_build_record_emits_new_gate_event(tmp_path): 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", "accepts_blue_buzz",
"price_sale_ends_at", "price_sale_ends_at",
"price_checked_at", "price_checked_at",
"price_alert_state", "price_check_attempted_at",
): ):
assert column in columns 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) record = service._get_record("lora", 999)
assert record is not None assert record is not None
@@ -1343,7 +1346,6 @@ def test_price_fields_round_trip_through_sqlite(tmp_path):
generation_price_buzz=100, generation_price_buzz=100,
accepts_blue_buzz=True, accepts_blue_buzz=True,
price_sale_ends_at="2026-10-01T00:00:00.000Z", price_sale_ends_at="2026-10-01T00:00:00.000Z",
price_alert_state=True,
is_in_library=True, is_in_library=True,
gate_lapsed_at=None, 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.accepts_blue_buzz is True
assert loaded.price_sale_ends_at == "2026-10-01T00:00:00.000Z" assert loaded.price_sale_ends_at == "2026-10-01T00:00:00.000Z"
assert loaded.price_checked_at == 123.5 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 -------------------------------------------------- # --- Optional price capture --------------------------------------------------
@@ -1625,107 +1627,14 @@ def _prices(price_buzz: int) -> dict:
return {12: {"price_buzz": price_buzz, "list_price_buzz": price_buzz}} 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) --------- # --- 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 @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_buzz is None
assert version.price_checked_at is None assert version.price_checked_at is None
assert version.price_check_attempted_at is not 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 @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_buzz == 250
assert version.price_checked_at is not None assert version.price_checked_at is not None
assert version.price_check_attempted_at is not None assert version.price_check_attempted_at is not None
assert service.count_unavailable_prices("lora") == 0
@pytest.mark.asyncio @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) record = await service.get_record("lora", 1)
assert record.versions[0].price_check_attempted_at is None assert record.versions[0].price_check_attempted_at is None
assert service.count_unavailable_prices("lora") == 0
@pytest.mark.asyncio @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( await service.refresh_for_model_type(
"lora", scanner, PriceProvider(GATED_RESPONSE, prices=None) "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)) await service.refresh_for_model_type("lora", scanner, DummyProvider(FREE_RESPONSE))
record = await service.get_record("lora", 1) record = await service.get_record("lora", 1)
assert record.versions[0].price_check_attempted_at is None assert record.versions[0].price_check_attempted_at is None
assert service.count_unavailable_prices("lora") == 0
def _long_ttl_service(tmp_path, **settings): 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) await service.refresh_for_model_type("lora", scanner, provider)
assert provider.price_calls == 1 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 @pytest.mark.asyncio
@@ -2051,3 +1819,85 @@ async def test_forced_refresh_reprices_within_the_ttl(tmp_path):
) )
assert provider.price_calls == 2 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