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

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

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

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

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

Verified against a copy of the owner's real database: 52 gated versions ->
28 owned (now silent) + 24 the feature is actually about; the drop migration ran
and both removed endpoints 404.
This commit is contained in:
Will Miao
2026-10-05 08:54:08 +08:00
parent 156d2e5eb9
commit e42d649df0
35 changed files with 403 additions and 2494 deletions
-160
View File
@@ -2242,11 +2242,6 @@ class ServiceRegistryAdapter:
get_downloaded_version_history_service: Callable[[], Awaitable[Any]]
get_backup_service: Callable[[], Awaitable[Any]] = _noop_backup_service
get_other_scanner: Callable[[], Awaitable[Any]] = ServiceRegistry.get_other_scanner
# Shared across model types (the update DB is per library), which is what lets
# the global price-alerts endpoint cover every type in one query.
get_model_update_service: Callable[[], Awaitable[Any]] = (
ServiceRegistry.get_model_update_service
)
class ModelLibraryHandler:
@@ -4319,157 +4314,6 @@ class SidecarMigrationHandler:
return web.json_response({"success": False, "error": str(exc)}, status=500)
class PriceAlertsHandler:
"""Data for the global Buzz price-alerts panel.
App-wide on purpose: the update DB is one file per library shared by every
model type, and ``ServiceRegistry.get_model_update_service()`` hands out one
instance, so the whole panel is a single query. Registered once (see
``MISC_ROUTE_DEFINITIONS``) rather than once per model type.
"""
# Model type -> adapter attribute, for the best-effort local name/path lookup.
_SCANNER_ATTRS = {
"lora": "get_lora_scanner",
"checkpoint": "get_checkpoint_scanner",
"embedding": "get_embedding_scanner",
"other": "get_other_scanner",
}
def __init__(
self,
*,
settings_service,
service_registry: ServiceRegistryAdapter,
) -> None:
self._settings = settings_service
self._service_registry = service_registry
def _setting(self, key: str, default: Any) -> Any:
if self._settings is None:
return default
try:
value = self._settings.get(key, default)
except Exception:
return default
return default if value is None else value
async def get_price_alerts(self, request: web.Request) -> web.Response:
"""Versions under the alert threshold, plus the ones that became free.
GET with an optional ``limit`` so the companion extension can call it too.
"""
try:
limit = int(request.query.get("limit") or 200)
except (TypeError, ValueError):
limit = 200
threshold = 0
try:
threshold = max(0, int(self._setting("price_alert_threshold_buzz", 0)))
except (TypeError, ValueError):
threshold = 0
try:
update_service = await self._service_registry.get_model_update_service()
except Exception as exc: # pragma: no cover - defensive
logger.error("Price alerts unavailable: %s", exc, exc_info=True)
return web.json_response(
{"success": False, "error": "Update service unavailable"}, status=503
)
try:
alerts = await update_service.get_price_alerts(
None, threshold_buzz=threshold, limit=limit
)
newest_checked_at = update_service.newest_price_checked_at()
unavailable_count = update_service.count_unavailable_prices()
priced_count = update_service.count_priced_versions()
except Exception as exc:
logger.error("Failed to load price alerts: %s", exc, exc_info=True)
return web.json_response(
{"success": False, "error": str(exc)}, status=500
)
await self._decorate_local_context(alerts)
civitai_host = self._setting("civitai_host", "civitai.com")
for alert in alerts:
alert["civitaiUrl"] = build_civitai_model_page_url(
alert.get("modelId"),
alert.get("versionId"),
host=civitai_host,
)
return web.json_response(
{
"success": True,
"enabled": bool(self._setting("price_tracking_enabled", False)),
"thresholdBuzz": threshold,
"newestCheckedAt": newest_checked_at,
# Gated versions whose price could not be read (mature models
# are served only by the challenged civitai.red host).
"unavailableCount": unavailable_count,
# Lets the empty state explain a 0 Buzz threshold instead of
# claiming there is simply nothing to show.
"pricedCount": priced_count,
"alerts": alerts,
}
)
async def _decorate_local_context(self, alerts: Sequence[Dict[str, Any]]) -> None:
"""Attach ``modelName`` / ``filePath`` / ``fileName`` when the model is local.
Best-effort by design: the panel must render even when a scanner cache is
cold or a type is unavailable, so every failure just leaves the fields as
None and the UI hides the affordances that need them.
"""
by_type: Dict[str, set] = {}
for alert in alerts:
model_type = alert.get("modelType")
model_id = alert.get("modelId")
if not model_type or model_id is None:
continue
by_type.setdefault(model_type, set()).add(model_id)
for model_type, model_ids in by_type.items():
attr = self._SCANNER_ATTRS.get(model_type)
scanner_getter = getattr(self._service_registry, attr, None) if attr else None
if scanner_getter is None:
continue
try:
scanner = await scanner_getter()
cache = await scanner.get_cached_data()
except Exception as exc:
logger.debug(
"Skipping local context for %s price alerts: %s", model_type, exc
)
continue
model_index = getattr(cache, "model_id_index", None) or {}
version_index = getattr(cache, "version_index", None) or {}
for alert in alerts:
if alert.get("modelType") != model_type:
continue
if alert.get("modelId") not in model_ids:
continue
entries = model_index.get(alert.get("modelId")) or []
if entries and isinstance(entries[0], Mapping):
first = entries[0]
alert["modelName"] = first.get("model_name") or first.get(
"file_name"
)
version_entry = version_index.get(alert.get("versionId"))
if isinstance(version_entry, Mapping):
alert["filePath"] = version_entry.get("file_path")
alert["fileName"] = version_entry.get("file_name")
alert.setdefault("modelName", version_entry.get("model_name"))
class MiscHandlerSet:
"""Aggregate handlers into a lookup compatible with the registrar."""
@@ -4493,7 +4337,6 @@ class MiscHandlerSet:
doctor: DoctorHandler,
example_workflows: ExampleWorkflowsHandler,
base_model: BaseModelHandlerSet,
price_alerts: PriceAlertsHandler,
model_source_handler: Any = None,
agent_handler: Any = None,
download_routing: Any = None,
@@ -4516,7 +4359,6 @@ class MiscHandlerSet:
self.doctor = doctor
self.example_workflows = example_workflows
self.base_model = base_model
self.price_alerts = price_alerts
self.model_source_handler = model_source_handler
self.agent_handler = agent_handler
self.download_routing = download_routing
@@ -4530,7 +4372,6 @@ class MiscHandlerSet:
"get_init_status": self.health.get_init_status,
"get_settings": self.settings.get_settings,
"update_settings": self.settings.update_settings,
"get_price_alerts": self.price_alerts.get_price_alerts,
"get_doctor_diagnostics": self.doctor.get_doctor_diagnostics,
"repair_doctor_cache": self.doctor.repair_doctor_cache,
"resolve_doctor_filename_conflicts": self.doctor.resolve_filename_conflicts,
@@ -4607,5 +4448,4 @@ def build_service_registry_adapter() -> ServiceRegistryAdapter:
get_other_scanner=ServiceRegistry.get_other_scanner,
get_downloaded_version_history_service=ServiceRegistry.get_downloaded_version_history_service,
get_backup_service=ServiceRegistry.get_backup_service,
get_model_update_service=ServiceRegistry.get_model_update_service,
)