Files
ComfyUI-Lora-Manager/py/routes/model_route_registrar.py
T
Will Miao ad2402724b 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.
2026-10-04 08:53:24 +08:00

203 lines
8.7 KiB
Python

"""Route registrar for model endpoints."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable, Iterable, Mapping
from aiohttp import web
@dataclass(frozen=True)
class RouteDefinition:
"""Declarative definition for a HTTP route."""
method: str
path_template: str
handler_name: str
def build_path(self, prefix: str) -> str:
return self.path_template.replace("{prefix}", prefix)
COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/{prefix}/list", "get_models"),
RouteDefinition("GET", "/api/lm/{prefix}/excluded", "get_excluded_models"),
RouteDefinition("POST", "/api/lm/{prefix}/delete", "delete_model"),
RouteDefinition("POST", "/api/lm/{prefix}/exclude", "exclude_model"),
RouteDefinition("POST", "/api/lm/{prefix}/unexclude", "unexclude_model"),
RouteDefinition("POST", "/api/lm/{prefix}/fetch-civitai", "fetch_civitai"),
RouteDefinition("POST", "/api/lm/{prefix}/fetch-all-civitai", "fetch_all_civitai"),
RouteDefinition("POST", "/api/lm/{prefix}/relink-civitai", "relink_civitai"),
RouteDefinition("POST", "/api/lm/{prefix}/replace-preview", "replace_preview"),
RouteDefinition(
"POST", "/api/lm/{prefix}/set-preview-from-url", "set_preview_from_url"
),
RouteDefinition("POST", "/api/lm/{prefix}/save-metadata", "save_metadata"),
RouteDefinition("POST", "/api/lm/{prefix}/add-tags", "add_tags"),
RouteDefinition("POST", "/api/lm/{prefix}/rename", "rename_model"),
RouteDefinition("POST", "/api/lm/{prefix}/bulk-delete", "bulk_delete_models"),
RouteDefinition("POST", "/api/lm/{prefix}/verify-duplicates", "verify_duplicates"),
RouteDefinition("POST", "/api/lm/{prefix}/move_model", "move_model"),
RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"),
RouteDefinition("POST", "/api/lm/{prefix}/create-folder", "create_folder"),
RouteDefinition("POST", "/api/lm/{prefix}/delete-folder", "delete_folder"),
RouteDefinition("POST", "/api/lm/{prefix}/rename-folder", "rename_folder"),
RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
RouteDefinition(
"GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"
),
RouteDefinition(
"GET", "/api/lm/{prefix}/apply-filename-template", "apply_filename_template"
),
RouteDefinition(
"POST", "/api/lm/{prefix}/apply-filename-template", "apply_filename_template"
),
RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"),
RouteDefinition("GET", "/api/lm/{prefix}/search-tags", "search_tags"),
RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"),
RouteDefinition("GET", "/api/lm/{prefix}/model-types", "get_model_types"),
RouteDefinition("GET", "/api/lm/{prefix}/scan", "scan_models"),
RouteDefinition("GET", "/api/lm/{prefix}/roots", "get_model_roots"),
RouteDefinition("GET", "/api/lm/{prefix}/folders", "get_folders"),
RouteDefinition("GET", "/api/lm/{prefix}/folder-tree", "get_folder_tree"),
RouteDefinition(
"GET", "/api/lm/{prefix}/unified-folder-tree", "get_unified_folder_tree"
),
RouteDefinition("GET", "/api/lm/{prefix}/find-duplicates", "find_duplicate_models"),
RouteDefinition(
"GET", "/api/lm/{prefix}/find-filename-conflicts", "find_filename_conflicts"
),
RouteDefinition("GET", "/api/lm/{prefix}/get-notes", "get_model_notes"),
RouteDefinition("GET", "/api/lm/{prefix}/preview-url", "get_model_preview_url"),
RouteDefinition("GET", "/api/lm/{prefix}/civitai-url", "get_model_civitai_url"),
RouteDefinition("GET", "/api/lm/{prefix}/metadata", "get_model_metadata"),
RouteDefinition(
"GET", "/api/lm/{prefix}/model-description", "get_model_description"
),
RouteDefinition("GET", "/api/lm/{prefix}/relative-paths", "get_relative_paths"),
RouteDefinition("PUT", "/api/lm/{prefix}/active-filters", "update_active_filters"),
RouteDefinition("GET", "/api/lm/{prefix}/active-filters", "get_active_filters"),
RouteDefinition(
"GET", "/api/lm/{prefix}/civitai/versions/{model_id}", "get_civitai_versions"
),
RouteDefinition(
"GET",
"/api/lm/{prefix}/civitai/model/version/{modelVersionId}",
"get_civitai_model_by_version",
),
RouteDefinition(
"GET", "/api/lm/{prefix}/civitai/model/hash/{hash}", "get_civitai_model_by_hash"
),
RouteDefinition(
"POST", "/api/lm/{prefix}/updates/refresh", "refresh_model_updates"
),
RouteDefinition(
"POST",
"/api/lm/{prefix}/updates/fetch-missing-license",
"fetch_missing_civitai_license_data",
),
RouteDefinition(
"POST", "/api/lm/{prefix}/updates/ignore", "set_model_update_ignore"
),
RouteDefinition(
"POST", "/api/lm/{prefix}/updates/ignore-version", "set_version_update_ignore"
),
RouteDefinition(
"GET", "/api/lm/{prefix}/updates/status/{model_id}", "get_model_update_status"
),
RouteDefinition(
"GET", "/api/lm/{prefix}/updates/versions/{model_id}", "get_model_versions"
),
RouteDefinition(
"GET", "/api/lm/{prefix}/updates/price-alerts", "get_price_alerts"
),
RouteDefinition("POST", "/api/lm/download-model", "download_model"),
RouteDefinition("GET", "/api/lm/download-model-get", "download_model_get"),
RouteDefinition("GET", "/api/lm/cancel-download-get", "cancel_download_get"),
RouteDefinition("GET", "/api/lm/skip-download", "skip_download_get"),
RouteDefinition("GET", "/api/lm/pause-download", "pause_download_get"),
RouteDefinition("GET", "/api/lm/resume-download", "resume_download_get"),
RouteDefinition(
"GET", "/api/lm/download-progress/{download_id}", "get_download_progress"
),
RouteDefinition("GET", "/api/lm/downloads/queue", "get_download_queue"),
RouteDefinition("GET", "/api/lm/downloads/queue/add", "add_to_download_queue"),
RouteDefinition(
"GET", "/api/lm/downloads/queue/remove", "remove_from_download_queue"
),
RouteDefinition(
"GET", "/api/lm/downloads/queue/move-to-top", "move_queue_item_to_top"
),
RouteDefinition(
"GET", "/api/lm/downloads/queue/move-to-end", "move_queue_item_to_end"
),
RouteDefinition(
"GET", "/api/lm/downloads/queue/clear", "clear_download_queue"
),
RouteDefinition("GET", "/api/lm/downloads/history", "get_download_history"),
RouteDefinition(
"GET", "/api/lm/downloads/history/clear", "clear_download_history"
),
RouteDefinition(
"GET", "/api/lm/downloads/history/delete", "delete_download_history_item"
),
RouteDefinition(
"GET", "/api/lm/downloads/history/retry", "retry_download_from_history"
),
RouteDefinition(
"GET", "/api/lm/downloads/history/retry-all", "retry_all_failed_downloads"
),
RouteDefinition("GET", "/api/lm/downloads/stats", "get_download_stats"),
RouteDefinition(
"GET", "/api/lm/downloads/queue/complete", "complete_download_in_queue"
),
RouteDefinition(
"GET", "/api/lm/downloads/queue/status", "update_download_queue_status"
),
RouteDefinition("POST", "/api/lm/{prefix}/cancel-task", "cancel_task"),
RouteDefinition("GET", "/{prefix}", "handle_models_page"),
)
class ModelRouteRegistrar:
"""Bind declarative definitions to an aiohttp router."""
_METHOD_MAP = {
"GET": "add_get",
"POST": "add_post",
"PUT": "add_put",
"DELETE": "add_delete",
}
def __init__(self, app: web.Application) -> None:
self._app = app
def register_common_routes(
self,
prefix: str,
handler_lookup: Mapping[str, Callable[[web.Request], object]],
*,
definitions: Iterable[RouteDefinition] = COMMON_ROUTE_DEFINITIONS,
) -> None:
for definition in definitions:
self._bind_route(
definition.method,
definition.build_path(prefix),
handler_lookup[definition.handler_name],
)
def add_route(self, method: str, path: str, handler: Callable[..., Any]) -> None:
self._bind_route(method, path, handler)
def add_prefixed_route(
self, method: str, path_template: str, prefix: str, handler: Callable[..., Any]
) -> None:
self._bind_route(method, path_template.replace("{prefix}", prefix), handler)
def _bind_route(self, method: str, path: str, handler: Callable[..., Any]) -> None:
add_method_name = self._METHOD_MAP[method.upper()]
add_method = getattr(self._app.router, add_method_name)
add_method(path, handler)