mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-05 17:35:33 -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:
@@ -10,9 +10,11 @@ import pytest
|
||||
from py.config import config
|
||||
from py.routes.handlers.model_handlers import (
|
||||
ModelCivitaiHandler,
|
||||
ModelHandlerSet,
|
||||
ModelManagementHandler,
|
||||
ModelUpdateHandler,
|
||||
)
|
||||
from py.routes.model_route_registrar import COMMON_ROUTE_DEFINITIONS
|
||||
from py.services.service_registry import ServiceRegistry
|
||||
from py.utils.metadata_manager import MetadataManager
|
||||
from py.services.model_update_service import ModelUpdateRecord, ModelVersionRecord
|
||||
@@ -324,6 +326,105 @@ async def test_refresh_model_updates_filters_records_without_updates():
|
||||
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
|
||||
async def test_refresh_model_updates_reports_gate_events_for_all_records():
|
||||
"""Gate transitions are reported even for records that do not qualify as
|
||||
updates (a version already in the library that became free)."""
|
||||
|
||||
cache = SimpleNamespace(version_index={})
|
||||
service = DummyService(cache)
|
||||
record = ModelUpdateRecord(
|
||||
model_type="lora",
|
||||
model_id=1,
|
||||
versions=[
|
||||
ModelVersionRecord(
|
||||
version_id=11,
|
||||
name="v11",
|
||||
base_model=None,
|
||||
released_at=None,
|
||||
size_bytes=None,
|
||||
preview_url=None,
|
||||
is_in_library=True,
|
||||
should_ignore=False,
|
||||
)
|
||||
],
|
||||
last_checked_at=None,
|
||||
should_ignore_model=False,
|
||||
events=[{"versionId": 11, "kind": "became_free", "versionName": "v11", "isInLibrary": True}],
|
||||
)
|
||||
update_service = DummyUpdateService({1: record})
|
||||
metadata_selector = AsyncMock(return_value=SimpleNamespace())
|
||||
|
||||
handler = ModelUpdateHandler(
|
||||
service=service,
|
||||
update_service=update_service,
|
||||
metadata_provider_selector=metadata_selector,
|
||||
settings_service=SimpleNamespace(get=lambda *_: False),
|
||||
logger=logging.getLogger(__name__),
|
||||
)
|
||||
|
||||
class DummyRequest:
|
||||
can_read_body = True
|
||||
query = {}
|
||||
|
||||
async def json(self):
|
||||
return {}
|
||||
|
||||
response = await handler.refresh_model_updates(
|
||||
DummyRequest() # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
# The record itself does not qualify as an update...
|
||||
assert payload["records"] == []
|
||||
# ...but its transition is still surfaced.
|
||||
assert payload["events"] == [
|
||||
{
|
||||
"modelId": 1,
|
||||
"modelType": "lora",
|
||||
"versionId": 11,
|
||||
"kind": "became_free",
|
||||
"versionName": "v11",
|
||||
"isInLibrary": True,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_model_updates_same_base_scope_excludes_cross_base_updates():
|
||||
"""Issue #1083: with version_grouping=same_base (the default), a newer
|
||||
@@ -1077,3 +1178,51 @@ async def test_relink_civitai_surfaces_provider_unavailable_without_500():
|
||||
payload = json.loads(response.text)
|
||||
assert payload["success"] is False
|
||||
assert "CivitArchive" in payload["error"]
|
||||
|
||||
|
||||
def test_every_common_route_definition_resolves_to_a_handler():
|
||||
"""Guard the declarative route table against a missing mapping entry.
|
||||
|
||||
Adding a RouteDefinition without registering it in
|
||||
``ModelHandlerSet.to_route_mapping`` fails at request time with a bare
|
||||
KeyError from the handler lookup (and only on a live server), so assert the
|
||||
whole table resolves here instead.
|
||||
"""
|
||||
|
||||
class AnyHandler:
|
||||
def __getattr__(self, _name):
|
||||
return lambda request: None
|
||||
|
||||
handler_set = ModelHandlerSet(
|
||||
page_view=AnyHandler(),
|
||||
listing=AnyHandler(),
|
||||
management=AnyHandler(),
|
||||
query=AnyHandler(),
|
||||
download=AnyHandler(),
|
||||
civitai=AnyHandler(),
|
||||
move=AnyHandler(),
|
||||
auto_organize=AnyHandler(),
|
||||
filename_template=AnyHandler(),
|
||||
updates=AnyHandler(),
|
||||
)
|
||||
|
||||
mapping = handler_set.to_route_mapping()
|
||||
missing = [
|
||||
definition.handler_name
|
||||
for definition in COMMON_ROUTE_DEFINITIONS
|
||||
if definition.handler_name not in mapping
|
||||
]
|
||||
|
||||
assert missing == []
|
||||
|
||||
|
||||
def test_get_price_alerts_is_registered_as_a_route():
|
||||
"""The alerts endpoint must be declared and reachable via GET."""
|
||||
|
||||
definition = next(
|
||||
d
|
||||
for d in COMMON_ROUTE_DEFINITIONS
|
||||
if d.handler_name == "get_price_alerts"
|
||||
)
|
||||
assert definition.method == "GET"
|
||||
assert definition.build_path("loras") == "/api/lm/loras/updates/price-alerts"
|
||||
|
||||
Reference in New Issue
Block a user