mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-05 09:35:31 -03:00
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:
@@ -98,6 +98,7 @@ export function getApiEndpoints(modelType) {
|
||||
modelUpdateVersions: `/api/lm/${modelType}/updates/versions`,
|
||||
ignoreModelUpdate: `/api/lm/${modelType}/updates/ignore`,
|
||||
ignoreVersionUpdate: `/api/lm/${modelType}/updates/ignore-version`,
|
||||
priceAlerts: `/api/lm/${modelType}/updates/price-alerts`,
|
||||
|
||||
// Preview management
|
||||
replacePreview: `/api/lm/${modelType}/replace-preview`,
|
||||
|
||||
@@ -1086,6 +1086,33 @@ export class BaseModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Versions whose stored price currently crosses the alert threshold.
|
||||
* Backed by the persisted alert state, so it matches the version badges.
|
||||
* @param {{limit?: number}} [options]
|
||||
* @returns {Promise<{alerts: Array, thresholdBuzz: number, enabled: boolean}>}
|
||||
*/
|
||||
async getPriceAlerts({ limit = 200 } = {}) {
|
||||
const endpoint = this.apiConfig?.endpoints?.priceAlerts;
|
||||
if (!endpoint) {
|
||||
return { alerts: [], thresholdBuzz: 0, enabled: false };
|
||||
}
|
||||
|
||||
const response = await fetch(`${endpoint}?limit=${encodeURIComponent(limit)}`, {
|
||||
method: 'GET'
|
||||
});
|
||||
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 : [],
|
||||
thresholdBuzz: payload.thresholdBuzz ?? 0,
|
||||
enabled: payload.enabled === true
|
||||
};
|
||||
}
|
||||
|
||||
async refreshUpdatesForModels(modelIds, { force = false } = {}) {
|
||||
if (!Array.isArray(modelIds) || modelIds.length === 0) {
|
||||
throw new Error('No model IDs provided');
|
||||
|
||||
@@ -187,6 +187,53 @@ function isPaidPermanent(version) {
|
||||
return version && version.isPaid === true;
|
||||
}
|
||||
|
||||
function isGated(version) {
|
||||
return isEarlyAccessActive(version) || isPaidPermanent(version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Buzz price for display, or '' when the price is unknown (price
|
||||
* tracking is opt-in, so most versions have no price at all).
|
||||
*/
|
||||
function formatBuzzPrice(value) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||
return '';
|
||||
}
|
||||
return `${value.toLocaleString()} Buzz`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip for a known price: notes a sale against the list price and the Blue
|
||||
* Buzz option, since both change what the number means to a buyer.
|
||||
*/
|
||||
function buildPriceTooltip(version, baseTooltip) {
|
||||
const effective = formatBuzzPrice(version?.priceBuzz);
|
||||
const segments = [baseTooltip];
|
||||
if (!effective) {
|
||||
return baseTooltip;
|
||||
}
|
||||
const list = version?.listPriceBuzz;
|
||||
if (typeof list === 'number' && Number.isFinite(list) && list > version.priceBuzz) {
|
||||
segments.push(
|
||||
translate(
|
||||
'modals.model.versions.badges.priceSaleTooltip',
|
||||
{ list: list.toLocaleString() },
|
||||
`On sale, normally ${list.toLocaleString()} Buzz`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (version?.acceptsBlueBuzz) {
|
||||
segments.push(
|
||||
translate(
|
||||
'modals.model.versions.badges.priceBlueBuzzTooltip',
|
||||
{},
|
||||
'Can also be paid with Blue Buzz'
|
||||
)
|
||||
);
|
||||
}
|
||||
return segments.join(' · ');
|
||||
}
|
||||
|
||||
function isDownloadAllowed(version) {
|
||||
if (!version.usageControl) {
|
||||
return true;
|
||||
@@ -524,20 +571,55 @@ function renderRow(version, options) {
|
||||
|
||||
if (isEarlyAccess) {
|
||||
badges.push(buildBadge(earlyAccessBadgeLabel, 'early-access', {
|
||||
title: translate(
|
||||
'modals.model.versions.badges.earlyAccessTooltip',
|
||||
{},
|
||||
'This version currently requires Civitai early access'
|
||||
title: buildPriceTooltip(
|
||||
version,
|
||||
translate(
|
||||
'modals.model.versions.badges.earlyAccessTooltip',
|
||||
{},
|
||||
'This version currently requires Civitai early access'
|
||||
)
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
if (isPaidPermanent(version)) {
|
||||
badges.push(buildBadge(paidBadgeLabel, 'paid', {
|
||||
title: buildPriceTooltip(
|
||||
version,
|
||||
translate(
|
||||
'modals.model.versions.badges.paidTooltip',
|
||||
{},
|
||||
'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),
|
||||
}));
|
||||
}
|
||||
|
||||
// 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.
|
||||
if (!isGated(version) && version.gateLapsedAt) {
|
||||
const freeBadgeLabel = translate('modals.model.versions.badges.freeNow', {}, 'Free Now');
|
||||
badges.push(buildBadge(freeBadgeLabel, 'success', {
|
||||
title: translate(
|
||||
'modals.model.versions.badges.paidTooltip',
|
||||
{},
|
||||
'This version requires payment to download'
|
||||
'modals.model.versions.badges.freeNowTooltip',
|
||||
{ date: formatDateLabel(version.gateLapsedAt) || '' },
|
||||
'This version no longer requires payment'
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1163,6 +1163,22 @@ export class SettingsManager {
|
||||
hidePaidUpdatesCheckbox.checked = state.global.settings.hide_paid_updates || false;
|
||||
}
|
||||
|
||||
// Set buzz price tracking settings
|
||||
const priceTrackingEnabledCheckbox = document.getElementById('priceTrackingEnabled');
|
||||
if (priceTrackingEnabledCheckbox) {
|
||||
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');
|
||||
if (priceCheckTtlInput) {
|
||||
priceCheckTtlInput.value = state.global.settings.price_check_ttl_hours ?? 24;
|
||||
}
|
||||
|
||||
const skipPreviouslyDownloadedModelVersionsCheckbox = document.getElementById('skipPreviouslyDownloadedModelVersions');
|
||||
if (skipPreviouslyDownloadedModelVersionsCheckbox) {
|
||||
skipPreviouslyDownloadedModelVersionsCheckbox.checked =
|
||||
|
||||
@@ -57,6 +57,9 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
version_grouping: 'same_base',
|
||||
hide_early_access_updates: false,
|
||||
hide_paid_updates: false,
|
||||
price_tracking_enabled: false,
|
||||
price_alert_threshold_buzz: 0,
|
||||
price_check_ttl_hours: 24,
|
||||
auto_organize_exclusions: [],
|
||||
metadata_refresh_skip_paths: [],
|
||||
skip_previously_downloaded_model_versions: false,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user