feat(update): track buzz prices and alert below a threshold

CivitAI's public API deliberately omits prices — paidAccess is trimmed to
{permanent, endsAt} because "pricing belongs to the purchase flow" — but the
public model page embeds the site's own model.getById result, including
paidAccess.terms, in its server-rendered payload. That is read anonymously
(no API key, no internal endpoint, no forged Origin), one request per gated
model, so only the ~2% of models that actually carry a gate pay for it.

- optional capture, off by default: price_tracking_enabled,
  price_alert_threshold_buzz (0 = alert on "became free" only) and
  price_check_ttl_hours; prices refresh on their own TTL and immediately when a
  gate changes, and a failed fetch keeps the stored price instead of blanking it
- versions that stop carrying a gate are marked free (persisted gate_lapsed_at)
  and gate transitions are reported as events on the refresh response, so a
  version already in the library can announce that it became free
- price_alert_state plus a price_drop edge event; new
  GET /api/lm/{type}/updates/price-alerts lists what is under the threshold
- versions tab shows the price (effective, with the list price struck through
  and a Blue Buzz note) and a Free Now badge; an update check toasts the
  transitions in one message
- the parser and the alerts query are unit-tested against a trimmed page
  fixture, and every route definition is now asserted to resolve to a handler

Plan, verification notes and the deviations from it are in
docs/plans/paid-model-price-tracking.md.
This commit is contained in:
Will Miao
2026-10-04 08:53:24 +08:00
parent 5f4054265d
commit ad2402724b
31 changed files with 2829 additions and 33 deletions
+62
View File
@@ -8,6 +8,64 @@ import { modalManager } from '../managers/ModalManager.js';
const CHECK_UPDATES_CONFIRMATION_KEY = 'ack_check_updates_for_all_models';
/**
* Count the gate/price transitions reported by an update refresh.
* @param {Array} events - `events` array from the refresh response.
* @returns {{becameFree: number, newGate: number, priceDrop: number, total: number}}
*/
export function summarizeGateEvents(events) {
const list = Array.isArray(events) ? events : [];
return {
becameFree: list.filter((event) => event?.kind === 'became_free').length,
newGate: list.filter((event) => event?.kind === 'new_gate').length,
priceDrop: list.filter((event) => event?.kind === 'price_drop').length,
total: list.length
};
}
/**
* Toast the gate/price transitions of an update refresh, if any. Kept to a single
* toast so a large refresh cannot bury the user in notifications.
* @param {Array} events
*/
export function showGateEventsToast(events) {
const { becameFree, newGate, priceDrop } = summarizeGateEvents(events);
if (!becameFree && !newGate && !priceDrop) {
return;
}
const parts = [];
if (becameFree) {
parts.push(
translate(
'globalContextMenu.checkModelUpdates.gateEvents.becameFree',
{ count: becameFree },
`${becameFree} version(s) became free`
)
);
}
if (priceDrop) {
parts.push(
translate(
'globalContextMenu.checkModelUpdates.gateEvents.priceDrop',
{ count: priceDrop },
`${priceDrop} version(s) dropped below your price threshold`
)
);
}
if (newGate) {
parts.push(
translate(
'globalContextMenu.checkModelUpdates.gateEvents.newGate',
{ count: newGate },
`${newGate} version(s) are now paid`
)
);
}
showToast(parts.join(' · '), {}, becameFree || priceDrop ? 'success' : 'info');
}
/**
* Perform a model update check using the shared backend endpoint.
* @param {Object} [options]
@@ -85,6 +143,8 @@ export async function performModelUpdateCheck({ onStart, onComplete } = {}) {
showToast('globalContextMenu.checkModelUpdates.none', { type: displayName }, 'info');
}
showGateEventsToast(payload.events);
await resetAndReload(false);
} catch (err) {
if (err?.name === 'AbortError') {
@@ -180,6 +240,8 @@ export async function performFolderUpdateCheck(folderPath, { onComplete } = {})
showToast('sidebar.folderUpdateCheck.none', { type: displayName }, 'info');
}
showGateEventsToast(payload.events);
await resetAndReload(false);
} catch (err) {
if (err?.name === 'AbortError') {