From 0540ec9e0aef49f3a0b84425b6086c19236c1ebb Mon Sep 17 00:00:00 2001 From: Gabriel Chamon Date: Thu, 16 Apr 2026 17:39:22 -0300 Subject: [PATCH 1/2] feat: implement centralized logging, bulk magnet addition, and UI refinements - VFS visibility: implemented polling loop and configurable wait timeouts for mount readiness - Selective sync: updated Curator to trigger specific Jellyfin library refreshes via JELLYFIN_LIBRARY_MAP - Centralized events: created EventRegistry to aggregate logs from both DAV and Curator processes - Unified Logs UI: added a dedicated /logs page with real-time aggregation and source labeling - Persistent notifications: implemented stateful "orange glow" for new logs using localStorage - Bulk magnet addition: added dynamic input rows and aggregated file selection UI for multiple links - Processing feedback: implemented "processing..." overlay and meta-bar console for real-time status - Robustness: added Jellyfin auth validation and ensured symlink creation is atomic and independent of scan success - CSS refactoring: eliminated inline styles in favor of semantic utility and component classes - Standards: added .htmlhintrc and updated README with linting and development instructions - UX: relocated restart/resync buttons, fixed icon rendering, and removed ready-label blinking - Internal categories: filtered out __unplayable__ from media server hooks and Jellyfin selective scans - Log utility: added per-line copy button in the logs view with hover visibility --- .htmlhintrc | 11 + README.md | 30 +-- buzz/Dockerfile | 8 +- buzz/core/curator.py | 173 +++++++++++-- buzz/core/events.py | 45 ++++ buzz/core/state.py | 126 +++++++-- buzz/curator_app.py | 34 ++- buzz/dav_app.py | 93 ++++++- buzz/models.py | 13 + buzz/static/buzz.css | 485 ++++++++++++++++++++++++++++++++++- buzz/static/buzz.js | 187 +++++++++++--- buzz/templates/logs.html | 69 +++++ buzz/templates/torrents.html | 411 +++++++++++++---------------- buzz/templates/trashcan.html | 57 ++-- tests/test_curator_app.py | 73 +++++- tests/test_events.py | 39 +++ tests/test_vfs_sync.py | 124 +++++++++ 17 files changed, 1554 insertions(+), 424 deletions(-) create mode 100644 .htmlhintrc create mode 100644 buzz/core/events.py create mode 100644 buzz/templates/logs.html create mode 100644 tests/test_events.py create mode 100644 tests/test_vfs_sync.py diff --git a/.htmlhintrc b/.htmlhintrc new file mode 100644 index 0000000..25de9c5 --- /dev/null +++ b/.htmlhintrc @@ -0,0 +1,11 @@ +{ + "style-disabled": true, + "attr-lowercase": true, + "attr-value-double-quotes": true, + "doctype-first": true, + "tag-pair": true, + "tagname-lowercase": true, + "id-unique": true, + "src-not-empty": true, + "attr-no-duplication": true +} diff --git a/README.md b/README.md index 789faf7..2e6a6db 100644 --- a/README.md +++ b/README.md @@ -102,23 +102,13 @@ For a deep dive into how Buzz works, components, and data flow, see the [Archite Source changes take effect immediately after a service restart (`docker compose restart buzz-dav`) without rebuilding the image. - **Isolated Development VM:** You can also deploy an isolated development environment using [Incus](./docs/incus-dev-vm.md). - **Production (Default):** Running `docker compose up -d` uses the stable, immutable code baked into the container image. To rebuild the production image after code changes, use `docker compose up -d --build`. -- Tests live in [tests/test_buzz.py](./tests/test_buzz.py). -- Config migration helper lives in [scripts/migrate_config.py](./scripts/migrate_config.py). - -Convert an old Zurg config into Buzz format with: - -```sh -python3 scripts/migrate_config.py --from zurg --to buzz config.yml -o buzz.yml -``` - -Convert a Buzz config back into a best-effort Zurg-style config with: - -```sh -python3 scripts/migrate_config.py --from buzz --to zurg buzz.yml -o config.yml -``` - -Run tests locally with: - -```sh -uv run python -m unittest discover -s tests -``` +- **Tests:** Run tests locally with: + ```sh + uv run python -m unittest discover -s tests + ``` +- **Linting:** We use `htmlhint` to enforce clean HTML templates and forbid inline styles. A `.htmlhintrc` is provided in the root directory. + ```sh + # Run linting on all templates + npx htmlhint "buzz/templates/*.html" + ``` +- **Config Migration:** Config migration helper lives in [scripts/migrate_config.py](./scripts/migrate_config.py). diff --git a/buzz/Dockerfile b/buzz/Dockerfile index 5c67254..bf8529d 100644 --- a/buzz/Dockerfile +++ b/buzz/Dockerfile @@ -11,11 +11,15 @@ RUN apk add --no-cache \ curl \ libxml2-utils +# Install dependencies first to improve Docker layer caching +COPY pyproject.toml README.md /app/ +RUN mkdir -p /app/buzz/core && touch /app/buzz/__init__.py /app/buzz/core/__init__.py +RUN uv pip install --system . + +# Copy application code and scripts COPY buzz /app/buzz COPY scripts /app/scripts -COPY pyproject.toml /app/ -RUN uv pip install --system . RUN chmod +x /app/scripts/*.sh ENTRYPOINT ["python3", "-m", "buzz"] diff --git a/buzz/core/curator.py b/buzz/core/curator.py index bc67d09..cba27c7 100644 --- a/buzz/core/curator.py +++ b/buzz/core/curator.py @@ -15,6 +15,7 @@ VIDEO_EXTENSIONS, YEAR_RE, ) +from .events import record_event from .media import ( is_sidecar_file, is_video_file, @@ -174,22 +175,16 @@ def mapping_diff(previous: list[dict], current: list[dict]) -> dict: def log_mapping_event(diff: dict, report: dict, mapping_entries: int): - print( - json.dumps( - { - "event": "curator_mapping_diff", - "mapping_entries": mapping_entries, - "movies": report["movies"], - "show_files": report["show_files"], - "anime_files": report["anime_files"], - "added": diff["added"], - "removed": diff["removed"], - "changed": diff["changed"], - }, - sort_keys=True, - separators=(",", ":"), - ), - flush=True, + record_event( + "Curator mapping updated", + event="curator_mapping_diff", + mapping_entries=mapping_entries, + movies=report["movies"], + show_files=report["show_files"], + anime_files=report["anime_files"], + added=diff["added"], + removed=diff["removed"], + changed=diff["changed"], ) @@ -253,8 +248,8 @@ def build_library(config: PresentationConfig): (config.state_root / "report.json").write_text( json.dumps(report, indent=2, sort_keys=True), encoding="utf-8" ) - if config.verbose: - log_mapping_event(mapping_diff(previous_mapping, mapping), report, len(mapping)) + + log_mapping_event(mapping_diff(previous_mapping, mapping), report, len(mapping)) return report @@ -448,14 +443,51 @@ def discover_scan_task_id(config: PresentationConfig) -> str: f"{config.jellyfin_url}/ScheduledTasks?IsHidden=false&IsEnabled=true", headers={"Authorization": f"MediaBrowser Token={config.jellyfin_api_key}"}, ) - with request.urlopen(req, timeout=30) as response: - tasks = json.load(response) + try: + with request.urlopen(req, timeout=30) as response: + tasks = json.load(response) + except error.HTTPError as exc: + if exc.code in (401, 403): + raise RuntimeError("Jellyfin API Token is invalid or unauthorized") from exc + raise for task in tasks: if task.get("Name") == "Scan Media Library": return task.get("Id", "") raise RuntimeError("Unable to find the Jellyfin Scan Media Library task ID.") +def validate_jellyfin_auth(config: PresentationConfig): + """Verifies that the Jellyfin API key is valid.""" + req = request.Request( + f"{config.jellyfin_url}/System/Info", + headers={"Authorization": f"MediaBrowser Token={config.jellyfin_api_key}"}, + ) + try: + with request.urlopen(req, timeout=10): + return True + except error.HTTPError as exc: + if exc.code in (401, 403): + return False + raise + except Exception: + return False + + +def discover_jellyfin_libraries(config: PresentationConfig) -> dict[str, str]: + """Returns a map of library Name -> ItemId.""" + req = request.Request( + f"{config.jellyfin_url}/Library/VirtualFolders", + headers={"Authorization": f"MediaBrowser Token={config.jellyfin_api_key}"}, + ) + with request.urlopen(req, timeout=30) as response: + libraries = json.load(response) + return { + lib.get("Name"): lib.get("ItemId") + for lib in libraries + if lib.get("Name") and lib.get("ItemId") + } + + def trigger_jellyfin_scan(config: PresentationConfig): task_id = discover_scan_task_id(config) req = request.Request( @@ -467,7 +499,74 @@ def trigger_jellyfin_scan(config: PresentationConfig): return -def rebuild_and_trigger(config: PresentationConfig): +def trigger_jellyfin_selective_refresh( + config: PresentationConfig, changed_roots: list[str] +): + if not changed_roots: + return + + categories = {root.split("/")[0] for root in changed_roots if "/" in root} + # Filter out internal/virtual categories like __unplayable__ that shouldn't trigger scans + categories = {cat for cat in categories if cat != "__unplayable__"} + + if not categories: + return + + library_names = { + config.jellyfin_library_map.get(cat) + for cat in categories + if cat in config.jellyfin_library_map + } + library_names = {name for name in library_names if name} + + # If all categories are known but none map to a library (e.g. __unplayable__), + # just skip instead of falling back to a full scan. + if not library_names and all(cat in config.jellyfin_library_map for cat in categories): + record_event( + f"No Jellyfin libraries mapped for categories: {categories}. Skipping refresh.", + level="info", + ) + return + + if not library_names: + record_event( + f"Unknown categories {categories} (not in JELLYFIN_LIBRARY_MAP). Falling back to full scan.", + level="warning", + ) + trigger_jellyfin_scan(config) + return + + libraries = discover_jellyfin_libraries(config) + for name in library_names: + library_id = libraries.get(name) + if not library_id: + record_event( + f"Jellyfin library '{name}' not found. Falling back to full scan.", + level="warning", + ) + trigger_jellyfin_scan(config) + return + + record_event( + f"Triggering selective refresh for Jellyfin library '{name}' ({library_id})...", + level="info", + ) + query = "Recursive=true&ImageRefreshMode=Default&MetadataRefreshMode=Default&ReplaceAllImages=false&ReplaceAllMetadata=false" + req = request.Request( + f"{config.jellyfin_url}/Items/{library_id}/Refresh?{query}", + method="POST", + headers={"Authorization": f"MediaBrowser Token={config.jellyfin_api_key}"}, + ) + try: + with request.urlopen(req, timeout=30): + pass + except Exception as exc: + record_event( + f"Failed to refresh Jellyfin library '{name}': {exc}", level="error" + ) + + +def rebuild_and_trigger(config: PresentationConfig, changed_roots: list[str] = None): report = build_library(config) if config.skip_jellyfin_scan: report["jellyfin_scan_triggered"] = False @@ -479,16 +578,34 @@ def rebuild_and_trigger(config: PresentationConfig): report["jellyfin_scan_status"] = "skipped_missing_auth" report["jellyfin_scan_error"] = None return report + + # Validate auth first to avoid cascading failures + if not validate_jellyfin_auth(config): + msg = "Jellyfin API Token is invalid or unauthorized" + record_event(msg, level="error") + report["jellyfin_scan_triggered"] = False + report["jellyfin_scan_status"] = "failed_auth" + report["jellyfin_scan_error"] = msg + return report + try: - trigger_jellyfin_scan(config) + if changed_roots: + trigger_jellyfin_selective_refresh(config, changed_roots) + report["jellyfin_scan_status"] = "selective_triggered" + else: + trigger_jellyfin_scan(config) + report["jellyfin_scan_status"] = "full_triggered" except Exception as exc: report["jellyfin_scan_triggered"] = False report["jellyfin_scan_status"] = "failed" report["jellyfin_scan_error"] = str(exc) - raise RebuildError(str(exc), report) from exc - report["jellyfin_scan_triggered"] = True - report["jellyfin_scan_status"] = "triggered" - report["jellyfin_scan_error"] = None + # We don't raise RebuildError here anymore to ensure the curator process + # doesn't think the whole rebuild failed just because the scan trigger failed. + # The symlinks (build_library) were already successfully swapped. + record_event(f"Jellyfin scan trigger failed: {exc}", level="error") + else: + report["jellyfin_scan_triggered"] = True + report["jellyfin_scan_error"] = None return report @@ -497,9 +614,9 @@ def __init__(self, config: PresentationConfig): self.config = config self.lock = threading.Lock() - def handle_rebuild(self): + def handle_rebuild(self, changed_roots: list[str] = None): with self.lock: - return rebuild_and_trigger(self.config) + return rebuild_and_trigger(self.config, changed_roots) def cleanup(self): with self.lock: diff --git a/buzz/core/events.py b/buzz/core/events.py new file mode 100644 index 0000000..a8ad2a5 --- /dev/null +++ b/buzz/core/events.py @@ -0,0 +1,45 @@ +import json +import threading +from collections import deque +from typing import Any + +from .utils import utc_now_iso + + +class EventRegistry: + def __init__(self, maxlen: int = 1000, default_source: str = None): + self.events = deque(maxlen=maxlen) + self.lock = threading.Lock() + self.default_source = default_source + + def record(self, message: str, level: str = "info", **extra: Any): + event = { + "timestamp": utc_now_iso(), + "message": message, + "level": level, + "source": extra.get("source") or self.default_source, + **extra, + } + if not event["source"]: + del event["source"] + with self.lock: + self.events.append(event) + + # Also print to stdout for legacy logging and visibility + prefix = f"[{level.upper()}]" if level != "info" else "" + out = f"{prefix} {message}".strip() + if extra: + out += f" {json.dumps(extra, sort_keys=True)}" + print(out, flush=True) + + def get_recent(self, limit: int = 100) -> list[dict]: + with self.lock: + return list(self.events)[-limit:] + + +# Global registry for the process +registry = EventRegistry() + + +def record_event(message: str, level: str = "info", **extra: Any): + registry.record(message, level, **extra) diff --git a/buzz/core/state.py b/buzz/core/state.py index fd60be3..50886dd 100644 --- a/buzz/core/state.py +++ b/buzz/core/state.py @@ -13,6 +13,7 @@ from urllib import error, parse, request from .constants import DEFAULT_ANIME_PATTERN, SHOW_PATTERNS +from .events import record_event from .media import is_video_file from .utils import ( normalize_posix_path, @@ -468,7 +469,7 @@ def _run_hook_task(self) -> None: self.hook_in_progress = False self.hook_last_finished_at = utc_now_iso() except Exception as exc: # noqa: BLE001 - print(f"Hook task failed unexpectedly: {exc}", flush=True) + record_event(f"Hook task failed unexpectedly: {exc}", level="error") with self.hook_lock: self.hook_task_active = False @@ -502,39 +503,114 @@ def _summary_signature(self, summary: dict[str, Any]) -> dict[str, Any]: def _trigger_curator_and_hooks( self, changed_roots: list[str], *, skip_delay: bool = False ) -> None: - if not skip_delay and self.config.rd_update_delay_secs > 0: - self.verbose_log( - f"Waiting {self.config.rd_update_delay_secs}s for Real-Debrid update..." - ) - time.sleep(self.config.rd_update_delay_secs) + if not skip_delay: + if self.config.library_mount and changed_roots: + self._wait_for_vfs_visibility(changed_roots) + elif self.config.rd_update_delay_secs > 0: + self.verbose_log( + f"Waiting {self.config.rd_update_delay_secs}s for Real-Debrid update..." + ) + time.sleep(self.config.rd_update_delay_secs) self._trigger_curator(changed_roots) self._run_hook(changed_roots) + def _wait_for_vfs_visibility(self, roots: list[str]) -> None: + mount = self.config.library_mount + timeout = self.config.vfs_wait_timeout_secs + start_time = time.time() + + # Determine current state of each root in our internal snapshot + with self.lock: + snapshot_roots = set() + for path in self.snapshot.get("files", {}): + root = self._root_for_snapshot_path(path) + if root: + snapshot_roots.add(root) + + to_check = [] + for root in roots: + # We only care about visibility of media roots + if not any(root.startswith(p) for p in ["movies/", "shows/", "anime/"]): + continue + expected = root in snapshot_roots + to_check.append((root, expected)) + + if not to_check: + return + + self.verbose_log( + f"Waiting for VFS visibility of {len(to_check)} roots in {mount} (timeout {timeout}s)..." + ) + + while time.time() - start_time < timeout: + all_visible = True + missing = [] + stale = [] + + for root, expected in to_check: + path = os.path.join(mount, root) + exists = os.path.exists(path) + if expected and not exists: + all_visible = False + missing.append(root) + elif not expected and exists: + all_visible = False + stale.append(root) + + if all_visible: + elapsed = int(time.time() - start_time) + self.verbose_log(f"VFS visibility confirmed after {elapsed}s") + return + + # Periodically log progress if there are many items or we've waited a bit + if int(time.time() - start_time) % 30 == 0: + self.verbose_log( + f"VFS still syncing... (missing: {len(missing)}, stale: {len(stale)})" + ) + + time.sleep(2) + + self.verbose_log( + f"VFS visibility timeout reached after {timeout}s. Proceeding with sync." + ) + def _trigger_curator(self, changed_roots: list[str]) -> None: if not self.config.curator_url: return self.verbose_log(f"Triggering curator rebuild at {self.config.curator_url}...") try: - req = request.Request(self.config.curator_url, method="POST") + payload = {"changed_roots": changed_roots} + data = json.dumps(payload).encode("utf-8") + req = request.Request( + self.config.curator_url, + data=data, + method="POST", + headers={"Content-Type": "application/json"}, + ) with request.urlopen(req, timeout=30) as response: if response.status not in (200, 204): raise ValueError(f"Curator returned HTTP {response.status}") self.verbose_log("Curator rebuild triggered successfully") except Exception as exc: - print(f"Failed to trigger curator rebuild: {exc}", flush=True) + record_event(f"Failed to trigger curator rebuild: {exc}", level="error") def _run_hook(self, changed_roots: list[str]) -> None: if not self.config.hook_command: return + # Filter out internal/virtual categories like __unplayable__ + filtered_roots = [r for r in changed_roots if not r.startswith("__unplayable__")] + if not filtered_roots: + return + self.verbose_log(f"Running library update hook: {self.config.hook_command}...") try: cmd = shlex.split(self.config.hook_command) - cmd.extend(changed_roots) + cmd.extend(filtered_roots) subprocess.run(cmd, check=True, timeout=60) self.verbose_log("Library update hook completed successfully") except Exception as exc: - print(f"Library update hook failed: {exc}", flush=True) + record_event(f"Library update hook failed: {exc}", level="error") def mark_startup_sync_complete(self) -> None: with self.lock: @@ -714,7 +790,7 @@ def restore_trash(self, thash: str) -> dict[str, Any]: try: self.select_files(torrent_id, file_ids) except Exception as exc: - print(f"Failed to auto-select files during restore: {exc}", flush=True) + record_event(f"Failed to auto-select files during restore: {exc}", level="error") with self.lock: if thash in self.trashcan: @@ -760,7 +836,7 @@ def invalidate_download_url(self, source_url: str) -> None: def verbose_log(self, message: str) -> None: if self.config.verbose: - print(f"[{utc_now_iso()}] {message}", flush=True) + record_event(message, level="debug") class Poller(threading.Thread): @@ -774,23 +850,17 @@ def run(self) -> None: try: report = self.state.sync() if report.get("changed"): - print( - json.dumps( - { - "event": "realdebrid_update", - "timestamp": report.get("timestamp"), - "synced_torrents": report.get("synced_torrents"), - "added_paths": report.get("added_paths", []), - "removed_paths": report.get("removed_paths", []), - "updated_paths": report.get("updated_paths", []), - }, - sort_keys=True, - ), - flush=True, + record_event( + "Real-Debrid library changed", + event="realdebrid_update", + synced_torrents=report.get("synced_torrents"), + added_paths=report.get("added_paths", []), + removed_paths=report.get("removed_paths", []), + updated_paths=report.get("updated_paths", []), ) except Exception as exc: # noqa: BLE001 self.state.last_error = str(exc) - print(f"background sync failed: {exc}", flush=True) + record_event(f"background sync failed: {exc}", level="error") def stop(self) -> None: self._stop_event.set() @@ -804,10 +874,10 @@ def __init__(self, state: BuzzState): def run(self) -> None: try: report = self.state.sync(trigger_hook=False) - print(json.dumps({"startup_sync": report}, sort_keys=True), flush=True) + record_event("Startup sync complete", event="startup_sync", report=report) except Exception as exc: # noqa: BLE001 self.state.last_error = str(exc) - print(f"startup sync failed: {exc}", flush=True) + record_event(f"startup sync failed: {exc}", level="error") finally: self.state.mark_startup_sync_complete() diff --git a/buzz/curator_app.py b/buzz/curator_app.py index bf7aaa4..cdfab4a 100644 --- a/buzz/curator_app.py +++ b/buzz/curator_app.py @@ -5,10 +5,14 @@ from fastapi.responses import JSONResponse from .core.curator import Curator, PresentationConfig, RebuildError, build_library +from .core.events import record_event class CuratorApp: def __init__(self, config: PresentationConfig): + from .core.events import registry + registry.default_source = "curator" + self.config = config self.curator = Curator(config) @@ -17,18 +21,17 @@ async def lifespan(app: FastAPI): if self.config.build_on_start: try: startup_report = build_library(self.config) - print( + record_event( "initial presentation build complete: " f"{startup_report['movies']} movies, " f"{startup_report['show_files']} show files, " - f"{startup_report['anime_files']} anime files", - flush=True, + f"{startup_report['anime_files']} anime files" ) except Exception as exc: - print(f"initial presentation build failed: {exc}", flush=True) + record_event(f"initial presentation build failed: {exc}", level="error") yield self.curator.cleanup() - print(f"curator cleaned up {self.config.target_root}", flush=True) + record_event(f"curator cleaned up {self.config.target_root}") self.app = FastAPI(lifespan=lifespan) @@ -36,10 +39,17 @@ async def lifespan(app: FastAPI): def healthz(): return {"status": "ok"} + @self.app.get("/api/logs") + def get_logs(limit: int = 100): + from .core.events import registry + + return registry.get_recent(limit) + @self.app.post("/rebuild") - def rebuild(): + async def rebuild(payload: dict = None): + changed_roots = (payload or {}).get("changed_roots", []) try: - report = self.curator.handle_rebuild() + report = self.curator.handle_rebuild(changed_roots) return report except Exception as exc: payload = {"error": str(exc)} @@ -52,16 +62,16 @@ def rebuild(): 401, 403, ): - print( - f"curator rebuild failed: Jellyfin API Token is invalid or unauthorized", - flush=True, + 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) - print( + record_event( f"curator rebuild failed: {exc}\n{traceback.format_exc()}", - flush=True, + level="error", ) return JSONResponse(status_code=500, content=payload) diff --git a/buzz/dav_app.py b/buzz/dav_app.py index b9bf7de..8de07f1 100644 --- a/buzz/dav_app.py +++ b/buzz/dav_app.py @@ -14,6 +14,7 @@ from rdapi import RD from .dav_protocol import open_remote_media, propfind_body +from .core.events import record_event from .core.utils import ( format_bytes, http_date, @@ -38,6 +39,9 @@ class DavApp: def __init__(self, config: DavConfig): + from .core.events import registry + registry.default_source = "dav" + self.config = config os.environ["RD_APITOKEN"] = config.token self.client = RD() @@ -71,6 +75,10 @@ async def lifespan(app: FastAPI): self._setup_routes() 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("/torrents", response_class=HTMLResponse) def index(): @@ -82,16 +90,24 @@ def trashcan(): @self.app.get("/healthz") def healthz(): - return {"status": "ok", **self.state.status()} + from .core.events import registry + + return {"status": "ok", "log_count": len(registry.events), **self.state.status()} @self.app.get("/readyz") def readyz(): + from .core.events import registry + is_ready = self.state.is_ready() status_code = HTTPStatus.OK if is_ready else HTTPStatus.SERVICE_UNAVAILABLE payload_status = "ready" if is_ready else "starting" return JSONResponse( status_code=status_code, - content={"status": payload_status, **self.state.status()}, + content={ + "status": payload_status, + "log_count": len(registry.events), + **self.state.status(), + }, ) @self.app.post("/sync") @@ -161,11 +177,48 @@ def delete_trash_permanently(payload: DeleteTrashRequest): @self.app.post("/api/curator/rebuild") def curator_rebuild(): try: + record_event("Manual library resync triggered") self.state.manual_rebuild() + record_event("Manual library resync completed") return {"status": "success"} except Exception as exc: + record_event(f"Manual library resync failed: {exc}", level="error") return JSONResponse(status_code=500, content={"error": str(exc)}) + @self.app.get("/api/logs") + def get_logs(limit: int = 100): + from .core.events import registry + import httpx + + 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:] + + @self.app.post("/api/restart") + def restart_service(): + import signal + + record_event("Restart requested via API", level="warning") + os.kill(os.getpid(), signal.SIGTERM) + return {"status": "restarting"} + @self.app.options("/dav/{path:path}") def options_dav(path: str): return Response( @@ -288,16 +341,17 @@ def stream_generator(): except error.HTTPError as exc: return Response(status_code=exc.code, content=str(exc)) except ValueError as exc: - print( - json.dumps( - {"event": "rd_stream_failed", "path": rel, "error": str(exc)}, - sort_keys=True, - ), - flush=True, + record_event( + f"Real-Debrid stream failed: {exc}", + event="rd_stream_failed", + path=rel, + level="error", ) return Response(status_code=502, content=str(exc)) def _torrents_page(self) -> str: + from .core.events import registry + status = self.state.status() torrents = self.state.torrents() page_torrents = [] @@ -328,9 +382,12 @@ def _torrents_page(self) -> str: last_error=status.get("last_error"), torrents=page_torrents, trash_count=len(self.state.trashcan), + log_count=len(registry.events), ) def _trashcan_page(self) -> str: + from .core.events import registry + status = self.state.status() torrents = self.state.trash_torrents() trash_torrents = [] @@ -350,13 +407,31 @@ def _trashcan_page(self) -> str: template = self.templates.get_template("trashcan.html") return template.render( - torrents_count=len(torrents), + 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_torrents=trash_torrents, trash_count=len(trash_torrents), + log_count=len(registry.events), + ) + + 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), ) async def _handle_validation_error( diff --git a/buzz/models.py b/buzz/models.py index b8ab952..a7b9f4c 100644 --- a/buzz/models.py +++ b/buzz/models.py @@ -1,4 +1,5 @@ import os +import json from pathlib import Path import yaml @@ -24,6 +25,8 @@ class DavConfig(BaseModel): version_label: str = "buzz/0.1" curator_url: str = "http://buzz-curator:8400/rebuild" rd_update_delay_secs: int = 15 + vfs_wait_timeout_secs: int = 300 + library_mount: str = "" verbose: bool = False @classmethod @@ -54,6 +57,8 @@ def load(cls, path: str = DEFAULT_DAV_CONFIG_PATH) -> "DavConfig": hooks.get("curator_url", "http://buzz-curator:8400/rebuild") ), rd_update_delay_secs=int(hooks.get("rd_update_delay_secs", 15)), + vfs_wait_timeout_secs=int(hooks.get("vfs_wait_timeout_secs", 300)), + library_mount=os.environ.get("LIBRARY_MOUNT", ""), anime_patterns=tuple(anime.get("patterns", [DEFAULT_ANIME_PATTERN])), enable_all_dir=bool(compat.get("enable_all_dir", True)), enable_unplayable_dir=bool(compat.get("enable_unplayable_dir", True)), @@ -102,6 +107,14 @@ class PresentationConfig(BaseModel): jellyfin_scan_task_id: str = Field( default_factory=lambda: os.environ.get("JELLYFIN_SCAN_TASK_ID", "") ) + jellyfin_library_map: dict[str, str] = Field( + default_factory=lambda: json.loads( + os.environ.get( + "JELLYFIN_LIBRARY_MAP", + '{"movies": "Movies", "shows": "TV Shows", "anime": "Anime"}', + ) + ) + ) skip_jellyfin_scan: bool = Field( default_factory=lambda: ( os.environ.get("PRESENTATION_SKIP_JELLYFIN_SCAN", "").lower() diff --git a/buzz/static/buzz.css b/buzz/static/buzz.css index b4284aa..33bb77b 100644 --- a/buzz/static/buzz.css +++ b/buzz/static/buzz.css @@ -70,6 +70,24 @@ main { text-shadow: 0 0 5px rgba(80, 250, 123, 0.5); } +.nav-logs-new { + color: var(--orange) !important; + text-shadow: 0 0 10px rgba(255, 184, 108, 0.8) !important; + animation: log-glow 2s infinite ease-in-out; +} + +@keyframes log-glow { + + 0%, + 100% { + text-shadow: 0 0 5px rgba(255, 184, 108, 0.5); + } + + 50% { + text-shadow: 0 0 15px rgba(255, 184, 108, 0.9); + } +} + .nav-sep { color: var(--comment); margin: 0 4px; @@ -78,18 +96,31 @@ main { .meta-bar { display: flex; - flex-wrap: wrap; - gap: 20px; + flex-direction: column; + gap: 10px; margin: 20px 15px 20px 0px; font-size: 0.9rem; color: var(--comment); } -.meta-item b { +.meta-row { + display: flex; + flex-wrap: wrap; + gap: 20px; + align-items: center; +} + +.meta-item-label { color: var(--orange); font-weight: normal; } +.meta-item { + display: flex; + align-items: center; + gap: 5px; +} + .meta-item span { color: var(--cyan); } @@ -106,6 +137,21 @@ main { width: 135px; } +.meta-console { + display: flex; + gap: 10px; + align-items: center; + font-family: inherit; +} + +.meta-console b { + color: var(--purple); +} + +.meta-console span { + color: var(--fg); +} + button { background: var(--purple); color: var(--bg); @@ -402,3 +448,436 @@ code { display: none; } } + +/* --- Utility Classes --- */ +.flex { + display: flex; +} + +.flex-row { + display: flex; + flex-direction: row; +} + +.flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +.gap-5 { + gap: 5px; +} + +.gap-10 { + gap: 10px; +} + +.justify-end { + justify-content: flex-end; +} + +.relative { + position: relative; +} + +.absolute-fill { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.m-b-5 { + margin-bottom: 5px; +} + +.m-b-10 { + margin-bottom: 10px; +} + +.m-b-15 { + margin-bottom: 15px; +} + +.m-t-10 { + margin-top: 10px; +} + +.m-t-15 { + margin-top: 15px; +} + +/* --- Component Classes --- */ +.btn-sm { + font-size: 0.7rem; + padding: 2px 8px; +} + +.btn-icon { + padding: 0 10px; + font-size: 1rem; +} + +.btn-restart { + color: var(--red); +} + +.status-label { + color: var(--fg); + text-transform: none; +} + +.service-status-green { + color: var(--green); +} + +.service-status-orange { + color: var(--orange); +} + +.service-status-cyan { + color: var(--cyan); +} + +.service-status-red { + color: var(--red); +} + +.hidden { + display: none; +} + +.accent-purple { + accent-color: var(--purple); +} + +.overlay { + background: rgba(0, 0, 0, 0.6); + z-index: 100; + border-radius: 4px; +} + +.overlay-msg { + color: var(--fg); + font-weight: bold; + font-size: 1.2rem; + background: var(--bg); + padding: 10px 20px; + border: 1px solid var(--purple); + border-radius: 4px; +} + +.logs-header-btns { + display: flex; + gap: 10px; +} + +.auto-refresh-label { + font-size: 0.7rem; + display: flex; + align-items: center; + gap: 5px; +} + +.width-icon { + width: 33px; +} + +.bulk-magnet-add { + display: flex; + justify-content: flex-end; +} + +.bulk-magnet-remove { + padding: 0 10px; + font-size: 1rem; + color: var(--red); +} + +/* --- Page: Torrents --- */ +.add-torrent-section { + background: var(--selection); + padding: 15px; + border-radius: 4px; + margin-bottom: 24px; + border: 1px solid var(--comment); +} + +.add-torrent-header { + color: var(--pink); + text-transform: uppercase; + font-size: 0.8rem; + letter-spacing: 1px; + margin-bottom: 10px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.input-group { + display: flex; +} + +input[type="text"] { + flex: 1; + background: var(--bg); + border: 1px solid var(--comment); + color: var(--fg); + padding: 8px 12px; + font-family: inherit; + outline: none; +} + +input[type="text"]:focus { + border-color: var(--purple); +} + +#file-selection-area { + margin-top: 15px; + border-top: 1px solid var(--comment); + padding-top: 15px; + display: none; +} + +.file-list { + max-height: 300px; + overflow-y: auto; + margin-bottom: 15px; + font-size: 0.8rem; +} + +.file-torrent-header { + padding: 10px 0 5px 0; + border-bottom: 1px solid var(--comment); + margin-top: 10px; + color: var(--purple); + font-weight: bold; +} + +.file-torrent-header:first-child { + margin-top: 0; +} + +.file-item { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 0; +} + +.file-item:hover { + background: rgba(255, 255, 255, 0.05); +} + +.file-item input { + accent-color: var(--purple); +} + +.file-info { + display: flex; + flex: 1; + justify-content: space-between; +} + +.file-size { + color: var(--cyan); +} + +.file-actions { + display: flex; + gap: 10px; +} + +#torrent-table th:nth-child(1), +#torrent-table td:nth-child(1) { + width: auto; +} + +#torrent-table th:nth-child(2), +#torrent-table td:nth-child(2) { + width: 110px; +} + +#torrent-table th:nth-child(3), +#torrent-table td:nth-child(3) { + width: 60px; +} + +#torrent-table th:nth-child(4), +#torrent-table td:nth-child(4) { + width: 80px; +} + +#torrent-table th:nth-child(5), +#torrent-table td:nth-child(5) { + width: 60px; +} + +#torrent-table th:nth-child(7), +#torrent-table td:nth-child(7) { + width: 85px; +} + +@media (max-width: 500px) { + + #torrent-table th:nth-child(2), + #torrent-table td:nth-child(2), + #torrent-table th:nth-child(3), + #torrent-table td:nth-child(3), + #torrent-table th:nth-child(6), + #torrent-table td:nth-child(6), + #torrent-table th:nth-child(7), + #torrent-table td:nth-child(7) { + display: none; + } +} + +.status { + font-weight: bold; +} + +.status-downloaded { + color: var(--green); +} + +.status-error { + color: var(--red); +} + +.status-uploading { + color: var(--cyan); +} + +.status-downloading { + color: var(--orange); +} + +/* --- Page: Logs --- */ +.logs-page-container { + margin-top: 24px; +} + +.logs-header { + color: var(--pink); + text-transform: uppercase; + font-size: 0.8rem; + letter-spacing: 1px; + display: flex; + justify-content: space-between; + align-items: center; + background: var(--selection); + padding: 10px 15px; + border: 1px solid var(--comment); + border-radius: 4px 4px 0 0; + border-bottom: none; +} + +.log-container { + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 0.8rem; + height: calc(100vh - 250px); + min-height: 400px; + overflow-y: auto; + background: var(--bg); + border: 1px solid var(--comment); + padding: 15px; + border-radius: 0 0 4px 4px; +} + +.log-entry { + margin-bottom: 6px; + white-space: pre-wrap; + word-break: break-all; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + padding-bottom: 4px; + position: relative; + display: flex; + align-items: flex-start; +} + +.log-entry:hover .log-copy-btn { + opacity: 1; +} + +.log-copy-btn { + opacity: 0; + cursor: pointer; + margin-left: auto; + padding: 0 5px; + color: var(--comment); + transition: opacity 0.2s, color 0.2s; + font-size: 0.9rem; + user-select: none; + flex-shrink: 0; +} + +.log-copy-btn:hover { + color: var(--cyan); +} + +.log-content { + flex-grow: 1; + display: flex; +} + +.log-entry { + height: 30px; +} + +.log-entry:last-child { + border-bottom: none; +} + +.log-ts { + color: var(--comment); + margin-right: 10px; + font-size: 0.75rem; +} + +.log-level-debug { + color: var(--comment); +} + +.log-level-info { + color: var(--green); +} + +.log-level-warning { + color: var(--orange); +} + +.log-level-error { + color: var(--red); + font-weight: bold; +} + +/* --- Page: Trashcan --- */ +#trash-table th:nth-child(1), +#trash-table td:nth-child(1) { + width: auto; +} + +#trash-table th:nth-child(2), +#trash-table td:nth-child(2) { + width: 100px; +} + +#trash-table th:nth-child(3), +#trash-table td:nth-child(3) { + width: 80px; +} + +#trash-table th:nth-child(4), +#trash-table td:nth-child(4) { + width: 220px; +} + +@media (max-width: 500px) { + + #trash-table th:nth-child(4), + #trash-table td:nth-child(4) { + display: none; + } +} diff --git a/buzz/static/buzz.js b/buzz/static/buzz.js index 59d1e9a..b3df099 100644 --- a/buzz/static/buzz.js +++ b/buzz/static/buzz.js @@ -1,10 +1,11 @@ let buzzPageConfig = { tableId: "torrent-table", - rebuildStatusTarget: null, statusSyncId: "status-sync", statusLastSyncId: "status-last-sync", statusReadyId: "status-ready", statusReadyLabelId: "status-ready-label", + navLogsId: "nav-logs", + consoleMsgId: "meta-console-msg", pollIntervalMs: 3000, }; @@ -18,51 +19,32 @@ function setReadyLabel(isReady, offline) { return; } + readyLabel.classList.remove("service-status-green", "service-status-orange", "service-status-cyan"); + if (offline) { readyLabel.innerText = "[offline]"; - readyLabel.style.color = "var(--cyan)"; + readyLabel.classList.add("service-status-cyan"); return; } if (isReady) { readyLabel.innerText = "[ready]"; - readyLabel.style.color = "var(--green)"; + readyLabel.classList.add("service-status-green"); } else { readyLabel.innerText = "[starting]"; - readyLabel.style.color = "var(--orange)"; + readyLabel.classList.add("service-status-orange"); } } -function createPromptStatusNode() { - const prompt = document.querySelector(".prompt"); - if (!prompt) { - return null; - } - - const status = document.createElement("span"); - prompt.appendChild(status); - return status; -} - function getRebuildStatusNode() { - if (buzzPageConfig.rebuildStatusTarget === "prompt") { - return createPromptStatusNode(); - } - - if (typeof buzzPageConfig.rebuildStatusTarget === "string") { - return document.querySelector(buzzPageConfig.rebuildStatusTarget); - } - - return null; + return document.getElementById(buzzPageConfig.consoleMsgId); } async function triggerManualRebuild() { const status = getRebuildStatusNode(); if (status) { - status.innerText = buzzPageConfig.rebuildStatusTarget === "prompt" - ? " Resyncing library..." - : "Resyncing library..."; - status.style.color = "var(--orange)"; + status.innerText = "Resyncing library..."; + status.className = "service-status-orange"; } try { @@ -73,28 +55,124 @@ async function triggerManualRebuild() { } if (status) { - status.innerText = buzzPageConfig.rebuildStatusTarget === "prompt" - ? " Library resynced!" - : "Library resynced!"; - status.style.color = "var(--green)"; + 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 = buzzPageConfig.rebuildStatusTarget === "prompt" - ? " Resync failed: " + err.message - : "Resync failed: " + err.message; - status.style.color = "var(--red)"; + 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"); + try { + await navigator.clipboard.writeText(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; } - if (status && buzzPageConfig.rebuildStatusTarget === "prompt") { - setTimeout(() => status.remove(), 3000); + 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 sourceLabel = `${source}`; + const messageText = `${source} ${ts} [${level.toUpperCase()}] ${log.message}`; + return ` +
+
+ ${sourceLabel}${ts}${levelLabel} ${log.message} +
+
+ +
+
`; + }) + .join(""); + + container.scrollTop = container.scrollHeight; + } catch (err) { + console.error("Failed to poll logs:", err); } } async function pollStatus() { const statusSync = getBuzzElement(buzzPageConfig.statusSyncId); const statusLastSync = getBuzzElement(buzzPageConfig.statusLastSyncId); + const navLogs = getBuzzElement(buzzPageConfig.navLogsId); try { const res = await fetch("/healthz"); @@ -109,6 +187,29 @@ async function pollStatus() { if (statusLastSync) { statusLastSync.innerText = data.last_sync_at || "never"; } + if (navLogs) { + const logCount = data.log_count || 0; + navLogs.innerText = `📜 logs(${logCount})`; + + 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) { if (statusSync) { @@ -137,6 +238,16 @@ function initBuzzPage(config) { if (buzzPageConfig.pollIntervalMs > 0) { 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); } } diff --git a/buzz/templates/logs.html b/buzz/templates/logs.html new file mode 100644 index 0000000..4276230 --- /dev/null +++ b/buzz/templates/logs.html @@ -0,0 +1,69 @@ + + + + + + + buzz: system logs + + + + + + +
+ + +
+
+
[torrents] {{ torrents_count }}
+
[last_sync] {{ last_sync_at }}
+
[state] {{ sync_state }}
+
+ [ready] + +
+
+
+ console: +
+
+ +
+
+ SYSTEM LOGS: +
+ + + + +
+
+
+
Loading logs...
+
+
+
+ + + + + + diff --git a/buzz/templates/torrents.html b/buzz/templates/torrents.html index 2572fee..8203207 100644 --- a/buzz/templates/torrents.html +++ b/buzz/templates/torrents.html @@ -8,153 +8,7 @@ - + @@ -165,21 +19,23 @@ 🪎 cache 🗑️ trashcan({{ trash_count }}) + + 📜 logs({{ log_count }})
-
[torrents] {{ torrents_count }}
-
[last_sync] {{ last_sync_at }}
-
[state] {{ sync_state }}
-
- [ready] - +
+
[torrents] {{ torrents_count }}
+
[last_sync] {{ last_sync_at }}
+
[state] {{ sync_state }}
+
+ [ready] + +
-
- +
+ console:
@@ -190,21 +46,28 @@
ADD NEW MAGNET TO THE CACHE: - +
-
- - +
+
+ +
+ +
+
+
- +
-
+
@@ -217,7 +80,10 @@
-
+
+ @@ -272,111 +138,185 @@ diff --git a/buzz/templates/trashcan.html b/buzz/templates/trashcan.html index 54914b7..618ec3f 100644 --- a/buzz/templates/trashcan.html +++ b/buzz/templates/trashcan.html @@ -8,35 +8,7 @@ - + @@ -47,21 +19,23 @@ 🪎 cache🗑️ trashcan({{ trash_count }}) + + 📜 logs({{ log_count }})
-
[torrents] {{ torrents_count }}
-
[last_sync] {{ last_sync_at }}
-
[state] {{ sync_state }}
-
- [ready] - +
+
[torrents] {{ torrents_count }}
+
[last_sync] {{ last_sync_at }}
+
[state] {{ sync_state }}
+
+ [ready] + +
-
- +
+ console:
@@ -70,7 +44,7 @@ {% endif %}
-
+
@@ -199,8 +173,7 @@ } initBuzzPage({ - rebuildStatusTarget: "prompt", - tableId: "torrent-table" + tableId: "trash-table" }); diff --git a/tests/test_curator_app.py b/tests/test_curator_app.py index 6766eae..e3ebf22 100644 --- a/tests/test_curator_app.py +++ b/tests/test_curator_app.py @@ -149,7 +149,9 @@ def test_rebuild_logs_mapping_when_verbose_is_enabled(self): self.assertEqual(report["movies"], 1) lines = [line for line in stdout.getvalue().splitlines() if line] - mapping_log = json.loads(lines[-1]) + last_line = lines[-1] + json_start = last_line.find("{") + mapping_log = json.loads(last_line[json_start:]) self.assertEqual(mapping_log["event"], "curator_mapping_diff") self.assertEqual(mapping_log["mapping_entries"], 1) self.assertEqual(mapping_log["removed"], []) @@ -180,13 +182,17 @@ def test_rebuild_logs_empty_diff_when_mapping_is_unchanged(self): self.assertEqual(report["movies"], 1) lines = [line for line in stdout.getvalue().splitlines() if line] - mapping_log = json.loads(lines[-1]) + last_line = lines[-1] + json_start = last_line.find("{") + mapping_log = json.loads(last_line[json_start:]) self.assertEqual(mapping_log["event"], "curator_mapping_diff") self.assertEqual(mapping_log["added"], []) self.assertEqual(mapping_log["removed"], []) self.assertEqual(mapping_log["changed"], []) - def test_rebuild_and_trigger_calls_jellyfin_scan_when_auth_is_configured(self): + @patch("buzz.core.curator.validate_jellyfin_auth") + def test_rebuild_and_trigger_calls_jellyfin_scan_when_auth_is_configured(self, mock_validate): + mock_validate.return_value = True with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) config = self._config( @@ -202,10 +208,57 @@ def test_rebuild_and_trigger_calls_jellyfin_scan_when_auth_is_configured(self): trigger_scan.assert_called_once_with(config) self.assertTrue(report["jellyfin_scan_triggered"]) - self.assertEqual(report["jellyfin_scan_status"], "triggered") + self.assertEqual(report["jellyfin_scan_status"], "full_triggered") self.assertIsNone(report["jellyfin_scan_error"]) - def test_rebuild_and_trigger_raises_structured_error_for_scan_failure(self): + @patch("buzz.core.curator.validate_jellyfin_auth") + def test_rebuild_and_trigger_calls_selective_refresh_when_changed_roots_provided(self, mock_validate): + mock_validate.return_value = True + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + config = self._config(root, skip_jellyfin_scan=False, jellyfin_api_key="token") + self._create_source_tree(config.source_root) + + with patch( + "buzz.core.curator.trigger_jellyfin_selective_refresh" + ) as trigger_selective: + report = rebuild_and_trigger(config, changed_roots=["movies/MyMovie"]) + + trigger_selective.assert_called_once_with(config, ["movies/MyMovie"]) + self.assertTrue(report["jellyfin_scan_triggered"]) + self.assertEqual(report["jellyfin_scan_status"], "selective_triggered") + + @patch("buzz.core.curator.discover_jellyfin_libraries") + @patch("urllib.request.urlopen") + def test_trigger_jellyfin_selective_refresh_calls_correct_id( + self, mock_urlopen, mock_discover + ): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + config = self._config( + root, + jellyfin_api_key="token", + jellyfin_library_map={"movies": "Movies"}, + ) + mock_discover.return_value = {"Movies": "movie-id-123"} + + from buzz.core.curator import trigger_jellyfin_selective_refresh + + trigger_jellyfin_selective_refresh(config, ["movies/MyMovie"]) + + # Verify that urlopen was called with the refresh URL for movie-id-123 + calls = [call.args[0] for call in mock_urlopen.call_args_list] + refresh_url = f"{config.jellyfin_url}/Items/movie-id-123/Refresh" + self.assertTrue( + any( + refresh_url in (url.full_url if hasattr(url, "full_url") else str(url)) + for url in calls + ) + ) + + @patch("buzz.core.curator.validate_jellyfin_auth") + def test_rebuild_and_trigger_logs_error_and_returns_report_for_scan_failure(self, mock_validate): + mock_validate.return_value = True with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) config = self._config( @@ -220,13 +273,11 @@ def test_rebuild_and_trigger_raises_structured_error_for_scan_failure(self): "buzz.core.curator.trigger_jellyfin_scan", side_effect=RuntimeError("scan failed"), ): - with self.assertRaises(RebuildError) as ctx: - rebuild_and_trigger(config) + report = rebuild_and_trigger(config) - self.assertEqual(str(ctx.exception), "scan failed") - self.assertEqual(ctx.exception.payload["jellyfin_scan_status"], "failed") - self.assertEqual(ctx.exception.payload["jellyfin_scan_error"], "scan failed") - self.assertFalse(ctx.exception.payload["jellyfin_scan_triggered"]) + self.assertEqual(report["jellyfin_scan_status"], "failed") + self.assertEqual(report["jellyfin_scan_error"], "scan failed") + self.assertFalse(report["jellyfin_scan_triggered"]) def test_curator_rebuild_logs_unexpected_errors(self): with tempfile.TemporaryDirectory() as tmpdir: diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..7409686 --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,39 @@ +import unittest +from buzz.core.events import EventRegistry + +class EventRegistryTests(unittest.TestCase): + def test_record_and_get_recent(self): + registry = EventRegistry(maxlen=5) + registry.record("msg 1") + registry.record("msg 2", level="warning") + + events = registry.get_recent() + self.assertEqual(len(events), 2) + self.assertEqual(events[0]["message"], "msg 1") + self.assertEqual(events[0]["level"], "info") + self.assertEqual(events[1]["message"], "msg 2") + self.assertEqual(events[1]["level"], "warning") + self.assertIn("timestamp", events[0]) + + def test_ring_buffer_behavior(self): + registry = EventRegistry(maxlen=3) + for i in range(5): + registry.record(f"msg {i}") + + events = registry.get_recent() + self.assertEqual(len(events), 3) + self.assertEqual(events[0]["message"], "msg 2") + self.assertEqual(events[-1]["message"], "msg 4") + + def test_get_recent_limit(self): + registry = EventRegistry(maxlen=10) + for i in range(10): + registry.record(f"msg {i}") + + events = registry.get_recent(limit=3) + self.assertEqual(len(events), 3) + self.assertEqual(events[0]["message"], "msg 7") + self.assertEqual(events[-1]["message"], "msg 9") + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_vfs_sync.py b/tests/test_vfs_sync.py new file mode 100644 index 0000000..f1b5dcc --- /dev/null +++ b/tests/test_vfs_sync.py @@ -0,0 +1,124 @@ +import unittest +from unittest.mock import patch, MagicMock +import os +import time +from buzz.core.state import BuzzState +from buzz.models import DavConfig + +class VFSSyncTests(unittest.TestCase): + def setUp(self): + self.config = DavConfig( + token="token", + library_mount="/mnt/buzz/raw", + vfs_wait_timeout_secs=10, + rd_update_delay_secs=0, + state_dir="/tmp/buzz-tests-vfs" + ) + self.client = MagicMock() + self.state = BuzzState(self.config, self.client) + # Setup a basic snapshot + self.state.snapshot = { + "files": { + "movies/MyMovie/Movie.mkv": {"type": "remote"}, + "shows/MyShow/S01E01.mkv": {"type": "remote"} + } + } + + @patch("os.path.exists") + @patch("time.sleep") + @patch("time.time") + @patch("buzz.core.state.BuzzState._trigger_curator") + @patch("buzz.core.state.BuzzState._run_hook") + def test_wait_for_vfs_visibility_success(self, mock_run_hook, mock_trigger_curator, mock_time, mock_sleep, mock_exists): + # Mock time to not advance much + mock_time.side_effect = [100.0, 101.0, 102.0, 103.0, 104.0, 105.0] + # Mock exists to return True for the requested root + mock_exists.return_value = True + + self.state._trigger_curator_and_hooks(["movies/MyMovie"]) + + # Verify os.path.exists was called with the correct path + mock_exists.assert_called_with("/mnt/buzz/raw/movies/MyMovie") + # Verify curator and hooks were triggered + mock_trigger_curator.assert_called_once() + mock_run_hook.assert_called_once() + # Verify no sleep was needed (it was visible immediately) + mock_sleep.assert_not_called() + + @patch("os.path.exists") + @patch("time.sleep") + @patch("time.time") + @patch("buzz.core.state.BuzzState._trigger_curator") + @patch("buzz.core.state.BuzzState._run_hook") + def test_wait_for_vfs_visibility_delay(self, mock_run_hook, mock_trigger_curator, mock_time, mock_sleep, mock_exists): + # Mock time to advance each call + mock_time.side_effect = [100.0, 101.0, 102.0, 103.0, 104.0, 105.0, 106.0, 107.0] + # Mock exists to return False first, then True + mock_exists.side_effect = [False, True] + + self.state._trigger_curator_and_hooks(["movies/MyMovie"]) + + # Verify it slept once + mock_sleep.assert_called_once_with(2) + # Verify curator and hooks were triggered + mock_trigger_curator.assert_called_once() + + @patch("os.path.exists") + @patch("time.sleep") + @patch("time.time") + @patch("buzz.core.state.BuzzState._trigger_curator") + @patch("buzz.core.state.BuzzState._run_hook") + def test_wait_for_vfs_visibility_timeout(self, mock_run_hook, mock_trigger_curator, mock_time, mock_sleep, mock_exists): + # Mock time to hit timeout (10s) + # start_time = 100.0 + # loop 1: 101.0 (elapsed 1.0 < 10.0) -> False -> sleep + # loop 2: 104.0 (elapsed 4.0 < 10.0) -> False -> sleep + # loop 3: 107.0 (elapsed 7.0 < 10.0) -> False -> sleep + # loop 4: 110.0 (elapsed 10.0 < 10.0 is False, so loop terminates) + mock_time.side_effect = [ + 100.0, # start_time + 101.0, # first loop check + 102.0, # first exists check path join + 103.0, # first sleep check time + 104.0, # second loop check + 105.0, # second exists check + 106.0, # second sleep check + 107.0, # third loop check + 108.0, # third exists check + 109.0, # third sleep check + 110.0, # fourth loop check -> exit + 111.0 # final log + ] + # Mock exists to always return False + mock_exists.return_value = False + + self.state._trigger_curator_and_hooks(["movies/MyMovie"]) + + # Verify it timed out but still proceeded + mock_trigger_curator.assert_called_once() + mock_run_hook.assert_called_once() + + @patch("os.path.exists") + @patch("time.sleep") + @patch("time.time") + @patch("buzz.core.state.BuzzState._trigger_curator") + @patch("buzz.core.state.BuzzState._run_hook") + def test_wait_for_vfs_visibility_removed_root(self, mock_run_hook, mock_trigger_curator, mock_time, mock_sleep, mock_exists): + # If a root is NOT in snapshot, we wait for it to be GONE (exists=False) + self.state.snapshot = {"files": {}} # Empty snapshot, so MyMovie is "removed" + + # Mock time + mock_time.side_effect = [100.0, 101.0, 102.0, 103.0, 104.0, 105.0, 106.0] + # Mock exists to return True (stale) then False (gone) + mock_exists.side_effect = [True, False] + + self.state._trigger_curator_and_hooks(["movies/MyMovie"]) + + # Should have called exists twice + self.assertEqual(mock_exists.call_count, 2) + # Should have slept once + mock_sleep.assert_called_once_with(2) + mock_trigger_curator.assert_called_once() + +if __name__ == "__main__": + unittest.main() From 52b0fe51d8449152395e710f61b51263a103cf7b Mon Sep 17 00:00:00 2001 From: Gabriel Chamon Date: Thu, 16 Apr 2026 20:27:29 -0300 Subject: [PATCH 2/2] feat(ui): log UX improvements, layout fixes, and observability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit logs: - make entire log row clickable to copy; fallback to execCommand for non-HTTPS contexts where navigator.clipboard is unavailable - add pointer cursor and selection-color hover highlight on log rows - lowercase all console status messages to match UI aesthetic - enrich "Real-Debrid library changed" log with per-category diffs: +N added: …; -N removed: …; ~N updated: … (N torrents) - fix nav log count: healthz now returns combined dav+curator total via a new GET /api/logs/count endpoint on the curator service config: - expose event ring buffer size as logging.max_entries in buzz.yml (dav) and PRESENTATION_LOG_MAX_ENTRIES env var (curator), default 1000 - add EventRegistry.reconfigure(maxlen) to resize the buffer at startup without losing events recorded before config is applied torrents layout: - move RESYNC LIB button into the console: row in the meta-bar - move Analyze button inline with the magnet input row using flexbox; match its height to the text input (font-size: 1rem, transparent border) - make torrent table fill remaining viewport height via JS measurement (fitTableToViewport / initTableFit); scrollbar always visible; sticky thead with var(--bg) background covers scrolling rows - restrict trunc-cell scroll animation to cells actually clipped (scrollWidth > clientWidth); ResizeObserver re-evaluates on resize --- buzz/core/events.py | 4 ++ buzz/core/state.py | 17 ++++-- buzz/curator_app.py | 8 +++ buzz/dav_app.py | 16 ++++- buzz/models.py | 5 ++ buzz/static/buzz.css | 115 ++++++++++++++++++++++++++++------- buzz/static/buzz.js | 110 ++++++++++++++++++++++++++++----- buzz/templates/logs.html | 25 +++++--- buzz/templates/torrents.html | 8 +-- 9 files changed, 250 insertions(+), 58 deletions(-) diff --git a/buzz/core/events.py b/buzz/core/events.py index a8ad2a5..d3a166e 100644 --- a/buzz/core/events.py +++ b/buzz/core/events.py @@ -36,6 +36,10 @@ def get_recent(self, limit: int = 100) -> list[dict]: with self.lock: return list(self.events)[-limit:] + def reconfigure(self, maxlen: int) -> None: + with self.lock: + self.events = deque(self.events, maxlen=maxlen) + # Global registry for the process registry = EventRegistry() diff --git a/buzz/core/state.py b/buzz/core/state.py index 50886dd..28be555 100644 --- a/buzz/core/state.py +++ b/buzz/core/state.py @@ -850,13 +850,20 @@ def run(self) -> None: try: report = self.state.sync() if report.get("changed"): + added = report.get("added_paths", []) + removed = report.get("removed_paths", []) + updated = report.get("updated_paths", []) + synced = report.get("synced_torrents", 0) + parts = [] + if added: + parts.append(f"+{len(added)} added: {', '.join(added)}") + if removed: + parts.append(f"-{len(removed)} removed: {', '.join(removed)}") + if updated: + parts.append(f"~{len(updated)} updated: {', '.join(updated)}") record_event( - "Real-Debrid library changed", + f"Real-Debrid library changed: {'; '.join(parts)} ({synced} torrents)", event="realdebrid_update", - synced_torrents=report.get("synced_torrents"), - added_paths=report.get("added_paths", []), - removed_paths=report.get("removed_paths", []), - updated_paths=report.get("updated_paths", []), ) except Exception as exc: # noqa: BLE001 self.state.last_error = str(exc) diff --git a/buzz/curator_app.py b/buzz/curator_app.py index cdfab4a..7587b3f 100644 --- a/buzz/curator_app.py +++ b/buzz/curator_app.py @@ -12,6 +12,7 @@ class CuratorApp: def __init__(self, config: PresentationConfig): from .core.events import registry registry.default_source = "curator" + registry.reconfigure(config.log_max_entries) self.config = config self.curator = Curator(config) @@ -45,6 +46,13 @@ def get_logs(limit: int = 100): return registry.get_recent(limit) + @self.app.get("/api/logs/count") + def get_logs_count(): + from .core.events import registry + + with registry.lock: + return {"count": len(registry.events)} + @self.app.post("/rebuild") async def rebuild(payload: dict = None): changed_roots = (payload or {}).get("changed_roots", []) diff --git a/buzz/dav_app.py b/buzz/dav_app.py index 8de07f1..5f1daa8 100644 --- a/buzz/dav_app.py +++ b/buzz/dav_app.py @@ -41,6 +41,7 @@ class DavApp: def __init__(self, config: DavConfig): from .core.events import registry registry.default_source = "dav" + registry.reconfigure(config.log_max_entries) self.config = config os.environ["RD_APITOKEN"] = config.token @@ -91,8 +92,21 @@ def trashcan(): @self.app.get("/healthz") def healthz(): from .core.events import registry + import httpx + + 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": len(registry.events), **self.state.status()} + return {"status": "ok", "log_count": dav_count + curator_count, **self.state.status()} @self.app.get("/readyz") def readyz(): diff --git a/buzz/models.py b/buzz/models.py index a7b9f4c..2a70e91 100644 --- a/buzz/models.py +++ b/buzz/models.py @@ -28,6 +28,7 @@ class DavConfig(BaseModel): vfs_wait_timeout_secs: int = 300 library_mount: str = "" verbose: bool = False + log_max_entries: int = 1000 @classmethod def load(cls, path: str = DEFAULT_DAV_CONFIG_PATH) -> "DavConfig": @@ -66,6 +67,7 @@ def load(cls, path: str = DEFAULT_DAV_CONFIG_PATH) -> "DavConfig": user_agent=str(raw.get("user_agent", "buzz/0.1")), version_label=str(raw.get("version_label", "buzz/0.1")), verbose=bool(logging.get("verbose", False)), + log_max_entries=int(logging.get("max_entries", 1000)), ) @@ -132,6 +134,9 @@ class PresentationConfig(BaseModel): os.environ.get("PRESENTATION_VERBOSE", "").lower() in {"1", "true", "yes"} ) ) + log_max_entries: int = Field( + default_factory=lambda: int(os.environ.get("PRESENTATION_LOG_MAX_ENTRIES", "1000")) + ) class ErrorResponse(BaseModel): diff --git a/buzz/static/buzz.css b/buzz/static/buzz.css index 33bb77b..10e385b 100644 --- a/buzz/static/buzz.css +++ b/buzz/static/buzz.css @@ -150,13 +150,14 @@ main { .meta-console span { color: var(--fg); + flex: 1; } button { background: var(--purple); color: var(--bg); border: none; - padding: 8px 16px; + padding: 8px; cursor: pointer; font-family: inherit; font-weight: bold; @@ -195,6 +196,11 @@ button.secondary { max-width: 100%; } +#torrent-table-container { + overflow-y: scroll; + overflow-x: auto; +} + .table-container table { border-collapse: collapse; } @@ -218,7 +224,10 @@ th { letter-spacing: 1px; cursor: pointer; user-select: none; - position: relative; + position: sticky; + top: 0; + background: var(--bg); + z-index: 1; text-overflow: ellipsis; white-space: nowrap; } @@ -301,15 +310,16 @@ td:last-of-type { } /* swap on hover */ -.trunc-cell:hover .trunc-idle { +/* swap on hover — only when text is actually clipped */ +.trunc-cell.is-truncated:hover .trunc-idle { display: none; } -.trunc-cell:hover .trunc-scroll-wrap { +.trunc-cell.is-truncated:hover .trunc-scroll-wrap { display: block; } -.trunc-cell:hover .trunc-scroll { +.trunc-cell.is-truncated:hover .trunc-scroll { animation: scroll-text 4s linear infinite; } @@ -425,7 +435,7 @@ code { background: var(--comment); } -@media (max-width: 500px) { +@media (max-width: 700px) { .prompt { flex-direction: column; align-items: center; @@ -616,6 +626,22 @@ code { align-items: center; } +#magnet-input-area { + display: flex; + gap: 8px; + align-items: flex-start; +} + +#magnet-inputs-container { + flex: 1; + min-width: 0; +} + +#resolve-btn { + border: 1px solid transparent; + align-self: flex-start; +} + .input-group { display: flex; } @@ -720,7 +746,7 @@ input[type="text"]:focus { width: 85px; } -@media (max-width: 500px) { +@media (max-width: 700px) { #torrent-table th:nth-child(2), #torrent-table td:nth-child(2), @@ -767,6 +793,8 @@ input[type="text"]:focus { display: flex; justify-content: space-between; align-items: center; + gap: 12px; + flex-wrap: wrap; background: var(--selection); padding: 10px 15px; border: 1px solid var(--comment); @@ -774,6 +802,24 @@ input[type="text"]:focus { border-bottom: none; } +.logs-title { + margin: 0; + font: inherit; +} + +.logs-header-btns { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.auto-refresh-label { + display: inline-flex; + align-items: center; + gap: 6px; +} + .log-container { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 0.8rem; @@ -782,19 +828,22 @@ input[type="text"]:focus { overflow-y: auto; background: var(--bg); border: 1px solid var(--comment); - padding: 15px; border-radius: 0 0 4px 4px; } .log-entry { - margin-bottom: 6px; - white-space: pre-wrap; - word-break: break-all; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 12px; border-bottom: 1px solid rgba(255, 255, 255, 0.05); - padding-bottom: 4px; - position: relative; - display: flex; - align-items: flex-start; + padding: 6px 0; + cursor: pointer; + padding: 8px; +} + +.log-entry:hover { + background: var(--selection); } .log-entry:hover .log-copy-btn { @@ -802,15 +851,19 @@ input[type="text"]:focus { } .log-copy-btn { + display: inline-flex; + align-items: center; + justify-content: center; opacity: 0; cursor: pointer; - margin-left: auto; - padding: 0 5px; + margin: 0; + padding: 2px 4px; color: var(--comment); - transition: opacity 0.2s, color 0.2s; font-size: 0.9rem; user-select: none; flex-shrink: 0; + background: transparent; + border: 0; } .log-copy-btn:hover { @@ -818,24 +871,40 @@ input[type="text"]:focus { } .log-content { - flex-grow: 1; + min-width: 0; display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; } -.log-entry { - height: 30px; +.log-entry-placeholder { + grid-template-columns: minmax(0, 1fr); } .log-entry:last-child { border-bottom: none; } +.log-source { + color: var(--comment); +} + .log-ts { color: var(--comment); - margin-right: 10px; font-size: 0.75rem; } +.log-level { + flex-shrink: 0; +} + +.log-message { + min-width: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + .log-level-debug { color: var(--comment); } @@ -874,7 +943,7 @@ input[type="text"]:focus { width: 220px; } -@media (max-width: 500px) { +@media (max-width: 700px) { #trash-table th:nth-child(4), #trash-table td:nth-child(4) { diff --git a/buzz/static/buzz.js b/buzz/static/buzz.js index b3df099..55c0fb6 100644 --- a/buzz/static/buzz.js +++ b/buzz/static/buzz.js @@ -13,6 +13,19 @@ 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) { @@ -43,7 +56,7 @@ function getRebuildStatusNode() { async function triggerManualRebuild() { const status = getRebuildStatusNode(); if (status) { - status.innerText = "Resyncing library..."; + status.innerText = "resyncing library..."; status.className = "service-status-orange"; } @@ -55,12 +68,12 @@ async function triggerManualRebuild() { } if (status) { - status.innerText = "Library resynced!"; + status.innerText = "library resynced!"; status.className = "service-status-green"; } } catch (err) { if (status) { - status.innerText = "Resync failed: " + err.message; + status.innerText = "resync failed: " + err.message; status.className = "service-status-red"; } } @@ -73,7 +86,7 @@ async function triggerRestart() { const status = getRebuildStatusNode(); if (status) { - status.innerText = "Restarting service..."; + status.innerText = "restarting service..."; status.className = "service-status-orange"; } @@ -82,16 +95,32 @@ async function triggerRestart() { setTimeout(() => location.reload(), 5000); } catch (err) { if (status) { - status.innerText = "Restart failed: " + err.message; + status.innerText = "restart failed: " + err.message; status.className = "service-status-red"; } } } -async function copyToClipboard(text, successMsg = "Copied to clipboard!") { +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 { - await navigator.clipboard.writeText(text); + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + } else { + fallbackCopy(text); + } if (consoleMsg) { consoleMsg.innerText = successMsg; consoleMsg.className = "service-status-green"; @@ -103,7 +132,7 @@ async function copyToClipboard(text, successMsg = "Copied to clipboard!") { } catch (err) { console.error("Failed to copy:", err); if (consoleMsg) { - consoleMsg.innerText = "Failed to copy."; + consoleMsg.innerText = "failed to copy."; consoleMsg.className = "service-status-red"; } return false; @@ -121,7 +150,7 @@ async function copyLogs() { .map((entry) => entry.innerText) .join("\n"); - await copyToClipboard(logText, "Logs copied to clipboard!"); + await copyToClipboard(logText, "logs copied to clipboard!"); } async function pollLogs() { @@ -133,7 +162,7 @@ async function pollLogs() { try { const res = await fetch("/api/logs?limit=100"); if (!res.ok) { - throw new Error("Log fetch failed"); + throw new Error("log fetch failed"); } const logs = await res.json(); @@ -149,16 +178,20 @@ async function pollLogs() { const levelClass = `log-level-${level}`; const levelLabel = `[${level.toUpperCase()}]`; const source = log.source === "curator" ? "buzz-curator" : "buzz-dav"; - const sourceLabel = `${source}`; const messageText = `${source} ${ts} [${level.toUpperCase()}] ${log.message}`; + const escapedMessage = escapeHtml(log.message); + const escapedCopyText = escapeHtml(messageText); return ` -
+
- ${sourceLabel}${ts}${levelLabel} ${log.message} + ${source} + ${ts} + ${levelLabel} + ${escapedMessage}
-
+
+
`; }) .join(""); @@ -169,6 +202,20 @@ async function pollLogs() { } } +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 statusSync = getBuzzElement(buzzPageConfig.statusSyncId); const statusLastSync = getBuzzElement(buzzPageConfig.statusLastSyncId); @@ -318,3 +365,36 @@ function sortTable(n) { }); tbody.appendChild(fragment); } + + +function markTruncatedCells() { + document.querySelectorAll(".trunc-cell").forEach((cell) => { + const idle = cell.querySelector(".trunc-idle"); + if (!idle) return; + if (idle.scrollWidth > idle.clientWidth) { + cell.classList.add("is-truncated"); + } else { + cell.classList.remove("is-truncated"); + } + }); +} + +function initTruncCells() { + markTruncatedCells(); + const table = getBuzzElement(buzzPageConfig.tableId); + 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); +} \ No newline at end of file diff --git a/buzz/templates/logs.html b/buzz/templates/logs.html index 4276230..7dea2d6 100644 --- a/buzz/templates/logs.html +++ b/buzz/templates/logs.html @@ -39,22 +39,27 @@
-
+
- SYSTEM LOGS: +

System Logs

- - - -
-
-
Loading logs...
+
+
+
+ Loading logs... +
+
-
+
diff --git a/buzz/templates/torrents.html b/buzz/templates/torrents.html index 8203207..e0747f1 100644 --- a/buzz/templates/torrents.html +++ b/buzz/templates/torrents.html @@ -36,6 +36,7 @@
console: +
@@ -46,9 +47,6 @@
ADD NEW MAGNET TO THE CACHE: -
@@ -80,7 +78,7 @@
-
+
@@ -398,6 +396,8 @@ initBuzzPage({ tableId: "torrent-table" }); + initTruncCells(); + initTableFit();
Name