feat(update): add a Buzz price alerts panel to the notification bell

P5a of docs/plans/paid-model-price-tracking.md: one surface that answers "what
got cheaper / became free", without a permanent button (the grid filter was
dropped by owner decision, so the panel carries the actions itself).

- price_alert_since records when an alert started, so the panel can say
  "dropped 3 d ago" and count what is new since the user last looked; it is set
  on the first sight of an already-cheap version, preserved while the alert
  stands, and cleared when the price rises back above the threshold
- get_price_alerts() compares the threshold at read time (editing it takes
  effect immediately, no refresh needed) and returns both kinds in one list;
  model_type=None covers every type, which the shared update DB makes a single
  query. "became free" needs no price data, so it is reported even while price
  tracking is off
- GET /api/lm/price-alerts, registered once in MiscRoutes rather than per model
  type, decorating rows best-effort with the local model name and file path from
  the scanner indexes (a cold cache just omits them)
- a third tab in the notification bell: segments for under-threshold and
  became-free, the three states (tracking off / nothing matching / stale), and
  per-row actions (CivitAI always, Open when the model is local)
- two non-permanent entry points share one helper: the controls-bar updates
  dropdown and the global context menu, whose label carries the unread count
- unread state stays client-side (localStorage watermark); the count is fetched
  once on init and only when price tracking is enabled
- the per-type frontend client method is removed as dead code; the per-type
  backend route stays for the companion extension
