diff --git a/buzz/core/events.py b/buzz/core/events.py index 348a1e0..629bfd5 100644 --- a/buzz/core/events.py +++ b/buzz/core/events.py @@ -3,6 +3,7 @@ import json import threading from collections import deque +from collections.abc import Callable from typing import Any from .utils import utc_now_iso @@ -20,6 +21,7 @@ def __init__( self.events = deque(maxlen=maxlen) self.lock = threading.Lock() self.default_source = default_source + self.listeners: list[Callable[[dict[str, Any]], None]] = [] def record( self, @@ -39,6 +41,13 @@ def record( del event["source"] with self.lock: self.events.append(event) + listeners = list(self.listeners) + + for listener in listeners: + try: + listener(event) + except Exception: + pass # Also print to stdout for legacy logging and visibility prefix = f"[{level.upper()}]" if level != "info" else "" @@ -57,6 +66,11 @@ def reconfigure(self, maxlen: int) -> None: with self.lock: self.events = deque(self.events, maxlen=maxlen) + def add_listener(self, listener: Callable[[dict[str, Any]], None]) -> None: + """Register a callback invoked after each event is recorded.""" + with self.lock: + self.listeners.append(listener) + # Global registry for the process registry = EventRegistry() diff --git a/buzz/core/state.py b/buzz/core/state.py index 00b7cef..112a271 100644 --- a/buzz/core/state.py +++ b/buzz/core/state.py @@ -297,7 +297,12 @@ def _etag(self, *parts: Any) -> str: class BuzzState: """Thread-safe cache of torrent state, snapshot, and Real-Debrid sync.""" - def __init__(self, config: DavConfig, client: Any) -> None: + def __init__( + self, + config: DavConfig, + client: Any, + on_ui_change: Any | None = None, + ) -> None: """Initialize state storage and load persisted data from disk.""" self.config = config self.client = client @@ -329,6 +334,7 @@ def __init__(self, config: DavConfig, client: Any) -> None: self.hook_lock = threading.Lock() self.hook_task_active = False self._closed = False + self.on_ui_change = on_ui_change def _snapshot_exists_in_db(self) -> bool: row = self.conn.execute( @@ -486,6 +492,7 @@ def _classified_changed_roots( def sync(self, *, trigger_hook: bool = True) -> SyncReport: """Sync torrent state with Real-Debrid and rebuild the snapshot.""" hook_paths: list[str] = [] + should_notify = False with self.lock: self.sync_in_progress = True try: @@ -560,6 +567,7 @@ def sync(self, *, trigger_hook: bool = True) -> SyncReport: self.snapshot_digest = digest self._save_snapshot(self.snapshot, self.snapshot_digest) self.snapshot_loaded = True + should_notify = True if trigger_hook and ( self.config.hook_command or self.config.curator_url ): @@ -570,6 +578,8 @@ def sync(self, *, trigger_hook: bool = True) -> SyncReport: self.last_error = None if hook_paths: self._enqueue_hook(hook_paths) + if should_notify: + self._notify_ui_change("sync") return report except Exception as exc: with self.lock: @@ -789,6 +799,7 @@ def mark_startup_sync_complete(self) -> None: """Flag that the initial startup sync has finished.""" with self.lock: self.startup_sync_complete = True + self._notify_ui_change("sync") def is_ready(self) -> bool: """Return True when the library is ready to serve DAV requests.""" @@ -918,6 +929,7 @@ def delete_torrent(self, torrent_id: str) -> OperationResult: if torrent_id in self.cache: del self.cache[torrent_id] self._delete_cache_entry(torrent_id) + self._notify_ui_change("archive") return {"status": "success"} def _add_to_archive(self, info: TorrentInfo, magnet: str | None = None) -> None: @@ -983,6 +995,7 @@ def restore_trash(self, thash: str) -> OperationResult: if thash in self.trashcan: del self.trashcan[thash] self._delete_archive_entry(thash) + self._notify_ui_change("archive") return {"status": "success", "id": torrent_id} @@ -992,8 +1005,17 @@ def delete_trash_permanently(self, thash: str) -> OperationResult: if thash in self.trashcan: del self.trashcan[thash] self._delete_archive_entry(thash) + self._notify_ui_change("archive") return {"status": "success"} + def _notify_ui_change(self, topic: str) -> None: + if self.on_ui_change is None: + return + try: + self.on_ui_change(topic) + except Exception: + pass + def select_files( self, torrent_id: str, file_ids: list[str] ) -> OperationResult: diff --git a/buzz/curator_app.py b/buzz/curator_app.py index d609c64..8ec5163 100644 --- a/buzz/curator_app.py +++ b/buzz/curator_app.py @@ -1,9 +1,11 @@ """FastAPI application for the curator service.""" +import json import traceback from contextlib import asynccontextmanager +from urllib import request -from fastapi import FastAPI +from fastapi import BackgroundTasks, FastAPI from fastapi.responses import JSONResponse from .core.curator import Curator, RebuildError, build_library @@ -29,6 +31,7 @@ def __init__(self, config: CuratorConfig) -> None: self.config = config self.curator = Curator(config) + registry.add_listener(self._notify_dav_ui) @asynccontextmanager async def lifespan(app: FastAPI): @@ -72,38 +75,13 @@ def get_logs_count(): return {"count": len(registry.events)} @self.app.post("/rebuild") - async def rebuild(payload: dict | None = None): + async def rebuild( + background_tasks: BackgroundTasks, + payload: dict | None = None, + ): changed_roots = (payload or {}).get("changed_roots", []) - try: - report = self.curator.handle_rebuild(changed_roots) - return report - except Exception as exc: - payload = {"error": str(exc)} - if isinstance(exc, RebuildError): - payload.update(exc.payload) - - from urllib.error import HTTPError - - cause = exc.__cause__ - if isinstance(cause, HTTPError) and cause.code in (401, 403): - record_event( - "curator rebuild failed: " - "Jellyfin API Token is invalid or unauthorized", - level="error", - ) - payload["error"] = ( - "Jellyfin API Token is invalid or unauthorized" - ) - return JSONResponse( - status_code=403, content=payload - ) - - record_event( - f"curator rebuild failed: {exc}\n" - f"{traceback.format_exc()}", - level="error", - ) - return JSONResponse(status_code=500, content=payload) + background_tasks.add_task(self._run_rebuild, changed_roots) + return {"status": "rebuilding"} @self.app.get("/api/subtitles/status") def get_subtitles_status(): @@ -134,6 +112,61 @@ def trigger_subtitles_fetch(payload: dict | None = None): ) return {"status": "triggered"} + def _run_rebuild(self, changed_roots: list[str]) -> None: + try: + self.curator.handle_rebuild(changed_roots) + except Exception as exc: + if isinstance(exc, RebuildError): + cause = exc.__cause__ + from urllib.error import HTTPError + + if isinstance(cause, HTTPError) and cause.code in (401, 403): + record_event( + "curator rebuild failed: " + "Jellyfin API Token is invalid or unauthorized", + level="error", + ) + return + + record_event( + f"curator rebuild failed: {exc}\n" + f"{traceback.format_exc()}", + level="error", + ) + + def _notify_dav_ui(self, event: dict) -> None: + if not self.config.dav_ui_notify_url: + return + + payload = { + "topics": ["logs", "status"], + "message": { + "source": "curator", + "event": event.get("event"), + "level": event.get("level"), + "message": event.get("message", ""), + }, + } + data = json.dumps(payload).encode("utf-8") + req = request.Request( + self.config.dav_ui_notify_url, + data=data, + method="POST", + headers={"Content-Type": "application/json"}, + ) + try: + with request.urlopen(req, timeout=2) as response: + if response.status not in (200, 204): + print( + f"[WARN] DAV UI notify returned HTTP {response.status}", + flush=True, + ) + except Exception as exc: + print( + f"[WARN] DAV UI notify failed: {exc}", + flush=True, + ) + def run_curator_server(config: CuratorConfig) -> None: """Start the curator HTTP server.""" diff --git a/buzz/dav_app.py b/buzz/dav_app.py index 792da98..424f150 100644 --- a/buzz/dav_app.py +++ b/buzz/dav_app.py @@ -3,17 +3,20 @@ import json import os import queue +import signal import threading +import asyncio from contextlib import asynccontextmanager from http import HTTPStatus from urllib import error -import jinja2 import yaml from fastapi import FastAPI, Request, Response from fastapi.exceptions import RequestValidationError from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse from fastapi.staticfiles import StaticFiles +from pyview.live_socket import pub_sub_hub +from pyview.pyview import liveview_container from rdapi import RD from .core.events import record_event @@ -37,11 +40,13 @@ ErrorResponse, RestoreTrashRequest, SelectFilesRequest, + UiNotifyRequest, _strip_secrets, mask_secrets, save_overrides, to_nested_dict, ) +from .ui_live import build_ui def _fetch_opensubtitles_languages() -> list[tuple[str, str]]: @@ -67,54 +72,81 @@ def __init__(self, config: DavConfig) -> None: self.config = config os.environ["RD_APITOKEN"] = config.token self.client = RD() - self.state = BuzzState(config, self.client) + self.ui_loop: asyncio.AbstractEventLoop | None = None + self.state = BuzzState(config, self.client, on_ui_change=self._notify_ui_change) self.opensubtitles_languages = _fetch_opensubtitles_languages() + self.ui = build_ui(self) + self._curator_log_level: str = "info" + registry.add_listener(self._handle_recorded_event) @asynccontextmanager async def lifespan(app: FastAPI): + self.ui_loop = asyncio.get_running_loop() initial_sync = InitialSync(self.state) poller = Poller(self.state) initial_sync.start() poller.start() yield poller.stop() + self.ui_loop = None self.state.close() self.app = FastAPI(lifespan=lifespan) self.app.add_exception_handler( RequestValidationError, self._handle_validation_error ) - self.templates = jinja2.Environment( - loader=jinja2.FileSystemLoader( - os.path.join(os.path.dirname(__file__), "templates") - ), - autoescape=jinja2.select_autoescape(["html", "xml"]), - ) self.app.mount( "/static", StaticFiles(directory=os.path.join(os.path.dirname(__file__), "static")), name="static", ) + self.app.mount( + "/pyview", + StaticFiles(packages=[("pyview", "static")]), + name="pyview", + ) self._setup_routes() + websocket_route = next( + route + for route in self.ui.routes + if route.__class__.__name__ == "WebSocketRoute" + ) + self.app.router.routes.append(websocket_route) def _setup_routes(self): - @self.app.get("/logs", response_class=HTMLResponse) - def logs_page(request: Request): - return self._logs_page() - @self.app.get("/", response_class=HTMLResponse) @self.app.get("/cache", response_class=HTMLResponse) - def index(): - return self._cache_page() + async def cache_page(request: Request): + return await liveview_container( + self.ui.rootTemplate, + self.ui.view_lookup, + request, + ) @self.app.get("/archive", response_class=HTMLResponse) - def archive(): - return self._archive_page() + async def archive_page(request: Request): + return await liveview_container( + self.ui.rootTemplate, + self.ui.view_lookup, + request, + ) + + @self.app.get("/logs", response_class=HTMLResponse) + async def logs_page(request: Request): + return await liveview_container( + self.ui.rootTemplate, + self.ui.view_lookup, + request, + ) @self.app.get("/config", response_class=HTMLResponse) - def config_page(): - return self._config_page() + async def config_page(request: Request): + return await liveview_container( + self.ui.rootTemplate, + self.ui.view_lookup, + request, + ) @self.app.get("/api/config") def get_config(): @@ -139,27 +171,33 @@ def post_config(payload: dict): except Exception as exc: return JSONResponse(status_code=400, content={"error": str(exc)}) + @self.app.post("/api/ui/notify") + def ui_notify(payload: UiNotifyRequest): + msg = str(payload.message.get("message", "")) + level = str(payload.message.get("level", "info")).lower() + source = str(payload.message.get("source", "dav")) + event_name = str(payload.message.get("event", "")) + priority = {"error": 3, "warning": 2, "info": 1, "debug": 0} + if priority.get(level, 0) > priority.get(self._curator_log_level, 0): + self._curator_log_level = level + record_event( + msg, + level=level, + source=source, + event=event_name or None, + ) + for topic in payload.topics: + self._notify_ui_topic( + f"buzz:{topic}", + dict(payload.message), + ) + return {"status": "ok"} + @self.app.get("/healthz") def healthz(): - import httpx - - from .core.events import registry - - dav_count = len(registry.events) - curator_count = 0 - if self.config.curator_url: - try: - count_url = self.config.curator_url.replace("/rebuild", "/api/logs/count") - with httpx.Client(timeout=1.0) as client: - resp = client.get(count_url) - if resp.status_code == 200: - curator_count = resp.json().get("count", 0) - except Exception: # noqa: BLE001 - pass - return { "status": "ok", - "log_count": dav_count + curator_count, + "log_count": self.log_count(), "archive_count": len(self.state.trashcan), **self.state.status(), } @@ -307,37 +345,11 @@ def fetch_subs_for_torrent(payload: dict): @self.app.get("/api/logs") def get_logs(limit: int = 100): - import httpx - - from .core.events import registry - - logs = registry.get_recent(limit) - for log in logs: - log.setdefault("source", "dav") - - # Try to fetch from curator - if self.config.curator_url: - try: - curator_logs_url = self.config.curator_url.replace("/rebuild", "/api/logs") - with httpx.Client(timeout=2.0) as client: - resp = client.get(f"{curator_logs_url}?limit={limit}") - if resp.status_code == 200: - curator_logs = resp.json() - for log in curator_logs: - log.setdefault("source", "curator") - logs.extend(curator_logs) - except Exception: # noqa: BLE001 - pass - - logs.sort(key=lambda x: x.get("timestamp", "")) - return logs[-limit:] + return self.get_logs(limit) @self.app.post("/api/restart") def restart_service(): - import signal - - record_event("Restart requested via API", level="warning") - os.kill(os.getpid(), signal.SIGTERM) + self.restart_service() return {"status": "restarting"} @self.app.options("/dav/{path:path}") @@ -535,122 +547,24 @@ def buffer_reader(): ) return Response(status_code=502, content=str(exc)) - def _cache_page(self) -> str: - from .core.events import registry - - status = self.state.status() - torrents = self.state.torrents() - page_torrents = [ - { - "id": torrent["id"], - "name": torrent["name"], - "status": torrent["status"], - "progress": torrent["progress"], - "bytes": torrent["bytes"], - "size": format_bytes(torrent["bytes"]), - "selected_files": torrent["selected_files"], - "links": torrent["links"], - "ended": torrent["ended"] or "-", - "short_id": torrent["id"][:8], - } - for torrent in torrents - ] - - sync_state = "syncing" if status.get("sync_in_progress") else "idle" - - template = self.templates.get_template("cache.html") - return template.render( - torrents_count=len(torrents), - last_sync_at=status.get("last_sync_at") or "never", - sync_state=sync_state, - snapshot_ready="true" if status.get("snapshot_loaded") else "false", - last_error=status.get("last_error"), - cache_items=page_torrents, - trash_count=len(self.state.trashcan), - log_count=len(registry.events), - subtitle_enabled=self.config.subtitles.enabled, - ui_poll_interval_secs=self.config.ui_poll_interval_secs, - ) - - def _archive_page(self) -> str: - from .core.events import registry - - status = self.state.status() - torrents = self.state.archive_torrents() - archive_items = [] - for torrent in torrents: - archive_items.append( - { - "hash": torrent["hash"], - "name": torrent["name"], - "bytes": torrent["bytes"], - "size": format_bytes(torrent["bytes"]), - "file_count": torrent["file_count"], - "deleted_at": torrent["deleted_at"] or "-", - } - ) - - sync_state = "syncing" if status.get("sync_in_progress") else "idle" - - template = self.templates.get_template("archive.html") - return template.render( - torrents_count=len(self.state.torrents()), - last_sync_at=status.get("last_sync_at") or "never", - sync_state=sync_state, - snapshot_ready="true" if status.get("snapshot_loaded") else "false", - last_error=status.get("last_error"), - archive_items=archive_items, - trash_count=len(archive_items), - log_count=len(registry.events), - ui_poll_interval_secs=self.config.ui_poll_interval_secs, - ) - - def _logs_page(self) -> str: - from .core.events import registry - - status = self.state.status() - sync_state = "syncing" if status.get("sync_in_progress") else "idle" - - template = self.templates.get_template("logs.html") - return template.render( - torrents_count=len(self.state.torrents()), - last_sync_at=status.get("last_sync_at") or "never", - sync_state=sync_state, - snapshot_ready="true" if status.get("snapshot_loaded") else "false", - last_error=status.get("last_error"), - trash_count=len(self.state.trashcan), - log_count=len(registry.events), - ui_poll_interval_secs=self.config.ui_poll_interval_secs, - ) - def _config_page(self) -> str: - from .core.events import registry - - status = self.state.status() - sync_state = "syncing" if status.get("sync_in_progress") else "idle" - effective = to_nested_dict(self.config) - masked = mask_secrets(effective) - effective_yaml = yaml.safe_dump(masked, default_flow_style=False, sort_keys=False) + def fetch_subtitles(self, torrent_name: str) -> dict: + """Request subtitle fetch for a torrent from the curator.""" + import httpx - selected = set(self.config.subtitles.languages) - languages = sorted( - self.opensubtitles_languages or [], - key=lambda item: (item[0] not in selected, item[1].lower()), - ) + if not self.config.curator_url: + return {"error": "No curator configured"} - template = self.templates.get_template("config.html") - return template.render( - torrents_count=len(self.state.torrents()), - last_sync_at=status.get("last_sync_at") or "never", - sync_state=sync_state, - snapshot_ready="true" if status.get("snapshot_loaded") else "false", - last_error=status.get("last_error"), - trash_count=len(self.state.trashcan), - log_count=len(registry.events), - ui_poll_interval_secs=self.config.ui_poll_interval_secs, - effective_yaml=effective_yaml, - config=self.config, - opensubtitles_languages=languages, + subs_url = self.config.curator_url.replace( + "/rebuild", "/api/subtitles/fetch" ) + try: + with httpx.Client(timeout=5.0) as client: + resp = client.post( + subs_url, json={"torrent_name": torrent_name} + ) + return {"status_code": resp.status_code, "data": resp.json()} + except Exception as exc: + return {"error": f"Curator unreachable: {exc}"} async def _handle_validation_error( self, request: Request, exc: Exception @@ -665,6 +579,75 @@ async def _handle_validation_error( content={"error": str(first_error.get("msg", "Invalid request"))}, ) + def get_logs(self, limit: int = 100) -> list[dict]: + from .core.events import registry + + logs = registry.get_recent(limit) + for log in logs: + log.setdefault("source", "dav") + + logs.sort(key=lambda item: item.get("timestamp", "")) + return logs[-limit:] + + def formatted_logs(self, limit: int = 100) -> list[dict[str, str]]: + formatted = [] + for log in self.get_logs(limit): + timestamp = log.get("timestamp", "") + display_timestamp = timestamp + if "T" in timestamp and len(timestamp) >= 19: + display_timestamp = timestamp[11:19] + level = str(log.get("level", "info")).lower() + level_label = f"[{level.upper()}]" + source = "buzz-curator" if log.get("source") == "curator" else "buzz-dav" + message = str(log.get("message", "")) + copy_text = f"{source} {display_timestamp} {level_label} {message}" + formatted.append( + { + "copy_text": copy_text, + "level": level, + "level_class": f"log-level-{level}", + "level_label": level_label, + "message": message, + "source": source, + "timestamp": display_timestamp, + } + ) + return formatted + + def log_count(self) -> int: + from .core.events import registry + + return len(registry.events) + + def restart_service(self) -> None: + record_event("Restart requested via API", level="warning") + os.kill(os.getpid(), signal.SIGTERM) + + def _handle_recorded_event(self, event: dict) -> None: + self._notify_ui_topic("buzz:logs", event) + self._notify_ui_topic("buzz:status", event) + + def _notify_ui_change(self, topic: str) -> None: + self._notify_ui_topic("buzz:status", {"topic": topic}) + if topic == "archive": + self._notify_ui_topic("buzz:archive", {"topic": topic}) + elif topic == "sync": + self._notify_ui_topic("buzz:archive", {"topic": topic}) + self._notify_ui_topic("buzz:logs", {"topic": topic}) + elif topic == "config": + self._notify_ui_topic("buzz:config", {"topic": topic}) + + def _notify_ui_topic(self, topic: str, message: dict) -> None: + if self.ui_loop is None: + return + try: + asyncio.run_coroutine_threadsafe( + pub_sub_hub.send_all_on_topic_async(topic, message), + self.ui_loop, + ) + except Exception: + pass + def run_dav_server(config: DavConfig) -> None: """Start the uvicorn server for the DAV application.""" diff --git a/buzz/models.py b/buzz/models.py index 32f1569..f38375c 100644 --- a/buzz/models.py +++ b/buzz/models.py @@ -471,6 +471,12 @@ class CuratorConfig(BaseModel): default_factory=lambda: _env_flag("CURATOR_VERBOSE") or _env_flag("PRESENTATION_VERBOSE") ) + dav_ui_notify_url: str = Field( + default_factory=lambda: os.environ.get( + "DAV_UI_NOTIFY_URL", + "http://buzz-dav:9999/api/ui/notify", + ).rstrip("/") + ) log_max_entries: int = Field( default_factory=lambda: int( os.environ.get( @@ -523,6 +529,22 @@ class ErrorResponse(BaseModel): error: str +class UiNotifyRequest(BaseModel): + """Request body for backend-driven UI websocket notifications.""" + + topics: list[str] + message: dict[str, object] = Field(default_factory=dict) + + @field_validator("topics") + @classmethod + def validate_topics(cls, value: list[str]) -> list[str]: + """Normalize topics and reject empty payloads.""" + normalized = [topic.strip() for topic in value if topic.strip()] + if not normalized: + raise ValueError("Missing topics") + return normalized + + class AddTorrentRequest(BaseModel): """Request body for adding a torrent by magnet link.""" diff --git a/buzz/pyview_templates/archive_live.html b/buzz/pyview_templates/archive_live.html new file mode 100644 index 0000000..b88138c --- /dev/null +++ b/buzz/pyview_templates/archive_live.html @@ -0,0 +1,132 @@ +
+
+ buzz: + +
+ +
+
+ {% for item in meta_items %} +
+ [{{ item.label }}] + {{ item.value }} +
+ {% endfor %} +
+ {% if is_ready %} + [ready] + {% else %} + [starting] + {% endif %} +
+
+
+ console: + {{ console_msg }} +
+
+ + {% if has_error %} +
[ERROR] {{ last_error }}
+ {% endif %} + +
+ + + + + + + + + + + + {% if has_items %} + {% for torrent in archive_items %} + + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
NameSizeFilesDate RemovedAct
+
{{ torrent.name }}
+
+ {{ torrent.name }} +
+
{{ torrent.size }}{{ torrent.file_count }} + {{ torrent.deleted_at }} + +
+ {% if confirm_restore_hash == torrent.hash %} +
+ + +
+ {% elif confirm_delete_hash == torrent.hash %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} +
+
Archive is empty.
+
+
diff --git a/buzz/pyview_templates/cache_live.html b/buzz/pyview_templates/cache_live.html new file mode 100644 index 0000000..0d2919b --- /dev/null +++ b/buzz/pyview_templates/cache_live.html @@ -0,0 +1,277 @@ +
+
+ buzz: + +
+ +
+
+ {% for item in meta_items %} +
+ [{{ item.label }}] + {{ item.value }} +
+ {% endfor %} +
+ {% if is_ready %} + [ready] + {% else %} + [starting] + {% endif %} +
+
+
+ console: + {{ console_msg }} + +
+
+ + {% if has_error %} +
[ERROR] {{ last_error }}
+ {% endif %} + +
+ {% if not analysis_results %} +
+ ADD NEW MAGNET TO THE CACHE: +
+
+
+
+ {% for input in magnet_inputs %} +
+ + {% if not loop.is_first %} + + {% else %} + + {% endif %} +
+ {% endfor %} +
+ +
+
+ {% else %} +
+ SELECT FILES TO CACHE: +
+
+ + + +
+
+ {% for result in analysis_results %} + {% if has_multiple_analysis_results %} +
{{ result.filename }}
+ {% endif %} + {% for file in result.files %} +
+ +
+ {{ file.path }} + {{ file.size }} +
+
+ {% endfor %} + {% endfor %} +
+
+ + +
+ {% endif %} +
+ +
+
+ processing... +
+ + + + + + + + + + + + + + + {% if has_torrents %} + {% for torrent in torrents %} + + + + + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
NameStatusProgSizeFilesEndedIDAct
+
{{ torrent.name }}
+
+ {{ torrent.name }} +
+
+ [{{ torrent.status }}] + {{ torrent.progress }}%{{ torrent.size }}{{ torrent.selected_files }}{{ torrent.ended }} + {{ torrent.short_id }} + +
+ {% if subtitle_enabled and confirm_delete_id != torrent.id %} + + {% endif %} + {% if confirm_delete_id == torrent.id %} +
+ + +
+ {% else %} + + + {% endif %} +
+
+ No cached items yet. Wait for the first sync or trigger + POST /sync. +
+
+
diff --git a/buzz/pyview_templates/config_live.html b/buzz/pyview_templates/config_live.html new file mode 100644 index 0000000..94a013f --- /dev/null +++ b/buzz/pyview_templates/config_live.html @@ -0,0 +1,395 @@ +
+
+ buzz: + +
+ +
+
+ {% for item in meta_items %} +
+ [{{ item.label }}] + {{ item.value }} +
+ {% endfor %} +
+ {% if is_ready %} + [ready] + {% else %} + [starting] + {% endif %} +
+
+
+ console: + {{ console_msg }} +
+
+ + {% if has_error %} +
[ERROR] {{ last_error }}
+ {% endif %} + + {% if is_editing %} +
+
+

Edit Overrides

+ +
+ +
+
+ {% if restart_required %} +
+ + Configuration saved. Restart required for changes to take effect. + +
+ {% endif %} + +
+
+ Polling +
+ + +
+
+ +
+ Server +
+ + +
+
+ + +
+
+ + +
+
+ +
+ Hooks +
+ + +
+
+ + +
+
+ + +
+
+ +
+ Compatibility +
+ + +
+
+ + +
+
+ +
+ Directories / Anime +
+ + +
+
+ +
+ Request +
+ + +
+
+ + +
+
+ + +
+
+ +
+ UI +
+ + +
+
+ +
+ Logging +
+ + +
+
+ +
+ Subtitles +
+ + +
+
+ + +
+
+ + +
+ {% for language in languages %} +
+ + +
+ {% endfor %} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+ {% else %} +
+
+

Effective Configuration

+ +
+
+
+
{{ effective_yaml }}
+
+ +
+
+ {% endif %} +
diff --git a/buzz/pyview_templates/logs_live.html b/buzz/pyview_templates/logs_live.html new file mode 100644 index 0000000..ac9ba7f --- /dev/null +++ b/buzz/pyview_templates/logs_live.html @@ -0,0 +1,110 @@ +
+
+ buzz: + +
+ +
+
+ {% for item in meta_items %} +
+ [{{ item.label }}] + {{ item.value }} +
+ {% endfor %} +
+ {% if is_ready %} + [ready] + {% else %} + [starting] + {% endif %} +
+
+
+ console: + {{ console_msg }} +
+
+ + {% if has_error %} +
[ERROR] {{ last_error }}
+ {% endif %} + +
+
+

System Logs

+
+ {% if confirm_restart %} + + + {% else %} + + {% endif %} + + + +
+
+
+ {% for log in log_items %} +
+
+ {{ log.source }} + {{ log.timestamp }} + {{ log.level_label }} + {{ log.message }} +
+ +
+ {% endfor %} +
+
+
diff --git a/buzz/static/buzz.css b/buzz/static/buzz.css index 45239e9..47ec2dc 100644 --- a/buzz/static/buzz.css +++ b/buzz/static/buzz.css @@ -22,12 +22,28 @@ body { background: var(--bg); color: var(--fg); line-height: 1.5; + height: 100dvh; + display: flex; + flex-direction: column; +} + +/* pyview injects a wrapper div between body and main */ +body > div[data-phx-main] { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; } main { padding: 20px; max-width: 1400px; margin: 0 auto; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + width: 100%; } .menu-options { @@ -74,13 +90,13 @@ main { text-shadow: 0 0 5px rgba(80, 250, 123, 0.5); } -.nav-logs-new { +.nav-logs-new-warning { color: var(--orange) !important; text-shadow: 0 0 10px rgba(255, 184, 108, 0.8) !important; - animation: log-glow 2s infinite ease-in-out; + animation: log-glow-warning 2s infinite ease-in-out; } -@keyframes log-glow { +@keyframes log-glow-warning { 0%, 100% { @@ -92,6 +108,24 @@ main { } } +.nav-logs-new-error { + color: var(--red) !important; + text-shadow: 0 0 10px rgba(255, 85, 85, 0.8) !important; + animation: log-glow-error 2s infinite ease-in-out; +} + +@keyframes log-glow-error { + + 0%, + 100% { + text-shadow: 0 0 5px rgba(255, 85, 85, 0.5); + } + + 50% { + text-shadow: 0 0 15px rgba(255, 85, 85, 0.9); + } +} + .nav-sep { color: var(--comment); margin: 0 4px; @@ -203,6 +237,8 @@ button.secondary { #torrent-table-container { overflow-y: scroll; overflow-x: auto; + flex: 1; + min-height: 0; } .table-container table { @@ -400,6 +436,11 @@ code { font-weight: bold; padding: 0; color: var(--comment); + background: transparent; + border: none; + text-transform: none; + font-size: inherit; + font-family: inherit; } .btn-r:hover { @@ -557,6 +598,22 @@ code { color: var(--orange); } +#status-ready-label.service-status-orange { + animation: starting-pulse 1.5s ease-in-out infinite; +} + +@keyframes starting-pulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.4; + } +} + .service-status-cyan { color: var(--cyan); } @@ -1008,7 +1065,7 @@ input[type="text"]:focus { margin: 0 0 24px 0; display: flex; flex-direction: column; - max-height: 80vh; + max-height: 75vh; overflow: hidden; } @@ -1029,7 +1086,7 @@ input[type="text"]:focus { flex-shrink: 0; } -div.code-toolbar > .toolbar { +div.code-toolbar>.toolbar { display: none; } diff --git a/buzz/static/buzz.js b/buzz/static/buzz.js index 0d8b329..321bc1d 100644 --- a/buzz/static/buzz.js +++ b/buzz/static/buzz.js @@ -1,468 +1,5 @@ -let buzzPageConfig = { - tableId: "torrent-table", - statusSyncId: "status-sync", - statusLastSyncId: "status-last-sync", - statusReadyId: "status-ready", - statusReadyLabelId: "status-ready-label", - navArchiveCountId: "nav-archive-count", - navLogsId: "nav-logs", - navLogCountId: "nav-log-count", - consoleMsgId: "meta-console-msg", - pollIntervalMs: 3000, -}; - -const zookeeper = { - storageKey: "buzz_ui_state", - - read() { - try { - const raw = localStorage.getItem(this.storageKey); - if (!raw) { - return null; - } - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== "object") { - return null; - } - return { - logCount: Number.isFinite(Number(parsed.logCount)) - ? Number(parsed.logCount) - : null, - archiveCount: Number.isFinite(Number(parsed.archiveCount)) - ? Number(parsed.archiveCount) - : null, - lastSyncAt: - typeof parsed.lastSyncAt === "string" && parsed.lastSyncAt - ? parsed.lastSyncAt - : null, - syncInProgress: - typeof parsed.syncInProgress === "boolean" - ? parsed.syncInProgress - : null, - }; - } catch (err) { - console.warn("Failed to read persisted UI state:", err); - return null; - } - }, - - write(state) { - try { - localStorage.setItem(this.storageKey, JSON.stringify(state)); - } catch (err) { - console.warn("Failed to persist UI state:", err); - } - }, - - merge(freshState) { - const previous = this.read() || {}; - const next = { - logCount: - Number.isFinite(Number(freshState.logCount)) - ? Number(freshState.logCount) - : previous.logCount ?? null, - archiveCount: - Number.isFinite(Number(freshState.archiveCount)) - ? Number(freshState.archiveCount) - : previous.archiveCount ?? null, - lastSyncAt: - typeof freshState.lastSyncAt === "string" - ? freshState.lastSyncAt - : previous.lastSyncAt ?? null, - syncInProgress: - typeof freshState.syncInProgress === "boolean" - ? freshState.syncInProgress - : previous.syncInProgress ?? null, - }; - this.write(next); - return next; - }, - - apply(state) { - if (!state) { - return; - } - - const statusSync = getBuzzElement(buzzPageConfig.statusSyncId); - const statusLastSync = getBuzzElement(buzzPageConfig.statusLastSyncId); - const navArchiveCount = getBuzzElement(buzzPageConfig.navArchiveCountId); - const navLogCount = getBuzzElement(buzzPageConfig.navLogCountId); - - if (statusSync && typeof state.syncInProgress === "boolean") { - statusSync.innerText = state.syncInProgress ? "syncing" : "idle"; - } - if (statusLastSync && typeof state.lastSyncAt === "string" && state.lastSyncAt) { - statusLastSync.innerText = state.lastSyncAt; - } - if (navArchiveCount && Number.isFinite(Number(state.archiveCount))) { - navArchiveCount.innerText = String(Number(state.archiveCount)); - } - if (navLogCount && Number.isFinite(Number(state.logCount))) { - navLogCount.innerText = String(Number(state.logCount)); - } - }, - - hydrate() { - this.apply(this.read()); - }, -}; - -function getBuzzElement(id) { - return id ? document.getElementById(id) : null; -} - -function escapeHtml(text) { - return String(text).replace(/[&<>"']/g, (char) => { - const entities = { - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - }; - return entities[char]; - }); -} - -function setReadyLabel(isReady, offline) { - const readyLabel = getBuzzElement(buzzPageConfig.statusReadyLabelId); - if (!readyLabel) { - return; - } - - readyLabel.classList.remove("service-status-green", "service-status-orange", "service-status-cyan"); - - if (offline) { - readyLabel.innerText = "[offline]"; - readyLabel.classList.add("service-status-cyan"); - return; - } - - if (isReady) { - readyLabel.innerText = "[ready]"; - readyLabel.classList.add("service-status-green"); - } else { - readyLabel.innerText = "[starting]"; - readyLabel.classList.add("service-status-orange"); - } -} - -function getRebuildStatusNode() { - return document.getElementById(buzzPageConfig.consoleMsgId); -} - -async function triggerManualRebuild() { - const status = getRebuildStatusNode(); - if (status) { - status.innerText = "resyncing library..."; - status.className = "service-status-orange"; - } - - try { - const res = await fetch("/api/curator/rebuild", { method: "POST" }); - const data = await res.json(); - if (data.error) { - throw new Error(data.error); - } - - if (status) { - status.innerText = "library resynced!"; - status.className = "service-status-green"; - } - } catch (err) { - if (status) { - status.innerText = "resync failed: " + err.message; - status.className = "service-status-red"; - } - } -} - -async function triggerRestart() { - if (!confirm("Are you sure you want to restart the stack?")) { - return; - } - - const status = getRebuildStatusNode(); - if (status) { - status.innerText = "restarting service..."; - status.className = "service-status-orange"; - } - - try { - await fetch("/api/restart", { method: "POST" }); - setTimeout(() => location.reload(), 5000); - } catch (err) { - if (status) { - status.innerText = "restart failed: " + err.message; - status.className = "service-status-red"; - } - } -} - -async function copyToClipboard(text, successMsg = "copied to clipboard!") { - const consoleMsg = document.getElementById("meta-console-msg"); - - function fallbackCopy(str) { - const el = document.createElement("textarea"); - el.value = str; - el.style.cssText = "position:fixed;opacity:0"; - document.body.appendChild(el); - el.select(); - const ok = document.execCommand("copy"); - document.body.removeChild(el); - if (!ok) throw new Error("execCommand copy failed"); - } - - try { - if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(text); - } else { - fallbackCopy(text); - } - if (consoleMsg) { - consoleMsg.innerText = successMsg; - consoleMsg.className = "service-status-green"; - setTimeout(() => { - if (consoleMsg.innerText === successMsg) consoleMsg.innerText = ""; - }, 3000); - } - return true; - } catch (err) { - console.error("Failed to copy:", err); - if (consoleMsg) { - consoleMsg.innerText = "failed to copy."; - consoleMsg.className = "service-status-red"; - } - return false; - } -} - -async function copyLogs() { - const container = document.getElementById("log-container"); - if (!container || container.innerText.trim() === "Loading logs...") { - return; - } - - const entries = container.querySelectorAll(".log-content"); - const logText = Array.from(entries) - .map((entry) => entry.innerText) - .join("\n"); - - await copyToClipboard(logText, "logs copied to clipboard!"); -} - -async function pollLogs() { - const container = document.getElementById("log-container"); - if (!container) { - return; - } - - try { - const res = await fetch("/api/logs?limit=100"); - if (!res.ok) { - throw new Error("log fetch failed"); - } - - const logs = await res.json(); - if (logs.length === 0) { - return; - } - - container.innerHTML = logs - .map(log => { - const tsMatch = log.timestamp.match(/T(\d{2}:\d{2}:\d{2})/); - const ts = tsMatch ? tsMatch[1] : log.timestamp; - const level = (log.level || "info").toLowerCase(); - const levelClass = `log-level-${level}`; - const levelLabel = `[${level.toUpperCase()}]`; - const source = log.source === "curator" ? "buzz-curator" : "buzz-dav"; - const messageText = `${source} ${ts} [${level.toUpperCase()}] ${log.message}`; - const escapedMessage = escapeHtml(log.message); - const escapedCopyText = escapeHtml(messageText); - return ` -
-
- ${source} - ${ts} - ${levelLabel} - ${escapedMessage} -
- -
`; - }) - .join(""); - - container.scrollTop = container.scrollHeight; - } catch (err) { - console.error("Failed to poll logs:", err); - } -} - -document.addEventListener("click", async (event) => { - const entry = event.target.closest(".log-entry[data-copy-text]"); - if (!entry) { - return; - } - - const text = entry.dataset.copyText; - if (!text) { - return; - } - - await copyToClipboard(text); -}); - -async function pollStatus() { - const navLogs = getBuzzElement(buzzPageConfig.navLogsId); - - try { - const res = await fetch("/healthz"); - if (!res.ok) { - throw new Error("Offline"); - } - - const data = await res.json(); - const uiState = zookeeper.merge({ - logCount: data.log_count || 0, - archiveCount: data.archive_count || 0, - lastSyncAt: data.last_sync_at || "never", - syncInProgress: Boolean(data.sync_in_progress), - }); - zookeeper.apply(uiState); - if (navLogs) { - const logCount = uiState.logCount || 0; - - const isLogsPage = window.location.pathname === "/logs"; - - if (isLogsPage) { - localStorage.setItem("buzz_seen_logs", logCount.toString()); - localStorage.setItem("buzz_logs_glow", "false"); - navLogs.classList.remove("nav-logs-new"); - } else { - const seenLogs = parseInt(localStorage.getItem("buzz_seen_logs") || "0"); - if (logCount > seenLogs) { - localStorage.setItem("buzz_logs_glow", "true"); - } - - if (localStorage.getItem("buzz_logs_glow") === "true") { - navLogs.classList.add("nav-logs-new"); - } else { - navLogs.classList.remove("nav-logs-new"); - } - } - } - setReadyLabel(data.snapshot_loaded, false); - } catch (err) { - setReadyLabel(false, true); - } -} - -function initializeReadyLabel() { - const statusReady = getBuzzElement(buzzPageConfig.statusReadyId); - if (!statusReady) { - return; - } - - setReadyLabel(statusReady.innerText === "true", false); -} - -function initBuzzPage(config) { - buzzPageConfig = { - ...buzzPageConfig, - ...config, - }; - - zookeeper.hydrate(); - initializeReadyLabel(); - - if (buzzPageConfig.pollIntervalMs > 0) { - pollStatus(); - setInterval(pollStatus, buzzPageConfig.pollIntervalMs); - - - - // Initial log fetch - pollLogs(); - // Log polling - setInterval(() => { - const autoRefresh = document.getElementById("auto-refresh-logs"); - if (autoRefresh && autoRefresh.checked) { - pollLogs(); - } - }, buzzPageConfig.pollIntervalMs); - } -} - -function sortTable(n) { - const table = getBuzzElement(buzzPageConfig.tableId); - if (!table) { - return; - } - - const headers = table.getElementsByTagName("th"); - let dir = "asc"; - for (let h = 0; h < headers.length; h++) { - if (h === n) { - if (headers[h].classList.contains("sort-asc")) { - headers[h].classList.replace("sort-asc", "sort-desc"); - dir = "desc"; - } else if (headers[h].classList.contains("sort-desc")) { - headers[h].classList.replace("sort-desc", "sort-asc"); - dir = "asc"; - } else { - headers[h].classList.add("sort-asc"); - dir = "asc"; - } - } else { - headers[h].classList.remove("sort-asc", "sort-desc"); - } - } - - const tbody = table.tBodies[0]; - if (!tbody) { - return; - } - - const rows = Array.from(tbody.rows); - const rowData = rows.map((row, index) => { - const cell = row.cells[n]; - const rawValue = cell - ? cell.getAttribute("data-value") || cell.textContent || "" - : ""; - const trimmed = rawValue.trim(); - const numValue = trimmed === "" ? Number.NaN : Number(trimmed); - return { - row, - index, - value: Number.isNaN(numValue) ? trimmed.toLowerCase() : numValue, - isNumber: !Number.isNaN(numValue), - }; - }); - - rowData.sort((a, b) => { - let result; - if (a.isNumber && b.isNumber) { - result = a.value - b.value; - } else { - result = String(a.value).localeCompare(String(b.value)); - } - - if (result === 0) { - result = a.index - b.index; - } - - return dir === "asc" ? result : -result; - }); - - const fragment = document.createDocumentFragment(); - rowData.forEach(entry => { - fragment.appendChild(entry.row); - }); - tbody.appendChild(fragment); -} - +const buzzTableId = "torrent-table"; +let _truncObserver = null; function markTruncatedCells() { document.querySelectorAll(".trunc-cell").forEach((cell) => { @@ -478,135 +15,20 @@ function markTruncatedCells() { function initTruncCells() { markTruncatedCells(); - const table = getBuzzElement(buzzPageConfig.tableId); + const table = document.getElementById(buzzTableId); if (!table || typeof ResizeObserver === "undefined") return; - new ResizeObserver(markTruncatedCells).observe(table); -} - -function fitTableToViewport() { - const el = document.getElementById("torrent-table-container"); - if (!el) return; - const top = el.getBoundingClientRect().top; - const bottomPadding = 20; - el.style.height = (window.innerHeight - top - bottomPadding) + "px"; -} - -function initTableFit() { - fitTableToViewport(); - window.addEventListener("resize", fitTableToViewport); -} - - -function buildNestedValue(obj, path, value) { - const keys = path.split("."); - let current = obj; - for (let i = 0; i < keys.length - 1; i++) { - const key = keys[i]; - if (!(key in current)) { - current[key] = {}; - } - current = current[key]; + if (_truncObserver) { + _truncObserver.disconnect(); } - current[keys[keys.length - 1]] = value; -} - -async function copyEffectiveConfig() { - const code = document.querySelector("#effective-config-section .config-yaml"); - if (!code) return; - await copyToClipboard(code.innerText, "config copied to clipboard!"); + _truncObserver = new ResizeObserver(markTruncatedCells); + _truncObserver.observe(table); } -function toggleEditOverrides() { - const effectiveSection = document.getElementById("effective-config-section"); - const editSection = document.getElementById("edit-overrides-section"); - if (!effectiveSection || !editSection) return; - - const isEditing = !editSection.classList.contains("hidden"); - if (isEditing) { - editSection.classList.add("hidden"); - effectiveSection.classList.remove("hidden"); - } else { - editSection.classList.remove("hidden"); - effectiveSection.classList.add("hidden"); +function initTableIfPresent() { + if (document.getElementById(buzzTableId)) { + initTruncCells(); } } -function filterLanguages(query) { - const list = document.getElementById("lang-list"); - if (!list) return; - const term = query.toLowerCase().trim(); - list.querySelectorAll(".lang-item").forEach((item) => { - const name = item.dataset.langName || ""; - const code = item.dataset.langCode || ""; - if (!term || name.includes(term) || code.includes(term)) { - item.classList.remove("hidden"); - } else { - item.classList.add("hidden"); - } - }); -} - -async function saveConfig() { - const form = document.getElementById("config-form"); - if (!form) return; - - const overrides = {}; - const textInputs = form.querySelectorAll("input[type=\"text\"], input[type=\"number\"], textarea, select"); - const checkboxes = form.querySelectorAll("input[type=\"checkbox\"]"); - - textInputs.forEach((el) => { - if (!el.name) return; - let value = el.value; - if (el.type === "number") { - value = el.value.includes(".") ? parseFloat(el.value) : parseInt(el.value, 10); - if (Number.isNaN(value)) value = 0; - } else if (el.tagName.toLowerCase() === "textarea") { - value = el.value.split("\n").map((s) => s.trim()).filter((s) => s !== ""); - } - buildNestedValue(overrides, el.name, value); - }); - - const langValues = []; - checkboxes.forEach((el) => { - if (!el.name) return; - if (el.name === "subtitles.languages") { - if (el.checked) langValues.push(el.value); - return; - } - buildNestedValue(overrides, el.name, el.checked); - }); - - if (langValues.length > 0) { - buildNestedValue(overrides, "subtitles.languages", langValues); - } - - const consoleMsg = document.getElementById("meta-console-msg"); - if (consoleMsg) { - consoleMsg.innerText = "saving..."; - consoleMsg.className = "service-status-orange"; - } - - try { - const res = await fetch("/api/config", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ overrides }), - }); - const data = await res.json(); - if (data.error) { - throw new Error(data.error); - } - window.scrollTo(0, 0); - const banner = document.getElementById("restart-banner"); - if (banner) banner.classList.remove("hidden"); - if (consoleMsg) { - consoleMsg.innerText = "saved."; - consoleMsg.className = "service-status-green"; - } - } catch (err) { - if (consoleMsg) { - consoleMsg.innerText = "save failed: " + err.message; - consoleMsg.className = "service-status-red"; - } - } -} \ No newline at end of file +document.addEventListener("DOMContentLoaded", initTableIfPresent); +window.addEventListener("phx:navigate", initTableIfPresent); diff --git a/buzz/static/pyview_helpers.js b/buzz/static/pyview_helpers.js new file mode 100644 index 0000000..c67e231 --- /dev/null +++ b/buzz/static/pyview_helpers.js @@ -0,0 +1,150 @@ +async function buzzCopyToClipboard(text, successMsg = "copied to clipboard!") { + const consoleMsg = document.getElementById("meta-console-msg"); + + function fallbackCopy(value) { + const element = document.createElement("textarea"); + element.value = value; + element.style.cssText = "position:fixed;opacity:0"; + document.body.appendChild(element); + element.select(); + const copied = document.execCommand("copy"); + document.body.removeChild(element); + if (!copied) { + throw new Error("execCommand copy failed"); + } + } + + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + } else { + fallbackCopy(text); + } + if (consoleMsg) { + consoleMsg.innerText = successMsg; + consoleMsg.className = "service-status-green"; + } + } catch (_error) { + if (consoleMsg) { + consoleMsg.innerText = "failed to copy."; + consoleMsg.className = "service-status-red"; + } + } +} + +async function buzzCopyTextById(elementId, successMsg) { + const element = document.getElementById(elementId); + if (!element) { + return; + } + await buzzCopyToClipboard(element.innerText, successMsg); +} + +async function buzzCopyVisibleLogs() { + const entries = document.querySelectorAll(".log-entry[data-copy-text]"); + const text = Array.from(entries) + .map((entry) => entry.getAttribute("data-copy-text") || "") + .filter((value) => value !== "") + .join("\n"); + + if (text) { + await buzzCopyToClipboard(text, "logs copied to clipboard!"); + } +} + +async function buzzCopyLogLine(button) { + const text = button.getAttribute("data-copy-text"); + if (!text) { + return; + } + await buzzCopyToClipboard(text); +} + +function buzzHighlightYamlElement(root) { + if ( + typeof window === "undefined" || + typeof window.Prism === "undefined" || + typeof window.Prism.highlightElement !== "function" || + !root + ) { + return; + } + + const code = root.matches("code") + ? root + : root.querySelector("code.language-yaml"); + if (code) { + window.Prism.highlightElement(code); + } +} + +if (typeof window !== "undefined") { + const hooks = window.Hooks || {}; + + hooks.BuzzPrismYaml = { + mounted() { + window.requestAnimationFrame(() => { + buzzHighlightYamlElement(this.el); + }); + }, + + updated() { + window.requestAnimationFrame(() => { + buzzHighlightYamlElement(this.el); + }); + }, + }; + + hooks.BuzzLogGlow = { + mounted() { + this._updateGlow(); + this._onClick = () => { + const countSpan = document.getElementById("nav-log-count"); + const logCount = parseInt(countSpan?.innerText || "0", 10); + localStorage.setItem("buzz_seen_logs", String(logCount)); + this._clearGlow(); + }; + this.el.addEventListener("click", this._onClick); + }, + updated() { + this._updateGlow(); + }, + destroyed() { + this.el.removeEventListener("click", this._onClick); + }, + _clearGlow() { + this.el.classList.remove("nav-logs-new-warning", "nav-logs-new-error"); + }, + _setGlow(level) { + this._clearGlow(); + if (level === "error") { + this.el.classList.add("nav-logs-new-error"); + } else if (level === "warning") { + this.el.classList.add("nav-logs-new-warning"); + } + }, + _updateGlow() { + const countSpan = document.getElementById("nav-log-count"); + const logCount = parseInt(countSpan?.innerText || "0", 10); + const currentLevel = this.el.dataset.logLevel || "info"; + const isLogsPage = window.location.pathname === "/logs"; + if (isLogsPage) { + localStorage.setItem("buzz_seen_logs", String(logCount)); + this._clearGlow(); + return; + } + const seenLogs = parseInt( + localStorage.getItem("buzz_seen_logs") || "0", 10 + ); + const priority = { error: 3, warning: 2, info: 1, debug: 0 }; + const currentP = priority[currentLevel] || 0; + if (logCount > seenLogs && currentP >= 2) { + this._setGlow(currentLevel); + } else { + this._clearGlow(); + } + }, + }; + + window.Hooks = hooks; +} diff --git a/buzz/templates/archive.html b/buzz/templates/archive.html deleted file mode 100644 index 7bfc91a..0000000 --- a/buzz/templates/archive.html +++ /dev/null @@ -1,184 +0,0 @@ - - - - - - - buzz: archive - - - - - - -
-
- buzz: - -
- -
-
-
[cache] {{ torrents_count }}
-
[last_sync] {{ last_sync_at }}
-
[state] {{ sync_state }}
-
- [ready] - -
-
-
- console: -
-
- - {% if last_error %} -
[ERROR] {{ last_error }}
- {% endif %} - -
- - - - - - - - - - - - {% for torrent in archive_items %} - - - - - - - - {% else %} - - - - {% endfor %} - -
NameSizeFilesDate RemovedAct
-
{{ torrent.name }}
-
- {{ torrent.name }} -
-
{{ torrent.size }}{{ torrent.file_count }}{{ torrent.deleted_at }} -
-
-
[Y] -
-
[N]
-
- -
-
[Y]
-
[N]
-
- -
-
- [R] -
-
[D] -
-
-
-
Archive is empty.
-
-
- - - - - - diff --git a/buzz/templates/cache.html b/buzz/templates/cache.html deleted file mode 100644 index ff7cc38..0000000 --- a/buzz/templates/cache.html +++ /dev/null @@ -1,448 +0,0 @@ - - - - - - - buzz: cache - - - - - - -
-
- buzz: - -
- -
-
-
[cache] {{ torrents_count }}
-
[last_sync] {{ last_sync_at }}
-
[state] {{ sync_state }}
-
- [ready] - -
-
-
- console: - -
- -
- - {% if last_error %} -
[ERROR] {{ last_error }}
- {% endif %} - -
-
- ADD NEW MAGNET TO THE CACHE: -
- -
-
-
- -
- -
-
-
- -
- -
-
- -
-
- - - -
-
-
- - -
-
-
- -
- - - - - - - - - - - - - - - - {% for torrent in cache_items %} - - - - - - - - - - - {% else %} - - - - {% endfor %} - -
NameStatusProgSizeFilesEndedIDAct
-
{{ torrent.name }}
-
- {{ torrent.name }} -
-
[{{ torrent.status - }}]{{ torrent.progress }}%{{ torrent.size }}{{ torrent.selected_files }}{{ torrent.ended }}{{ torrent.short_id }} -
- {% if subtitle_enabled %} -
[S]
- {% endif %} -
-
[Y]
-
[N]
-
-
[X]
-
-
No cached items yet. Wait for the first sync or trigger POST - /sync.
-
-
- - - - - - diff --git a/buzz/templates/config.html b/buzz/templates/config.html deleted file mode 100644 index cf88008..0000000 --- a/buzz/templates/config.html +++ /dev/null @@ -1,273 +0,0 @@ - - - - - - - buzz: config - - - - - - - -
-
- buzz: - -
- -
-
-
[cache] {{ torrents_count }}
-
[last_sync] {{ last_sync_at - }}
-
[state] {{ sync_state }} -
-
- [ready] - -
-
-
- console: -
-
- - {% if last_error %} -
[ERROR] {{ last_error }}
- {% endif %} - -
-
-

Effective Configuration

- -
-
-
-
{{ effective_yaml }}
-
- -
-
- - -
- - - - - - - diff --git a/buzz/templates/logs.html b/buzz/templates/logs.html deleted file mode 100644 index d89766c..0000000 --- a/buzz/templates/logs.html +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - buzz: system logs - - - - - - -
-
- buzz: - -
- -
-
-
[cache] {{ torrents_count }}
-
[last_sync] {{ last_sync_at }}
-
[state] {{ sync_state }}
-
- [ready] - -
-
-
- console: -
-
- -
-
-

System Logs

-
- - - - -
-
-
-
-
- Loading logs... -
-
-
-
-
- - - - - - diff --git a/buzz/ui_live.py b/buzz/ui_live.py new file mode 100644 index 0000000..eb66e10 --- /dev/null +++ b/buzz/ui_live.py @@ -0,0 +1,1089 @@ +"""PyView-backed operator pages for the Buzz management UI.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, TypedDict, cast + +import yaml +from markupsafe import Markup +from pyview import ( + ConnectedLiveViewSocket, + LiveView, + LiveViewSocket, + PyView, + is_connected, +) +from pyview.events import InfoEvent, info +from pyview.template import LiveRender, RenderedContent, template_file + +from .core.utils import format_bytes +from .models import mask_secrets, save_overrides, to_nested_dict + +_TEMPLATE_DIR = Path(__file__).with_name("pyview_templates") + +_CONFIG_BOOL_FIELDS = ( + "compat.enable_all_dir", + "compat.enable_unplayable_dir", + "logging.verbose", + "subtitles.enabled", + "subtitles.fetch_on_resync", + "subtitles.filters.exclude_ai", + "subtitles.filters.exclude_machine", +) +_CONFIG_NUMBER_FIELDS = ( + "poll_interval_secs", + "server.port", + "server.stream_buffer_size", + "hooks.rd_update_delay_secs", + "request_timeout_secs", + "ui.poll_interval_secs", + "subtitles.search_delay_secs", + "subtitles.download_delay_secs", +) + + +class PageItem(TypedDict): + label: str + value: str + + +class PageNav(TypedDict): + archive_count: int + cache_active: bool + archive_active: bool + logs_active: bool + config_active: bool + log_count: int + log_level: str + + +class PageContext(TypedDict): + console_class: str + console_msg: str + has_error: bool + is_ready: bool + last_error: str + meta_items: list[PageItem] + nav: PageNav + + +class CacheFileItem(TypedDict): + id: str + path: str + bytes: int + size: str + is_video: bool + selected: bool + + +class CacheAnalysisResult(TypedDict): + torrent_id: str + filename: str + files: list[CacheFileItem] + + +class CacheTorrentItem(TypedDict): + id: str + name: str + status: str + progress: int + bytes: int + size: str + selected_files: int + links: int + ended: str + short_id: str + + +class ArchiveItem(TypedDict): + bytes: int + deleted_at: str + file_count: int + hash: str + name: str + size: str + + +class CacheContext(PageContext): + analysis_error: str + analysis_results: list[CacheAnalysisResult] + analyzing: bool + caching: bool + confirm_delete_id: str | None + has_multiple_analysis_results: bool + has_torrents: bool + magnet_inputs: list[str] + show_overlay: bool + sort_col: int + sort_dir: str + subtitle_enabled: bool + torrents: list[CacheTorrentItem] + + +class ArchiveContext(PageContext): + archive_items: list[ArchiveItem] + confirm_delete_hash: str | None + confirm_restore_hash: str | None + has_items: bool + + +class LogItem(TypedDict): + copy_text: str + level: str + level_class: str + level_label: str + message: str + source: str + timestamp: str + + +class LogsContext(PageContext): + auto_refresh: bool + confirm_restart: bool + log_items: list[LogItem] + logs_loaded: bool + + +class ConfigLanguage(TypedDict): + checked: bool + code: str + name: str + + +class ConfigValues(TypedDict): + anime_patterns: str + bind: str + curator_url: str + download_delay_secs: int + enable_all_dir: bool + enable_unplayable_dir: bool + exclude_ai: bool + exclude_machine: bool + fetch_on_resync: bool + hearing_impaired: str + poll_interval_secs: int + port: int + on_library_change: str + request_timeout_secs: int + rd_update_delay_secs: int + search_delay_secs: int + stream_buffer_size: int + strategy: str + subtitles_enabled: bool + ui_poll_interval_secs: int + user_agent: str + verbose: bool + version_label: str + + +class ConfigContext(PageContext): + effective_yaml: str + is_editing: bool + language_query: str + languages: list[ConfigLanguage] + restart_required: bool + values: ConfigValues + + +def _load_template(name: str) -> Any: + template = template_file(str(_TEMPLATE_DIR / name)) + if template is None: + raise FileNotFoundError(name) + return template + + +def _build_root_template() -> Any: + favicon = ( + "data:image/svg+xml," + "%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 " + "viewBox=%220 0 100 100%22%3E%3Ctext y=%22.9em%22 " + "font-size=%2290%22%3E🐝%3C/text%3E%3C/svg%3E" + ) + + def render(context: dict[str, Any]) -> str: + title = context.get("title") or "buzz" + additional_head = "\n".join(context["additional_head_elements"]) + session = context["session"] + return str( + Markup( + f""" + + + + + {title} + + + + + + + + + + {additional_head} + + +
+ {context["content"]} +
+ +""" + ) + ) + + return render + + +def build_ui(owner: Any) -> PyView: + """Build the PyView application mounted into the DAV app.""" + app = PyView() + app.rootTemplate = _build_root_template() + app.add_live_view("/", lambda: CacheLiveView(owner)) + app.add_live_view("/cache", lambda: CacheLiveView(owner)) + app.add_live_view("/archive", lambda: ArchiveLiveView(owner)) + app.add_live_view("/logs", lambda: LogsLiveView(owner)) + app.add_live_view("/config", lambda: ConfigLiveView(owner)) + return app + + +class _BaseBuzzLiveView(LiveView[PageContext]): + page_title = "buzz" + page_name = "cache" + + def _sort_torrents( + self, + torrents: list[CacheTorrentItem], + col: int, + dir: str, + ) -> list[CacheTorrentItem]: + key_funcs = [ + lambda t: t["name"].lower(), + lambda t: t["status"].lower(), + lambda t: t["progress"], + lambda t: t["bytes"], + lambda t: t["selected_files"], + lambda t: t["ended"] or "", + lambda t: t["short_id"].lower(), + ] + if col < 0 or col >= len(key_funcs): + return torrents + reverse = dir == "desc" + return sorted(torrents, key=key_funcs[col], reverse=reverse) + + def __init__(self, owner: Any) -> None: + self.owner = owner + + def _nav(self) -> PageNav: + return { + "archive_count": len(self.owner.state.trashcan), + "cache_active": self.page_name == "cache", + "archive_active": self.page_name == "archive", + "logs_active": self.page_name == "logs", + "config_active": self.page_name == "config", + "log_count": self.owner.log_count(), + "log_level": self._highest_log_level(), + } + + def _highest_log_level(self) -> str: + from .core.events import registry + + logs = registry.get_recent(limit=50) + priority = {"error": 3, "warning": 2, "info": 1, "debug": 0} + highest = priority.get(self.owner._curator_log_level, 0) + for log in logs: + level = str(log.get("level", "info")).lower() + highest = max(highest, priority.get(level, 0)) + for level, p in priority.items(): + if p == highest: + return level + return "info" + + def _meta_items(self) -> list[PageItem]: + status = self.owner.state.status() + sync_state = "syncing" if status.get("sync_in_progress") else "idle" + return [ + {"label": "cache", "value": str(len(self.owner.state.torrents()))}, + { + "label": "last_sync", + "value": status.get("last_sync_at") or "never", + }, + {"label": "state", "value": sync_state}, + ] + + def _base_context( + self, + console_msg: str = "", + console_class: str = "", + ) -> PageContext: + status = self.owner.state.status() + context: PageContext = { + "console_class": console_class, + "console_msg": console_msg, + "has_error": bool(status.get("last_error")), + "is_ready": self.owner.state.is_ready(), + "last_error": status.get("last_error") or "", + "meta_items": self._meta_items(), + "nav": self._nav(), + } + return context + + async def mount( + self, + socket: LiveViewSocket[PageContext], + _session: dict[str, Any], + ) -> None: + socket.live_title = self.page_title + if is_connected(socket): + await socket.subscribe("buzz:status") + + @info("buzz:status") + async def handle_status(self, _event: InfoEvent, _socket: LiveViewSocket[PageContext]) -> None: + """Re-render nav when curator sends a status update.""" + pass + + +class CacheLiveView(_BaseBuzzLiveView): + page_name = "cache" + page_title = "buzz: cache" + + async def mount( + self, + socket: LiveViewSocket[CacheContext], + session: dict[str, Any], + ) -> None: + await super().mount(socket, session) + socket.context = self._context() + if is_connected(socket): + await socket.subscribe("buzz:status") + await socket.subscribe("buzz:archive") + + async def handle_event( + self, + event: str, + socket: ConnectedLiveViewSocket[CacheContext], + payload: dict[str, Any] | None = None, + to: str = "", + hash: str = "", + index: str = "", + torrent_name: str = "", + torrent_id: str = "", + file_id: str = "", + mode: str = "", + col: str = "", + ) -> None: + if event == "navigate": + await socket.push_navigate(to) + return + if event == "prompt_delete": + socket.context["confirm_delete_id"] = hash + return + if event == "cancel_delete": + socket.context["confirm_delete_id"] = None + return + if event == "delete": + try: + self.owner.state.delete_torrent(hash) + socket.context = self._context( + console_msg="item moved to archive", + console_class="service-status-green", + confirm_delete_id=None, + magnet_inputs=socket.context["magnet_inputs"], + analysis_results=socket.context["analysis_results"], + analysis_error=socket.context["analysis_error"], + analyzing=socket.context["analyzing"], + caching=socket.context["caching"], + sort_col=socket.context["sort_col"], + sort_dir=socket.context["sort_dir"], + ) + except Exception as exc: + socket.context["console_msg"] = f"delete failed: {exc}" + socket.context["console_class"] = "service-status-red" + return + if event == "fetch_subs": + result = self.owner.fetch_subtitles(torrent_name) + if result.get("error"): + socket.context["console_msg"] = ( + f"subs fetch failed: {result['error']}" + ) + socket.context["console_class"] = "service-status-red" + else: + socket.context["console_msg"] = ( + f"subs fetch triggered for: {torrent_name}" + ) + socket.context["console_class"] = "service-status-green" + return + if event == "resync": + socket.context["console_msg"] = "resyncing library..." + socket.context["console_class"] = "service-status-orange" + try: + self.owner.state.manual_rebuild() + socket.context["console_msg"] = "library resynced!" + socket.context["console_class"] = "service-status-green" + except Exception as exc: + socket.context["console_msg"] = f"resync failed: {exc}" + socket.context["console_class"] = "service-status-red" + return + if event == "add_magnet_input": + socket.context["magnet_inputs"].append("") + return + if event == "remove_magnet_input": + try: + idx = int(index) - 1 + if 0 <= idx < len(socket.context["magnet_inputs"]): + socket.context["magnet_inputs"].pop(idx) + except ValueError: + pass + return + if event == "update_magnets": + raw = (payload or {}).get("magnet", []) + if isinstance(raw, str): + raw = [raw] + socket.context["magnet_inputs"] = [str(v) for v in raw] + return + if event == "analyze": + raw = (payload or {}).get("magnet", []) + if isinstance(raw, str): + raw = [raw] + magnets = [str(m).strip() for m in raw if str(m).strip()] + if not magnets: + return + socket.context["analyzing"] = True + socket.context["analysis_error"] = "" + results: list[CacheAnalysisResult] = [] + errors: list[str] = [] + import re + + for magnet in magnets: + try: + info = self.owner.state.add_magnet(magnet) + files: list[CacheFileItem] = [] + for f in info.get("files", []): + path = str(f.get("path", "")) + is_video = bool( + re.search(r"\.(mkv|mp4|avi|m4v|mov)$", path, re.I) + ) + b = int(f.get("bytes", 0)) + files.append( + { + "id": str(f.get("id", "")), + "path": path, + "bytes": b, + "size": format_bytes(b), + "is_video": is_video, + "selected": is_video, + } + ) + results.append( + { + "torrent_id": str(info["id"]), + "filename": str(info.get("filename") or "Torrent Files"), + "files": files, + } + ) + except Exception as exc: + errors.append(str(exc)) + socket.context["analyzing"] = False + socket.context["analysis_results"] = results + if errors: + socket.context["analysis_error"] = f"Failed: {', '.join(errors)}" + socket.context["console_msg"] = ( + f"Resolved {len(results)} magnets, {len(errors)} failed." + ) + socket.context["console_class"] = "ready-label-orange" + else: + socket.context["console_msg"] = ( + f"Resolved {len(results)} magnet(s)." + if len(results) != 1 + else "Ready to cache." + ) + socket.context["console_class"] = "ready-label-green" + return + if event == "select_files": + for result in socket.context["analysis_results"]: + for file in result["files"]: + if mode == "all": + file["selected"] = True + elif mode == "none": + file["selected"] = False + elif mode == "video": + file["selected"] = file["is_video"] + return + if event == "toggle_file": + for result in socket.context["analysis_results"]: + if result["torrent_id"] == torrent_id: + for file in result["files"]: + if file["id"] == file_id: + file["selected"] = not file["selected"] + break + return + if event == "confirm_cache": + socket.context["caching"] = True + try: + for result in socket.context["analysis_results"]: + selected = [ + f["id"] for f in result["files"] if f["selected"] + ] + if selected: + self.owner.state.select_files( + result["torrent_id"], selected + ) + self.owner.state.sync() + socket.context = self._context( + console_msg="Items added and synced.", + console_class="service-status-green", + confirm_delete_id=socket.context["confirm_delete_id"], + sort_col=socket.context["sort_col"], + sort_dir=socket.context["sort_dir"], + ) + except Exception as exc: + socket.context["caching"] = False + socket.context["console_msg"] = f"Error: {exc}" + socket.context["console_class"] = "service-status-red" + return + if event == "cancel_cache": + socket.context = self._context( + console_msg=socket.context["console_msg"], + console_class=socket.context["console_class"], + confirm_delete_id=socket.context["confirm_delete_id"], + magnet_inputs=socket.context["magnet_inputs"], + sort_col=socket.context["sort_col"], + sort_dir=socket.context["sort_dir"], + ) + return + if event == "sort": + try: + new_col = int(col) + except ValueError: + return + if socket.context["sort_col"] == new_col: + socket.context["sort_dir"] = ( + "desc" if socket.context["sort_dir"] == "asc" else "asc" + ) + else: + socket.context["sort_col"] = new_col + socket.context["sort_dir"] = "asc" + return + + async def handle_info( + self, + event: InfoEvent, + socket: ConnectedLiveViewSocket[CacheContext], + ) -> None: + if event.name not in {"buzz:status", "buzz:archive"}: + return + socket.context = self._context( + console_msg=socket.context["console_msg"], + console_class=socket.context["console_class"], + confirm_delete_id=socket.context["confirm_delete_id"], + magnet_inputs=socket.context["magnet_inputs"], + analysis_results=socket.context["analysis_results"], + analysis_error=socket.context["analysis_error"], + analyzing=socket.context["analyzing"], + caching=socket.context["caching"], + sort_col=socket.context["sort_col"], + sort_dir=socket.context["sort_dir"], + ) + + async def render( + self, + assigns: CacheContext, + meta: Any, + ) -> RenderedContent: + return LiveRender(_load_template("cache_live.html"), assigns, meta) + + def _context( + self, + console_msg: str = "", + console_class: str = "", + confirm_delete_id: str | None = None, + magnet_inputs: list[str] | None = None, + analysis_results: list[CacheAnalysisResult] | None = None, + analysis_error: str = "", + analyzing: bool = False, + caching: bool = False, + sort_col: int = 0, + sort_dir: str = "asc", + ) -> CacheContext: + torrents = [] + for torrent in self.owner.state.torrents(): + torrents.append( + { + "id": torrent["id"], + "name": torrent["name"], + "status": torrent["status"], + "progress": torrent["progress"], + "bytes": torrent["bytes"], + "size": format_bytes(torrent["bytes"]), + "selected_files": torrent["selected_files"], + "links": torrent["links"], + "ended": torrent["ended"] or "-", + "short_id": torrent["id"][:8], + } + ) + torrents = self._sort_torrents(torrents, sort_col, sort_dir) + base = self._base_context(console_msg, console_class) + analysis_results = analysis_results or [] + return cast( + CacheContext, + { + **base, + "analysis_error": analysis_error, + "analysis_results": analysis_results, + "analyzing": analyzing, + "caching": caching, + "confirm_delete_id": confirm_delete_id, + "has_multiple_analysis_results": len(analysis_results) > 1, + "has_torrents": bool(torrents), + "magnet_inputs": magnet_inputs or [""], + "show_overlay": analyzing or caching, + "sort_col": sort_col, + "sort_dir": sort_dir, + "subtitle_enabled": self.owner.config.subtitles.enabled, + "torrents": torrents, + }, + ) + + +class ArchiveLiveView(_BaseBuzzLiveView): + page_name = "archive" + page_title = "buzz: archive" + + async def mount( + self, + socket: LiveViewSocket[ArchiveContext], + session: dict[str, Any], + ) -> None: + await super().mount(socket, session) + socket.context = self._context() + if is_connected(socket): + await socket.subscribe("buzz:archive") + await socket.subscribe("buzz:status") + + async def handle_event( + self, + event: str, + socket: ConnectedLiveViewSocket[ArchiveContext], + to: str = "", + hash: str = "", + ) -> None: + if event == "navigate": + await socket.push_navigate(to) + return + if event == "prompt_restore": + socket.context["confirm_restore_hash"] = hash + socket.context["confirm_delete_hash"] = None + return + if event == "cancel_restore": + socket.context["confirm_restore_hash"] = None + return + if event == "prompt_delete": + socket.context["confirm_delete_hash"] = hash + socket.context["confirm_restore_hash"] = None + return + if event == "cancel_delete": + socket.context["confirm_delete_hash"] = None + return + if event == "restore": + self.owner.state.restore_trash(hash) + self.owner.state.sync() + socket.context = self._context( + console_msg="item restored to cache", + console_class="service-status-green", + ) + return + if event == "delete": + self.owner.state.delete_trash_permanently(hash) + socket.context = self._context( + console_msg="archive item deleted", + console_class="service-status-green", + ) + + async def handle_info( + self, + event: InfoEvent, + socket: ConnectedLiveViewSocket[ArchiveContext], + ) -> None: + if event.name not in {"buzz:archive", "buzz:status"}: + return + socket.context = self._context( + console_msg=socket.context["console_msg"], + console_class=socket.context["console_class"], + confirm_delete_hash=socket.context["confirm_delete_hash"], + confirm_restore_hash=socket.context["confirm_restore_hash"], + ) + + async def render( + self, + assigns: ArchiveContext, + meta: Any, + ) -> RenderedContent: + return LiveRender(_load_template("archive_live.html"), assigns, meta) + + def _context( + self, + console_msg: str = "", + console_class: str = "", + confirm_delete_hash: str | None = None, + confirm_restore_hash: str | None = None, + ) -> ArchiveContext: + items = [] + for torrent in self.owner.state.archive_torrents(): + items.append( + { + "bytes": torrent["bytes"], + "deleted_at": torrent["deleted_at"] or "-", + "file_count": torrent["file_count"], + "hash": torrent["hash"], + "name": torrent["name"], + "size": format_bytes(torrent["bytes"]), + } + ) + + base = self._base_context(console_msg, console_class) + return cast( + ArchiveContext, + { + **base, + "archive_items": items, + "confirm_delete_hash": confirm_delete_hash, + "confirm_restore_hash": confirm_restore_hash, + "has_items": bool(items), + }, + ) + + +class LogsLiveView(_BaseBuzzLiveView): + page_name = "logs" + page_title = "buzz: system logs" + + async def mount( + self, + socket: LiveViewSocket[LogsContext], + session: dict[str, Any], + ) -> None: + await super().mount(socket, session) + self.owner._curator_log_level = "info" + socket.context = self._context() + if is_connected(socket): + await socket.subscribe("buzz:status") + if socket.context["auto_refresh"]: + await socket.subscribe("buzz:logs") + + async def handle_event( + self, + event: str, + socket: ConnectedLiveViewSocket[LogsContext], + to: str = "", + ) -> None: + if event == "navigate": + await socket.push_navigate(to) + return + if event == "toggle_auto_refresh": + socket.context["auto_refresh"] = not socket.context["auto_refresh"] + if socket.context["auto_refresh"]: + await socket.subscribe("buzz:logs") + else: + await socket.pub_sub.unsubscribe_topic_async("buzz:logs") + return + if event == "prompt_restart": + socket.context["confirm_restart"] = True + return + if event == "cancel_restart": + socket.context["confirm_restart"] = False + return + if event == "restart": + socket.context["console_msg"] = "restarting service..." + socket.context["console_class"] = "service-status-orange" + self.owner.restart_service() + + async def handle_info( + self, + event: InfoEvent, + socket: ConnectedLiveViewSocket[LogsContext], + ) -> None: + if event.name not in {"buzz:logs", "buzz:status"}: + return + if event.name == "buzz:status" and not socket.context["auto_refresh"]: + base = self._base_context( + socket.context["console_msg"], + socket.context["console_class"], + ) + socket.context = cast( + LogsContext, + { + **base, + "auto_refresh": socket.context["auto_refresh"], + "confirm_restart": socket.context["confirm_restart"], + "log_items": socket.context["log_items"], + "logs_loaded": socket.context["logs_loaded"], + }, + ) + return + socket.context = self._context( + auto_refresh=socket.context["auto_refresh"], + confirm_restart=socket.context["confirm_restart"], + ) + + async def render( + self, + assigns: LogsContext, + meta: Any, + ) -> RenderedContent: + return LiveRender(_load_template("logs_live.html"), assigns, meta) + + def _context( + self, + auto_refresh: bool = True, + confirm_restart: bool = False, + ) -> LogsContext: + base = self._base_context() + return cast( + LogsContext, + { + **base, + "auto_refresh": auto_refresh, + "confirm_restart": confirm_restart, + "log_items": self.owner.formatted_logs(limit=100), + "logs_loaded": True, + }, + ) + + +class ConfigLiveView(_BaseBuzzLiveView): + page_name = "config" + page_title = "buzz: config" + + async def mount( + self, + socket: LiveViewSocket[ConfigContext], + session: dict[str, Any], + ) -> None: + await super().mount(socket, session) + socket.context = self._context() + if is_connected(socket): + await socket.subscribe("buzz:status") + await socket.subscribe("buzz:config") + + async def handle_event( + self, + event: str, + socket: ConnectedLiveViewSocket[ConfigContext], + payload: dict[str, Any], + to: str = "", + language_query: str = "", + ) -> None: + if event == "navigate": + await socket.push_navigate(to) + return + if event == "edit": + socket.context["is_editing"] = True + return + if event == "cancel": + socket.context["is_editing"] = False + socket.context["restart_required"] = False + socket.context["console_msg"] = "" + socket.context["console_class"] = "" + return + if event == "filter_languages": + socket.context = self._context( + is_editing=True, + language_query=language_query, + restart_required=socket.context["restart_required"], + console_msg=socket.context["console_msg"], + console_class=socket.context["console_class"], + ) + return + if event != "save": + return + + overrides = _config_overrides_from_payload(payload) + save_overrides(overrides, self.owner.config._overrides_path) + self.owner._notify_ui_change("config") + socket.context = self._context( + is_editing=True, + language_query=socket.context["language_query"], + restart_required=True, + console_msg="saved.", + console_class="service-status-green", + ) + + async def handle_info( + self, + event: InfoEvent, + socket: ConnectedLiveViewSocket[ConfigContext], + ) -> None: + if event.name not in {"buzz:status", "buzz:config"}: + return + socket.context = self._context( + is_editing=socket.context["is_editing"], + language_query=socket.context["language_query"], + restart_required=socket.context["restart_required"], + console_msg=socket.context["console_msg"], + console_class=socket.context["console_class"], + ) + + async def render( + self, + assigns: ConfigContext, + meta: Any, + ) -> RenderedContent: + return LiveRender(_load_template("config_live.html"), assigns, meta) + + def _context( + self, + is_editing: bool = False, + language_query: str = "", + restart_required: bool = False, + console_msg: str = "", + console_class: str = "", + ) -> ConfigContext: + base = self._base_context(console_msg, console_class) + effective = to_nested_dict(self.owner.config) + masked = mask_secrets(effective) + effective_yaml = yaml.safe_dump( + masked, + default_flow_style=False, + sort_keys=False, + ) + values = _config_values(self.owner.config) + languages = _language_rows( + self.owner.opensubtitles_languages, + self.owner.config.subtitles.languages, + language_query, + ) + return cast( + ConfigContext, + { + **base, + "effective_yaml": effective_yaml, + "is_editing": is_editing, + "language_query": language_query, + "languages": languages, + "restart_required": restart_required, + "values": values, + }, + ) + + +def _language_rows( + languages: list[tuple[str, str]], + selected_codes: tuple[str, ...], + query: str, +) -> list[ConfigLanguage]: + selected = set(selected_codes) + term = query.strip().lower() + ordered = sorted( + languages or [], + key=lambda item: (item[0] not in selected, item[1].lower()), + ) + rows = [] + for code, name in ordered: + normalized_name = name.lower() + normalized_code = code.lower() + if term and term not in normalized_name and term not in normalized_code: + continue + rows.append( + {"checked": code in selected, "code": code, "name": name} + ) + return rows + + +def _config_values(config: Any) -> ConfigValues: + return { + "anime_patterns": "\n".join(config.anime_patterns), + "bind": config.bind, + "curator_url": config.curator_url, + "download_delay_secs": config.subtitles.download_delay_secs, + "enable_all_dir": config.enable_all_dir, + "enable_unplayable_dir": config.enable_unplayable_dir, + "exclude_ai": config.subtitles.filters.exclude_ai, + "exclude_machine": config.subtitles.filters.exclude_machine, + "fetch_on_resync": config.subtitles.fetch_on_resync, + "hearing_impaired": config.subtitles.filters.hearing_impaired, + "on_library_change": config.hook_command, + "poll_interval_secs": config.poll_interval_secs, + "port": config.port, + "request_timeout_secs": config.request_timeout_secs, + "rd_update_delay_secs": config.rd_update_delay_secs, + "search_delay_secs": config.subtitles.search_delay_secs, + "stream_buffer_size": config.stream_buffer_size, + "strategy": config.subtitles.strategy, + "subtitles_enabled": config.subtitles.enabled, + "ui_poll_interval_secs": config.ui_poll_interval_secs, + "user_agent": config.user_agent, + "verbose": config.verbose, + "version_label": config.version_label, + } + + +def _config_overrides_from_payload( + payload: dict[str, Any], +) -> dict[str, Any]: + overrides: dict[str, Any] = {} + normalized = { + key: value if isinstance(value, list) else [value] + for key, value in payload.items() + } + + for field in _CONFIG_NUMBER_FIELDS: + if field in normalized and normalized[field]: + raw_value = normalized[field][0] + value = str(raw_value).strip() + parsed: int | float + if "." in value: + parsed = float(value) + else: + parsed = int(value) + _set_nested_value(overrides, field, parsed) + + for field in _CONFIG_BOOL_FIELDS: + _set_nested_value(overrides, field, field in normalized) + + text_fields = ( + "server.bind", + "hooks.on_library_change", + "hooks.curator_url", + "user_agent", + "version_label", + "subtitles.strategy", + "subtitles.filters.hearing_impaired", + ) + for field in text_fields: + if field in normalized and normalized[field]: + _set_nested_value(overrides, field, str(normalized[field][0])) + + patterns = normalized.get("directories.anime.patterns", [""]) + _set_nested_value( + overrides, + "directories.anime.patterns", + [ + line.strip() + for line in str(patterns[0]).splitlines() + if line.strip() + ], + ) + + languages = [ + str(value) + for value in normalized.get("subtitles.languages", []) + if str(value).strip() + ] + if languages: + _set_nested_value(overrides, "subtitles.languages", languages) + + return overrides + + +def _set_nested_value(target: dict[str, Any], path: str, value: Any) -> None: + keys = path.split(".") + cursor = target + for key in keys[:-1]: + cursor = cast(dict[str, Any], cursor.setdefault(key, {})) + cursor[keys[-1]] = value diff --git a/docs/architecture.md b/docs/architecture.md index c93cb7c..ed79c3c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -122,6 +122,23 @@ sequenceDiagram 2. `buzz-dav` immediately polls the RD API, bypassing the interval. 3. If changes are found, it proceeds with snapshot generation and hook execution immediately. +## Operator UI Integration + +The operator-facing HTML pages now use a split architecture: + +1. FastAPI remains the outer application shell for `/dav`, health checks, and + machine-facing JSON endpoints such as `/api/cache/*`, `/api/config`, and + `/sync`. +2. A small embedded `PyView` application is initialized inside `DavApp` and + its routes are appended into the same ASGI router for `/archive`, `/logs`, + and `/config`. +3. `PyView`'s websocket endpoint lives at `/live/websocket`, and its bundled + frontend asset is mounted separately at `/pyview/assets/app.js` so it does + not collide with Buzz's existing `/static` directory. +4. The live views call the same in-process state and config helpers used by + the REST handlers, which keeps the mutation boundary shallow and makes the + UI layer reversible if `pyview-web` proves to be the wrong fit. + ## Subtitle Integration Buzz integrates directly with OpenSubtitles.com REST API v2 to automatically fetch subtitles for your media. diff --git a/docs/work-items/adopt-pyview.md b/docs/work-items/adopt-pyview.md index 0115713..4ebc46a 100644 --- a/docs/work-items/adopt-pyview.md +++ b/docs/work-items/adopt-pyview.md @@ -2,7 +2,20 @@ ## Status -planned +in progress + +## Progress + +- **Integration spike** — complete; pyview is mounted inside the FastAPI app +- **Archive page migration** — complete +- **Logs page migration** — complete +- **Config page migration** — complete +- **Cache page migration** — complete; add-magnet, file-selection, delete-confirmation, + subtitle-fetch, and server-side sorting are all server-managed +- **Cleanup** — superseded Jinja templates (`buzz/templates/*.html`) and the + `_cache_page()` helper have been removed +- **Tests** — rendering tests updated for all pyview pages; HTTP endpoint tests + preserved ## Outcome @@ -35,6 +48,11 @@ and machine-facing POST endpoints. The adoption boundary is the operator UI. as confirmation prompts, pending mutations, selected torrent rows, filter values, and transient feedback should live in the live-view context instead of being reconstructed from manual DOM mutations and `localStorage`. +- **Require backend-driven live updates over websocket pushes.** Operator + pages should not rely on client polling, heartbeat timers, or browser-owned + refresh loops to discover state changes. When Real-Debrid syncs, archive + contents change, logs append, or config status changes, the server should + push the resulting live-view updates over the `pyview` websocket connection. - **Preserve the current visual language during the migration.** This work item is an interaction-model change, not a redesign. Existing copy, tables, CSS classes, and keyboard-sized actions (`[X]`, `[S]`, `[R]`, `[D]`) should @@ -65,10 +83,19 @@ and machine-facing POST endpoints. The adoption boundary is the operator UI. - keep support for legacy archive rows with `NULL` magnet values - preserve current restore semantics: prefer stored magnet when present, fall back to `magnet:?xt=urn:btih:` + - react to archive changes through server-pushed live-view updates, not + client-side polling - **Logs and config migration**: - replace polling-heavy DOM code with live updates driven through `pyview` + websocket pushes from the backend - keep the same operator-visible controls and status surfaces - ensure config save, restart-required notices, and log filtering still work +- **Live update plumbing**: + - connect Buzz's backend change sources (sync completion, archive mutations, + new log events, config save status) to `pyview` so connected sessions are + updated proactively + - avoid page-local timers, periodic `pushEvent("refresh")` hooks, and + equivalent browser polling loops for operator state refresh - **Cleanup and simplification**: - remove superseded Jinja templates and page-specific inline JavaScript once each page is migrated @@ -90,6 +117,8 @@ and machine-facing POST endpoints. The adoption boundary is the operator UI. live view. - Archive restore/delete, logs inspection, and config editing continue to work with the same operator-facing behavior as before the migration. +- Archive counts, log surfaces, and other operator-visible status views update + in response to backend state changes without requiring browser polling. - The resulting UI code is materially smaller or simpler than the combined Jinja-plus-inline-JS implementation it replaces. - New or updated tests cover the migrated UI behavior, and the Python type diff --git a/pyproject.toml b/pyproject.toml index 64c4b2f..da5c3e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "uvicorn[standard]>=0.27.0", "jinja2>=3.1.0", "httpx>=0.27.0", + "pyview-web>=0.8.3", ] [tool.setuptools] diff --git a/tests/test_buzz.py b/tests/test_buzz.py index 563791a..b265b85 100644 --- a/tests/test_buzz.py +++ b/tests/test_buzz.py @@ -1044,9 +1044,11 @@ def setUp(self): rd_patcher.start() self.dav_app = DavApp(config) self.state = self.dav_app.state - self.client = TestClient(self.dav_app.app) + self.client_cm = TestClient(self.dav_app.app) + self.client = self.client_cm.__enter__() def tearDown(self): + self.client_cm.__exit__(None, None, None) self.tmpdir.cleanup() def test_dav_rel_path_decodes_encoded_names(self): @@ -1087,7 +1089,7 @@ def test_get_and_head_resolve_encoded_file_paths(self): self.assertEqual(node["size"], 2) self.assertEqual(node["content"], "ok") - def test_cache_page_renders_cached_items(self): + def test_cache_page_renders_pyview_shell(self): self.dav_app.config.subtitles.enabled = True self.state.cache = { "torrent-1": { @@ -1111,20 +1113,18 @@ def test_cache_page_renders_cached_items(self): self.assertEqual(response.status_code, 200) self.assertIn("buzz: cache", body) + self.assertIn('data-phx-main="true"', body) + self.assertIn('src="/pyview/assets/app.js"', body) self.assertIn("Movie & Stuff", body) self.assertIn("1.5 MiB", body) - self.assertIn("2026-01-02T00:00:00Z", body) - self.assertIn("status-downloaded", body) self.assertIn('href="/static/buzz.css"', body) - self.assertIn('src="/static/buzz.js"', body) - self.assertIn('id="btn-s-torrent-1"', body) - self.assertIn('document.getElementById("btn-x-" + id).style.display', body) - self.assertIn('const subtitleButton = document.getElementById("btn-s-" + id);', body) - self.assertIn('subtitleButton.style.display = show ? "none" : "flex";', body) + self.assertIn('phx-click="prompt_delete"', body) + self.assertIn('phx-click="fetch_subs"', body) - def test_archive_page_renders_shared_assets(self): + def test_archive_page_renders_pyview_shell(self): self.state.trashcan = { "trash-1": { + "hash": "trash-1", "name": "Old & Gone", "bytes": 4096, "file_count": 3, @@ -1138,13 +1138,15 @@ def test_archive_page_renders_shared_assets(self): self.assertEqual(response.status_code, 200) self.assertIn("buzz: archive", body) + self.assertIn('data-phx-main="true"', body) + self.assertIn('src="/pyview/assets/app.js"', body) self.assertIn("fa-box-archive", body) self.assertIn('id="nav-archive-count"', body) self.assertIn("archive(1)", body) self.assertIn('id="nav-log-count"', body) self.assertIn("Old & Gone", body) self.assertIn('href="/static/buzz.css"', body) - self.assertIn('src="/static/buzz.js"', body) + self.assertIn('phx-click="prompt_restore"', body) def test_cache_page_renders_empty_state_and_error_banner(self): self.state.last_error = "Boom & stuff" @@ -1153,6 +1155,7 @@ def test_cache_page_renders_empty_state_and_error_banner(self): body = response.text self.assertEqual(response.status_code, 200) + self.assertIn('data-phx-main="true"', body) self.assertIn("No cached items yet.", body) self.assertIn("Boom & stuff", body) @@ -1163,12 +1166,40 @@ def test_archive_page_renders_empty_state(self): self.assertEqual(response.status_code, 200) self.assertIn("Archive is empty.", body) + def test_logs_page_renders_pyview_content(self): + response = self.client.get("/logs") + body = response.text + + self.assertEqual(response.status_code, 200) + self.assertIn("buzz: system logs", body) + self.assertIn('src="/pyview/assets/app.js"', body) + self.assertIn("System Logs", body) + self.assertIn("RESTART STACK", body) + self.assertIn("COPY", body) + + def test_config_page_renders_pyview_content(self): + response = self.client.get("/config") + body = response.text + + self.assertEqual(response.status_code, 200) + self.assertIn("buzz: config", body) + self.assertIn('src="/pyview/assets/app.js"', body) + self.assertIn("Effective Configuration", body) + self.assertIn("EDIT", body) + self.assertIn('id="effective-config-code"', body) + def test_static_assets_are_served(self): response = self.client.get("/static/buzz.js") self.assertEqual(response.status_code, 200) - self.assertIn("initBuzzPage", response.text) - self.assertIn("const zookeeper", response.text) + self.assertIn("markTruncatedCells", response.text) + self.assertIn("initTruncCells", response.text) + + def test_pyview_assets_are_served(self): + response = self.client.get("/pyview/assets/app.js") + + self.assertEqual(response.status_code, 200) + self.assertIn("LiveSocket", response.text) def test_healthz_and_readyz_use_asgi_routes(self): self.state.snapshot_loaded = False diff --git a/tests/test_curator_app.py b/tests/test_curator_app.py index 2a8d5c3..2049ed6 100644 --- a/tests/test_curator_app.py +++ b/tests/test_curator_app.py @@ -66,7 +66,7 @@ def test_curator_app_routes_use_fastapi(self): self.assertEqual(health.status_code, 200) self.assertEqual(health.json(), {"status": "ok"}) self.assertEqual(rebuild.status_code, 200) - self.assertEqual(rebuild.json()["movies"], 0) + self.assertEqual(rebuild.json(), {"status": "rebuilding"}) def test_curator_lifespan_runs_startup_build(self): with tempfile.TemporaryDirectory() as tmpdir: @@ -164,7 +164,7 @@ def test_curator_subtitle_fetch_uses_consistent_torrent_name(self): source_path = f"movies/{original_filename}/{filename}" self.assertTrue(_source_matches_torrent(source_path, original_filename)) - def test_curator_rebuild_error_payload_is_preserved(self): + def test_curator_rebuild_returns_immediately(self): with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) (root / "raw" / "movies").mkdir(parents=True) @@ -174,6 +174,20 @@ def test_curator_rebuild_error_payload_is_preserved(self): app = CuratorApp(self._config(root)) client = TestClient(app.app) + response = client.post("/rebuild") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"status": "rebuilding"}) + + def test_curator_rebuild_error_is_logged(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + (root / "raw" / "movies").mkdir(parents=True) + (root / "raw" / "shows").mkdir(parents=True) + (root / "raw" / "anime").mkdir(parents=True) + + app = CuratorApp(self._config(root)) + with patch.object( app.curator, "handle_rebuild", @@ -182,12 +196,11 @@ def test_curator_rebuild_error_payload_is_preserved(self): {"jellyfin_scan_status": "failed", "jellyfin_scan_triggered": False}, ), ): - with patch("sys.stdout", io.StringIO()): - response = client.post("/rebuild") + with patch("sys.stdout", io.StringIO()) as stdout: + app._run_rebuild([]) - self.assertEqual(response.status_code, 500) - self.assertEqual(response.json()["error"], "scan failed") - self.assertEqual(response.json()["jellyfin_scan_status"], "failed") + logged = stdout.getvalue() + self.assertIn("curator rebuild failed: scan failed", logged) def test_rebuild_and_trigger_skips_scan_when_configured(self): with tempfile.TemporaryDirectory() as tmpdir: @@ -376,8 +389,8 @@ def test_rebuild_and_trigger_logs_unexpected_errors(self): with patch("sys.stdout", stdout): response = client.post("/rebuild") - self.assertEqual(response.status_code, 500) - self.assertEqual(response.json()["error"], "boom") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"status": "rebuilding"}) logged = stdout.getvalue() self.assertIn("curator rebuild failed: boom", logged) diff --git a/uv.lock b/uv.lock index 201e109..117fb98 100644 --- a/uv.lock +++ b/uv.lock @@ -32,6 +32,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "apscheduler" +version = "3.11.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, +] + [[package]] name = "buzz" version = "1.0.0" @@ -41,6 +53,7 @@ dependencies = [ { name = "httpx" }, { name = "jinja2" }, { name = "pydantic" }, + { name = "pyview-web" }, { name = "pyyaml" }, { name = "rd-api-py" }, { name = "typing-extensions" }, @@ -60,6 +73,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27.0" }, { name = "jinja2", specifier = ">=3.1.0" }, { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pyview-web", specifier = ">=0.8.3" }, { name = "pyyaml", specifier = ">=6.0.1" }, { name = "rd-api-py", specifier = ">=0.1.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, @@ -230,6 +244,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -402,6 +425,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" }, ] +[[package]] +name = "pyview-web" +version = "0.8.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "apscheduler" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "markupsafe" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/8b/a9b7241b8daeada48ef5adff953358b0712fe3b8f972e359333e87b506fd/pyview_web-0.8.3.tar.gz", hash = "sha256:dbc5e1e6d16ad5a4cb18f9082623e44de72f3ca8dbb83064a2012a68c4836ca3", size = 129032, upload-time = "2026-01-28T02:49:03.096Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/68/7da515f8b7d9d8c6a24d3501da53f77c400c5849defe03af743d3861cf88/pyview_web-0.8.3-py3-none-any.whl", hash = "sha256:3d2014a0f110986fe4dc852fa59572243db8e95e032e835fcf6310e3459f62d0", size = 152853, upload-time = "2026-01-28T02:49:01.409Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -483,14 +524,14 @@ wheels = [ [[package]] name = "starlette" -version = "1.0.0" +version = "0.50.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, + { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, ] [[package]] @@ -514,6 +555,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2026.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -627,3 +689,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +]