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:
+
+
+
+
+
+ {% if has_error %}
+ [ERROR] {{ last_error }}
+ {% endif %}
+
+
+
+
+
+ | Name |
+ Size |
+ Files |
+ Date Removed |
+ Act |
+
+
+
+ {% if has_items %}
+ {% for torrent in archive_items %}
+
+ |
+ {{ torrent.name }}
+
+ {{ torrent.name }}
+
+ |
+ {{ torrent.size }} |
+ {{ torrent.file_count }} |
+
+
+
+ {% if confirm_restore_hash == torrent.hash %}
+
+
+
+
+ {% elif confirm_delete_hash == torrent.hash %}
+
+
+
+
+ {% else %}
+
+
+
+
+ {% endif %}
+
+ |
+
+ {% endfor %}
+ {% else %}
+
+ | Archive is empty. |
+
+ {% endif %}
+
+
+
+
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:
+
+
+
+
+
+ {% if has_error %}
+ [ERROR] {{ last_error }}
+ {% endif %}
+
+
+ {% if not analysis_results %}
+
+
+ {% else %}
+
+
+
+
+
+
+
+ {% for result in analysis_results %}
+ {% if has_multiple_analysis_results %}
+
+ {% endif %}
+ {% for file in result.files %}
+
+
+
+ {{ file.path }}
+ {{ file.size }}
+
+
+ {% endfor %}
+ {% endfor %}
+
+
+
+
+
+ {% endif %}
+
+
+
+
+ processing...
+
+
+
+
+ | Name |
+ Status |
+ Prog |
+ Size |
+ Files |
+ Ended |
+ ID |
+ Act |
+
+
+
+ {% if has_torrents %}
+ {% for torrent in torrents %}
+
+ |
+ {{ torrent.name }}
+
+ {{ torrent.name }}
+
+ |
+
+ [{{ torrent.status }}]
+ |
+ {{ torrent.progress }}% |
+ {{ torrent.size }} |
+ {{ torrent.selected_files }} |
+
+
+ {{ torrent.short_id }}
+ |
+
+
+ {% if subtitle_enabled and confirm_delete_id != torrent.id %}
+
+ {% endif %}
+ {% if confirm_delete_id == torrent.id %}
+
+
+
+
+ {% else %}
+
+
+
+
+
+ {% endif %}
+
+ |
+
+ {% endfor %}
+ {% else %}
+
+
+ No cached items yet. Wait for the first sync or trigger
+ POST /sync.
+ |
+
+ {% endif %}
+
+
+
+
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:
+
+
+
+
+
+ {% if has_error %}
+ [ERROR] {{ last_error }}
+ {% endif %}
+
+ {% if is_editing %}
+
+ {% else %}
+
+ {% 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:
+
+
+
+
+
+ {% if has_error %}
+ [ERROR] {{ last_error }}
+ {% 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:
-
-
-
-
-
- {% if last_error %}
- [ERROR] {{ last_error }}
- {% endif %}
-
-
-
-
-
- | Name |
- Size |
- Files |
- Date Removed |
- Act |
-
-
-
- {% for torrent in archive_items %}
-
- |
- {{ torrent.name }}
-
- {{ torrent.name }}
-
- |
- {{ torrent.size }} |
- {{ torrent.file_count }} |
-
-
-
- |
-
- {% else %}
-
- | Archive is empty. |
-
- {% endfor %}
-
-
-
-
-
-
-
-
-
-
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:
-
-
-
-
-
- {% if last_error %}
- [ERROR] {{ last_error }}
- {% endif %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- processing...
-
-
-
-
- | Name |
- Status |
- Prog |
- Size |
- Files |
- Ended |
- ID |
- Act |
-
-
-
- {% for torrent in cache_items %}
-
- |
- {{ torrent.name }}
-
- {{ torrent.name }}
-
- |
- [{{ torrent.status
- }}] |
- {{ torrent.progress }}% |
- {{ torrent.size }} |
- {{ torrent.selected_files }} |
-
- {{ torrent.short_id }} |
-
-
- {% if subtitle_enabled %}
- [S]
- {% endif %}
-
- [X]
-
- |
-
- {% else %}
-
- No cached items yet. Wait for the first sync or trigger POST
- /sync. |
-
- {% endfor %}
-
-
-
-
-
-
-
-
-
-
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:
-
-
-
-
-
- {% if last_error %}
- [ERROR] {{ last_error }}
- {% endif %}
-
-
-
-
-
-
-
-
-
-
-
-
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:
-
-
-
-
-
-
-
-
-
-
-
-
-
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" },
+]