This commit is contained in:
Will Miao
2026-10-04 15:08:48 +08:00
parent ad2402724b
commit 7ed19c185c
27 changed files with 2142 additions and 86 deletions
+137 -36
View File
@@ -124,6 +124,10 @@ class ModelVersionRecord:
# Last computed "price alert threshold hit" state, used to detect the edge
# (0 -> 1) that fires a one-shot price_drop event.
price_alert_state: bool = False
# When the alert state last flipped to True (POSIX seconds); cleared when the
# price rises back above the threshold. Gives the panel "dropped X ago" and
# the unread count something to compare against.
price_alert_since: Optional[float] = None
@dataclass
@@ -374,6 +378,7 @@ class ModelUpdateService:
price_sale_ends_at TEXT,
price_checked_at REAL,
price_alert_state INTEGER NOT NULL DEFAULT 0,
price_alert_since REAL,
PRIMARY KEY (model_id, version_id),
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
);
@@ -657,6 +662,10 @@ class ModelUpdateService:
"ALTER TABLE model_update_versions "
"ADD COLUMN price_alert_state INTEGER NOT NULL DEFAULT 0"
),
"price_alert_since": (
"ALTER TABLE model_update_versions "
"ADD COLUMN price_alert_since REAL"
),
}
for column, statement in migrations.items():
@@ -769,6 +778,7 @@ class ModelUpdateService:
price_sale_ends_at TEXT,
price_checked_at REAL,
price_alert_state INTEGER NOT NULL DEFAULT 0,
price_alert_since REAL,
PRIMARY KEY (model_id, version_id),
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
)
@@ -799,6 +809,7 @@ class ModelUpdateService:
"price_sale_ends_at",
"price_checked_at",
"price_alert_state",
"price_alert_since",
]
defaults = {
"sort_index": "0",
@@ -822,6 +833,7 @@ class ModelUpdateService:
"price_sale_ends_at": "NULL",
"price_checked_at": "NULL",
"price_alert_state": "0",
"price_alert_since": "NULL",
}
select_parts = []
@@ -1203,13 +1215,28 @@ class ModelUpdateService:
return self._get_records_bulk(model_type, normalized_ids)
async def get_price_alerts(
self, model_type: str, limit: int = 200
self,
model_type: Optional[str] = None,
*,
threshold_buzz: Optional[int] = None,
limit: int = 200,
) -> List[Dict[str, Any]]:
"""Return the gated versions whose price currently crosses the threshold.
"""Return the versions worth alerting on, for one type or for all of them.
Reads the persisted ``price_alert_state`` rather than recomputing, so the
list matches the badges the user sees. Cheapest versions first, since that
is what a price watch is for.
The threshold is compared **at read time** rather than reading the persisted
``price_alert_state``: otherwise editing the threshold in the panel would
not take effect until the next refresh. ``price_alert_state`` stays the
source of the one-shot toast edge.
Two kinds are returned in one list:
* ``below_threshold`` — gated versions whose effective price is at or below
the threshold (only possible when price capture has run);
* ``became_free`` — versions whose gate lapsed, which needs no price data at
all, so they are reported even while price tracking is switched off.
``model_type=None`` covers every type, which is what the global alerts panel
wants; the update DB is shared across types, so that is still one query.
"""
try:
@@ -1217,9 +1244,25 @@ class ModelUpdateService:
except (TypeError, ValueError):
normalized_limit = 200
if threshold_buzz is None:
threshold = self._price_alert_threshold_buzz()
else:
try:
threshold = max(0, int(threshold_buzz))
except (TypeError, ValueError):
threshold = self._price_alert_threshold_buzz()
params: List[Any] = []
type_filter = ""
if model_type:
type_filter = "AND s.model_type = ?"
params.append(model_type)
params.append(threshold)
params.append(normalized_limit)
with self._connect() as conn:
rows = conn.execute(
"""
f"""
SELECT v.model_id,
v.version_id,
v.name,
@@ -1228,6 +1271,8 @@ class ModelUpdateService:
v.accepts_blue_buzz,
v.price_sale_ends_at,
v.price_checked_at,
v.price_alert_since,
v.gate_lapsed_at,
v.is_in_library,
v.early_access_ends_at,
v.paid_access,
@@ -1236,38 +1281,74 @@ class ModelUpdateService:
s.model_type
FROM model_update_versions v
JOIN model_update_status s ON s.model_id = v.model_id
WHERE v.price_alert_state = 1
AND v.should_ignore = 0
WHERE v.should_ignore = 0
AND s.should_ignore_model = 0
AND s.model_type = ?
ORDER BY v.price_buzz ASC, v.model_id ASC, v.version_id ASC
{type_filter}
AND (
(v.price_buzz IS NOT NULL AND v.price_buzz <= ?)
OR (v.gate_lapsed_at IS NOT NULL
AND v.paid_access IS NULL
AND v.is_paid = 0
AND v.is_early_access = 0)
)
ORDER BY (v.price_buzz IS NULL) ASC,
v.price_buzz ASC,
v.gate_lapsed_at DESC,
v.model_id ASC,
v.version_id ASC
LIMIT ?
""",
(model_type, normalized_limit),
tuple(params),
).fetchall()
return [
{
"modelId": int(row["model_id"]),
"modelType": row["model_type"],
"versionId": int(row["version_id"]),
"versionName": row["name"],
"priceBuzz": _normalize_int(row["price_buzz"]),
"listPriceBuzz": _normalize_int(row["list_price_buzz"]),
"acceptsBlueBuzz": bool(row["accepts_blue_buzz"]),
"priceSaleEndsAt": row["price_sale_ends_at"],
"priceCheckedAt": (
float(row["price_checked_at"])
if row["price_checked_at"] is not None
else None
),
"isInLibrary": bool(row["is_in_library"]),
"earlyAccessEndsAt": row["early_access_ends_at"],
"isPaid": bool(row["is_paid"]),
"isEarlyAccess": bool(row["is_early_access"]),
}
for row in rows
]
alerts: List[Dict[str, Any]] = []
for row in rows:
price_buzz = _normalize_int(row["price_buzz"])
below_threshold = price_buzz is not None and price_buzz <= threshold
alerts.append(
{
"modelId": int(row["model_id"]),
"modelType": row["model_type"],
"versionId": int(row["version_id"]),
"versionName": row["name"],
"kind": "below_threshold" if below_threshold else "became_free",
"priceBuzz": price_buzz if below_threshold else None,
"listPriceBuzz": (
_normalize_int(row["list_price_buzz"])
if below_threshold
else None
),
"acceptsBlueBuzz": bool(row["accepts_blue_buzz"]),
"priceSaleEndsAt": row["price_sale_ends_at"],
"priceCheckedAt": (
float(row["price_checked_at"])
if row["price_checked_at"] is not None
else None
),
"priceAlertSince": (
float(row["price_alert_since"])
if row["price_alert_since"] is not None
else None
),
"gateLapsedAt": row["gate_lapsed_at"],
"isInLibrary": bool(row["is_in_library"]),
"earlyAccessEndsAt": row["early_access_ends_at"],
"isPaid": bool(row["is_paid"]),
"isEarlyAccess": bool(row["is_early_access"]),
}
)
return alerts
def newest_price_checked_at(self) -> Optional[float]:
"""Newest successful price fetch across the tracked versions."""
with self._connect() as conn:
row = conn.execute(
"SELECT MAX(price_checked_at) AS newest FROM model_update_versions"
).fetchone()
if row is None or row["newest"] is None:
return None
return float(row["newest"])
async def _refresh_single_model(
self,
@@ -1852,6 +1933,16 @@ class ModelUpdateService:
price_fields["price_alert_state"] = self._evaluate_price_alert(
price_fields.get("price_buzz")
)
# `price_alert_since` records when the alert last started, so the panel
# can say "dropped 3 days ago" and count what is new since the user
# last looked. A still-standing alert keeps its original timestamp; a
# price back above the threshold clears it.
if not price_fields["price_alert_state"]:
price_fields["price_alert_since"] = None
elif existing_version is not None and existing_version.price_alert_state:
price_fields["price_alert_since"] = existing_version.price_alert_since
else:
price_fields["price_alert_since"] = timestamp
if (
existing_version is not None
@@ -1987,6 +2078,7 @@ class ModelUpdateService:
"price_sale_ends_at": None,
"price_checked_at": None,
"price_alert_state": False,
"price_alert_since": None,
}
if remote_version.price_checked_at is not None:
@@ -2002,6 +2094,7 @@ class ModelUpdateService:
"price_sale_ends_at": source.price_sale_ends_at,
"price_checked_at": source.price_checked_at,
"price_alert_state": source.price_alert_state,
"price_alert_since": source.price_alert_since,
}
def _price_tracking_enabled(self) -> bool:
@@ -2374,7 +2467,8 @@ class ModelUpdateService:
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
is_early_access, usage_control, paid_access, is_paid, file_count,
gate_lapsed_at, price_buzz, list_price_buzz, generation_price_buzz,
accepts_blue_buzz, price_sale_ends_at, price_checked_at, price_alert_state
accepts_blue_buzz, price_sale_ends_at, price_checked_at, price_alert_state,
price_alert_since
FROM model_update_versions
WHERE model_id IN ({placeholders})
ORDER BY model_id ASC, sort_index ASC, version_id ASC
@@ -2418,6 +2512,11 @@ class ModelUpdateService:
else None
),
price_alert_state=bool(row["price_alert_state"]),
price_alert_since=(
float(row["price_alert_since"])
if row["price_alert_since"] is not None
else None
),
)
)
@@ -2482,8 +2581,9 @@ class ModelUpdateService:
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
is_early_access, usage_control, paid_access, is_paid, file_count,
gate_lapsed_at, price_buzz, list_price_buzz, generation_price_buzz,
accepts_blue_buzz, price_sale_ends_at, price_checked_at, price_alert_state
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
accepts_blue_buzz, price_sale_ends_at, price_checked_at, price_alert_state,
price_alert_since
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
version.version_id,
@@ -2510,6 +2610,7 @@ class ModelUpdateService:
version.price_sale_ends_at,
version.price_checked_at,
1 if version.price_alert_state else 0,
version.price_alert_since,
),
)
conn.commit()