mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-05 09:35:31 -03:00
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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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')) {
|
||||||
|
|||||||
@@ -569,65 +569,58 @@ function renderRow(version, options) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEarlyAccess) {
|
// Obtainability, only where there is a decision to make: a version already in
|
||||||
|
// the library cannot become cheaper *for this user*, so it gets no cost badge.
|
||||||
|
// The download price is the only price axis that matters here — the generation
|
||||||
|
// fee is what CivitAI charges to generate on its own site, which a local model
|
||||||
|
// never incurs.
|
||||||
|
const priceLabel = formatBuzzPrice(version.priceBuzz);
|
||||||
|
if (!version.isInLibrary && isEarlyAccess) {
|
||||||
|
// Early access is the one state with a real countdown: "free on <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
|
||||||
'modals.model.versions.badges.earlyAccessTooltip',
|
? translate(
|
||||||
{},
|
'modals.model.versions.badges.earlyAccessTooltipUntil',
|
||||||
'This version currently requires Civitai early access'
|
{ date: eaTime },
|
||||||
)
|
`Early access until ${eaTime}`
|
||||||
|
)
|
||||||
|
: translate(
|
||||||
|
'modals.model.versions.badges.earlyAccessTooltip',
|
||||||
|
{},
|
||||||
|
'This version currently requires Civitai early access'
|
||||||
|
)
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPaidPermanent(version)) {
|
if (!version.isInLibrary && isGated(version)) {
|
||||||
badges.push(buildBadge(paidBadgeLabel, 'paid', {
|
if (priceLabel) {
|
||||||
title: buildPriceTooltip(
|
// The number already says "paid", so a separate Paid badge would be
|
||||||
version,
|
// noise next to it.
|
||||||
translate(
|
badges.push(buildBadge(priceLabel, isEarlyAccess ? 'early-access' : 'paid', {
|
||||||
|
title: buildPriceTooltip(version, paidBadgeLabel),
|
||||||
|
}));
|
||||||
|
} else if (!isEarlyAccess) {
|
||||||
|
// Paid, but no readable price: the gate is certain (it comes from the
|
||||||
|
// public API), only the number is best-effort. Saying nothing here
|
||||||
|
// would read as "free", which is the one thing this version is not.
|
||||||
|
badges.push(buildBadge(paidBadgeLabel, 'paid', {
|
||||||
|
title: translate(
|
||||||
'modals.model.versions.badges.paidTooltip',
|
'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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
isPaid: true,
|
...version,
|
||||||
paidAccess: { permanent: true, endsAt: null },
|
|
||||||
priceBuzz: 5000,
|
|
||||||
listPriceBuzz: 5000,
|
|
||||||
priceCheckedAt: 1791039694.5,
|
|
||||||
priceAttemptedAt: 1791039694.5,
|
|
||||||
},
|
},
|
||||||
]));
|
]));
|
||||||
|
return renderVersions();
|
||||||
|
}
|
||||||
|
|
||||||
await renderVersions();
|
const PAID_GATE = { permanent: true, endsAt: null };
|
||||||
|
|
||||||
expect(rowFor(20).textContent).toContain('5,000 Buzz');
|
it('shows the download price for a paid version the user does not have', async () => {
|
||||||
|
await renderOne({ isPaid: true, paidAccess: PAID_GATE, priceBuzz: 5000 });
|
||||||
|
|
||||||
|
expect(rowFor(1).textContent).toContain('5,000 Buzz');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks a gated version with no readable price as unavailable', async () => {
|
it('says Paid without a number when the price could not be read', async () => {
|
||||||
// The mature-model case: we looked, no host would serve the page.
|
// The gate is certain (it comes from the public API); only the number is
|
||||||
fetchModelUpdateVersions.mockResolvedValue(buildRecord([
|
// best-effort, and that is our plumbing rather than the user's problem.
|
||||||
{
|
await renderOne({ isPaid: true, paidAccess: PAID_GATE, priceBuzz: null });
|
||||||
versionId: 21,
|
|
||||||
name: 'Beta',
|
|
||||||
isInLibrary: false,
|
|
||||||
shouldIgnore: false,
|
|
||||||
isPaid: true,
|
|
||||||
paidAccess: { permanent: true, endsAt: null },
|
|
||||||
priceBuzz: null,
|
|
||||||
priceAttemptedAt: 1791039694.5,
|
|
||||||
},
|
|
||||||
]));
|
|
||||||
|
|
||||||
await renderVersions();
|
expect(rowFor(1).textContent).toContain('Paid');
|
||||||
|
expect(rowFor(1).textContent).not.toContain('Buzz');
|
||||||
expect(rowFor(21).textContent).toContain('Price unavailable');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stays quiet when the price was never looked up', async () => {
|
it('shows the early access countdown alongside the price', async () => {
|
||||||
fetchModelUpdateVersions.mockResolvedValue(buildRecord([
|
await renderOne({
|
||||||
{
|
isEarlyAccess: true,
|
||||||
versionId: 22,
|
earlyAccessEndsAt: new Date(Date.now() + 3 * 86400000).toISOString(),
|
||||||
name: 'Gamma',
|
paidAccess: { permanent: false, endsAt: '2999-01-01T00:00:00.000Z' },
|
||||||
isInLibrary: false,
|
priceBuzz: 500,
|
||||||
shouldIgnore: false,
|
});
|
||||||
isPaid: true,
|
|
||||||
paidAccess: { permanent: true, endsAt: null },
|
|
||||||
priceBuzz: null,
|
|
||||||
priceAttemptedAt: null,
|
|
||||||
},
|
|
||||||
]));
|
|
||||||
|
|
||||||
await renderVersions();
|
const text = rowFor(1).textContent;
|
||||||
|
expect(text).toContain('Early Access');
|
||||||
|
expect(text).toContain('500 Buzz');
|
||||||
|
});
|
||||||
|
|
||||||
expect(rowFor(22).textContent).not.toContain('Price unavailable');
|
it('shows nothing about cost for a version already in the library', async () => {
|
||||||
|
// A version on disk cannot become cheaper *for this user*.
|
||||||
|
await renderOne({
|
||||||
|
isInLibrary: true,
|
||||||
|
isPaid: true,
|
||||||
|
paidAccess: PAID_GATE,
|
||||||
|
priceBuzz: 5000,
|
||||||
|
gateLapsedAt: '2026-09-28T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = rowFor(1).textContent;
|
||||||
|
expect(text).not.toContain('Buzz');
|
||||||
|
expect(text).not.toContain('Paid');
|
||||||
|
expect(text).not.toContain('Free Now');
|
||||||
|
expect(text).toContain('In Library');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a version that became free and is not in the library', async () => {
|
||||||
|
await renderOne({
|
||||||
|
isInLibrary: false,
|
||||||
|
isPaid: false,
|
||||||
|
priceBuzz: null,
|
||||||
|
gateLapsedAt: '2026-09-28T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(rowFor(1).textContent).toContain('Free Now');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows nothing at all for a plain free version', async () => {
|
||||||
|
await renderOne({ isInLibrary: false, isPaid: false, priceBuzz: null });
|
||||||
|
|
||||||
|
const text = rowFor(1).textContent;
|
||||||
|
expect(text).not.toContain('Buzz');
|
||||||
|
expect(text).not.toContain('Paid');
|
||||||
|
expect(text).not.toContain('Free Now');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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]
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user