From f56e70237379e9ed8ffce0f3dc74452d05da1863 Mon Sep 17 00:00:00 2001 From: Gabriel Chamon Date: Tue, 14 Apr 2026 15:21:57 -0300 Subject: [PATCH 1/8] trashcan implementation first pass --- buzz/core/state.py | 78 +++++++ buzz/dav_app.py | 93 +++++++- buzz/models.py | 24 ++ buzz/templates/torrents.html | 42 +++- buzz/templates/trashcan.html | 439 +++++++++++++++++++++++++++++++++++ 5 files changed, 669 insertions(+), 7 deletions(-) create mode 100644 buzz/templates/trashcan.html diff --git a/buzz/core/state.py b/buzz/core/state.py index dd2d8d5..fd60be3 100644 --- a/buzz/core/state.py +++ b/buzz/core/state.py @@ -274,8 +274,10 @@ def __init__(self, config: DavConfig, client: Any): self.lock = threading.RLock() self.state_dir = config.state_dir self.cache_path = os.path.join(self.state_dir, "torrent_cache.json") + self.trashcan_path = os.path.join(self.state_dir, "trashcan.json") self.snapshot_path = os.path.join(self.state_dir, "library_snapshot.json") self.cache = self._load_json(self.cache_path, default={}) + self.trashcan = self._load_json(self.trashcan_path, default={}) self.snapshot_loaded = os.path.exists(self.snapshot_path) self.snapshot = self._load_json( self.snapshot_path, default={"dirs": [""], "files": {}} @@ -642,6 +644,13 @@ def add_magnet(self, magnet: str) -> dict[str, Any]: } def delete_torrent(self, torrent_id: str) -> dict[str, Any]: + with self.lock: + cached = self.cache.get(torrent_id) + if cached: + info = cached.get("info") + if isinstance(info, dict) and info.get("hash"): + self._add_to_trashcan(info) + res = self.client.torrents.delete(torrent_id) if res.status_code not in (200, 204): raise ValueError(f"Failed to delete torrent: {res.text}") @@ -652,6 +661,75 @@ def delete_torrent(self, torrent_id: str) -> dict[str, Any]: self._write_json(self.cache_path, self.cache) return {"status": "success"} + def _add_to_trashcan(self, info: dict[str, Any]) -> None: + thash = info.get("hash") + if not thash: + return + self.trashcan[thash] = { + "hash": thash, + "name": info.get("filename") or info.get("original_filename") or "Unknown", + "bytes": info.get("bytes", 0), + "files": [ + { + "id": f.get("id"), + "path": f.get("path"), + "bytes": f.get("bytes"), + } + for f in info.get("files", []) + if f.get("selected") + ], + "deleted_at": utc_now_iso(), + } + self._write_json(self.trashcan_path, self.trashcan) + + def trash_torrents(self) -> list[dict[str, Any]]: + with self.lock: + results = [] + for thash, entry in self.trashcan.items(): + results.append( + { + "hash": thash, + "name": entry.get("name", "Unknown"), + "bytes": entry.get("bytes", 0), + "file_count": len(entry.get("files", [])), + "deleted_at": entry.get("deleted_at"), + } + ) + return sorted(results, key=lambda x: x["deleted_at"] or "", reverse=True) + + def restore_trash(self, thash: str) -> dict[str, Any]: + with self.lock: + entry = self.trashcan.get(thash) + if not entry: + raise ValueError("Torrent not found in trashcan") + + magnet = f"magnet:?xt=urn:btih:{thash}" + res = self.client.torrents.add_magnet(magnet).json() + torrent_id = res.get("id") + if not torrent_id: + raise ValueError(f"Failed to restore torrent: {res}") + + file_ids = [str(f["id"]) for f in entry.get("files", []) if f.get("id")] + if file_ids: + try: + self.select_files(torrent_id, file_ids) + except Exception as exc: + print(f"Failed to auto-select files during restore: {exc}", flush=True) + + with self.lock: + if thash in self.trashcan: + del self.trashcan[thash] + self._write_json(self.trashcan_path, self.trashcan) + + return {"status": "success", "id": torrent_id} + + def delete_trash_permanently(self, thash: str) -> dict[str, Any]: + with self.lock: + if thash in self.trashcan: + del self.trashcan[thash] + self._write_json(self.trashcan_path, self.trashcan) + return {"status": "success"} + def select_files(self, torrent_id: str, file_ids: list[str]) -> dict[str, Any]: files_str = ",".join(str(f) for f in file_ids) res = self.client.torrents.select_files(torrent_id, files_str) diff --git a/buzz/dav_app.py b/buzz/dav_app.py index af45a5e..a3916c4 100644 --- a/buzz/dav_app.py +++ b/buzz/dav_app.py @@ -22,6 +22,8 @@ AddTorrentRequest, DavConfig, DeleteTorrentRequest, + RestoreTrashRequest, + DeleteTrashRequest, ErrorResponse, SelectFilesRequest, ) @@ -68,6 +70,10 @@ def _setup_routes(self): def index(): return self._torrents_page() + @self.app.get("/trashcan", response_class=HTMLResponse) + def trashcan(): + return self._trashcan_page() + @self.app.get("/healthz") def healthz(): return {"status": "ok", **self.state.status()} @@ -124,6 +130,28 @@ def delete_torrent(payload: DeleteTorrentRequest): except Exception as exc: return JSONResponse(status_code=500, content={"error": str(exc)}) + @self.app.post( + "/api/torrents/restore", + responses={400: {"model": ErrorResponse}, 500: {"model": ErrorResponse}}, + ) + def restore_trash(payload: RestoreTrashRequest): + try: + result = self.state.restore_trash(payload.hash) + return result + except Exception as exc: + return JSONResponse(status_code=500, content={"error": str(exc)}) + + @self.app.post( + "/api/torrents/delete_permanently", + responses={400: {"model": ErrorResponse}, 500: {"model": ErrorResponse}}, + ) + def delete_trash_permanently(payload: DeleteTrashRequest): + try: + result = self.state.delete_trash_permanently(payload.hash) + return result + except Exception as exc: + return JSONResponse(status_code=500, content={"error": str(exc)}) + @self.app.post("/api/curator/rebuild") def curator_rebuild(): try: @@ -271,21 +299,21 @@ def _torrents_page(self) -> str: torrent_id = torrent["id"] rows.append( "" - f"{html_escape(torrent['name'])}" + f"
{html_escape(torrent['name'])}
" f'[{html_escape(torrent["status"])}]' f"{html_escape(torrent['progress'])}%" f"{html_escape(format_bytes(torrent['bytes']))}" - f"{html_escape(torrent['selected_files'])}" - f"{html_escape(torrent['links'])}" + f"{html_escape(str(torrent['selected_files']))}" + f"{html_escape(str(torrent['links']))}" f"{html_escape(torrent['ended'] or '-')}" f"{html_escape(torrent_id[:8])}" "" f'
' f'
' - f'
[Y]
' - f'
[N]
' + f'
[Y]
' + f'
[N]
' "
" - f'
[X]
' + f'
[X]
' "
" "" "" @@ -316,6 +344,59 @@ def _torrents_page(self) -> str: rows="".join(rows), ) + def _trashcan_page(self) -> str: + status = self.state.status() + torrents = self.state.trash_torrents() + rows = [] + for torrent in torrents: + thash = torrent["hash"] + rows.append( + "" + f"
{html_escape(torrent['name'])}
" + f"{html_escape(format_bytes(torrent['bytes']))}" + f"{html_escape(str(torrent['file_count']))}" + f"{html_escape(torrent['deleted_at'] or '-')}" + "" + f'
' + f'
' + f'
[Y]
' + f'
[N]
' + "
" + f'
[R]
' + f'
' + f'
[Y]
' + f'
[N]
' + "
" + f'
[D]
' + "
" + "" + "" + ) + if not rows: + rows.append( + 'Trashcan is empty.' + ) + + sync_state = "syncing" if status.get("sync_in_progress") else "idle" + error_html = "" + if status.get("last_error"): + error_html = ( + '
[ERROR] ' + f"{html_escape(status['last_error'])}
" + ) + + template = self.templates.get_template("trashcan.html") + return template.render( + torrents_count=len(torrents), + last_sync_at=html_escape(status.get("last_sync_at") or "never"), + sync_state=html_escape(sync_state), + snapshot_ready=html_escape( + "true" if status.get("snapshot_loaded") else "false" + ), + error_html=error_html, + rows="".join(rows), + ) + async def _handle_validation_error( self, request: Request, exc: Exception ) -> JSONResponse: diff --git a/buzz/models.py b/buzz/models.py index b63af64..b8ab952 100644 --- a/buzz/models.py +++ b/buzz/models.py @@ -160,3 +160,27 @@ def validate_torrent_id(cls, value: str) -> str: if not value: raise ValueError("Missing torrent_id") return value + + +class RestoreTrashRequest(BaseModel): + hash: str + + @field_validator("hash") + @classmethod + def validate_hash(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("Missing hash") + return value + + +class DeleteTrashRequest(BaseModel): + hash: str + + @field_validator("hash") + @classmethod + def validate_hash(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("Missing hash") + return value diff --git a/buzz/templates/torrents.html b/buzz/templates/torrents.html index 2c5bdbf..9b3b32f 100644 --- a/buzz/templates/torrents.html +++ b/buzz/templates/torrents.html @@ -36,6 +36,25 @@ .prompt b { color: var(--purple); font-weight: bold; } .prompt span { color: var(--fg); } + .nav-link { + color: var(--comment); + text-decoration: none; + cursor: pointer; + transition: all 0.2s ease; + } + .nav-link:hover { + color: var(--cyan); + text-shadow: 0 0 5px rgba(139, 233, 253, 0.5); + } + .nav-link.active { + color: var(--fg); + background: rgba(189, 147, 249, 0.2); + text-shadow: 0 0 5px rgba(189, 147, 249, 0.5); + border-radius: 4px; + padding: 0 4px; + } + .nav-sep { color: var(--comment); margin: 0 4px; } + .meta-bar { display: flex; flex-wrap: wrap; @@ -165,6 +184,22 @@ } tr:hover { background: var(--selection); } + .trunc-cell { + position: relative; + max-width: 1px; /* allows text-overflow in fixed tables */ + } + .trunc-content { + display: inline-block; + white-space: nowrap; + } + .trunc-cell:hover .trunc-content { + animation: scroll-text 4s linear infinite; + } + @keyframes scroll-text { + 0%, 10% { transform: translateX(0); } + 90%, 100% { transform: translateX(calc(-100% + 200px)); } /* approximate width */ + } + .name { color: var(--fg); } /* Column widths */ @@ -230,7 +265,12 @@
-
buzz: list torrents
+
+ buzz: + 🪎 cache + + 🗑 trashcan +
[torrents] {{ torrents_count }}
[last_sync] {{ last_sync_at }}
diff --git a/buzz/templates/trashcan.html b/buzz/templates/trashcan.html new file mode 100644 index 0000000..c717044 --- /dev/null +++ b/buzz/templates/trashcan.html @@ -0,0 +1,439 @@ + + + + + + buzz: list torrents + + + + +
+
+ buzz: + 🪎 cache + + 🗑 trashcan +
+
+
[torrents] {{ torrents_count }}
+
[last_sync] {{ last_sync_at }}
+
[state] {{ sync_state }}
+
[ready] {{ snapshot_ready }}
+
+ +
+
+ + {{ error_html | safe }} + +
+ + + + + + + + + + + + {{ rows | safe }} + +
NameSizeFilesDate RemovedAct
+
+
+ + + + From 3b41aeae61a9cefd83eabf6005daa7fc172ff8e7 Mon Sep 17 00:00:00 2001 From: Gabriel Chamon Date: Tue, 14 Apr 2026 15:23:14 -0300 Subject: [PATCH 2/8] fix what's possibly a merge silent conflict --- docker-compose.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 03ff37d..e0d2644 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,8 +84,6 @@ services: profiles: ["jellyfin"] image: jellyfin/jellyfin container_name: jellyfin - devices: - - "/dev/dri:/dev/dri" depends_on: rclone: condition: service_healthy From 69b43347a8eb0ede8713a1eefa49834ebb781d9e Mon Sep 17 00:00:00 2001 From: Gabriel Chamon Date: Tue, 14 Apr 2026 15:29:59 -0300 Subject: [PATCH 3/8] fix buzz config not being mapped --- docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index e0d2644..d4a909c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,7 @@ services: volumes: - ./data:/app/data - /mnt/buzz/raw:/mnt/buzz/raw + - ./buzz.yml:/app/buzz.yml networks: default: aliases: @@ -129,6 +130,7 @@ services: volumes: - ./presentation/overrides.yml:/config/overrides.yml:ro - ./state/curator:/state + - ./buzz.yml:/app/buzz.yml - /mnt/buzz/curated:/mnt/buzz/curated - /mnt/buzz/raw:/mnt/buzz/raw:ro restart: unless-stopped From 723956e9a5d13d31903694bd43309b685fbda75c Mon Sep 17 00:00:00 2001 From: Gabriel Chamon Date: Tue, 14 Apr 2026 15:31:02 -0300 Subject: [PATCH 4/8] removing unused files; buzz config now readonly --- docker-compose.yml | 47 +------------ scripts/healthcheck.sh | 26 -------- scripts/presentation_builder.py | 115 -------------------------------- 3 files changed, 2 insertions(+), 186 deletions(-) delete mode 100644 scripts/healthcheck.sh delete mode 100644 scripts/presentation_builder.py diff --git a/docker-compose.yml b/docker-compose.yml index d4a909c..ef543e9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,7 +26,7 @@ services: volumes: - ./data:/app/data - /mnt/buzz/raw:/mnt/buzz/raw - - ./buzz.yml:/app/buzz.yml + - ./buzz.yml:/app/buzz.yml:ro networks: default: aliases: @@ -130,50 +130,7 @@ services: volumes: - ./presentation/overrides.yml:/config/overrides.yml:ro - ./state/curator:/state - - ./buzz.yml:/app/buzz.yml + - ./buzz.yml:/app/buzz.yml:rp - /mnt/buzz/curated:/mnt/buzz/curated - /mnt/buzz/raw:/mnt/buzz/raw:ro restart: unless-stopped -# plex-healthcheck: -# profiles: ["plex"] -# image: docker:dind -# container_name: plex-healthcheck -# environment: -# - TARGET_CONTAINER=plex -# - SELF_CONTAINER=plex-healthcheck -# - HEALTHCHECK_PATH=/mnt/buzz/raw/movies -# - HEALTHCHECK_VERBOSE=${HEALTHCHECK_VERBOSE:-false} -# volumes: -# - /var/run/docker.sock:/var/run/docker.sock -# - ./scripts/healthcheck.sh:/app/healthcheck.sh -# - /mnt/buzz/raw:/mnt/buzz/raw:ro -# entrypoint: [] -# command: "ash /app/healthcheck.sh" -# depends_on: -# rclone: -# condition: service_healthy -# plex: -# condition: service_started -# restart: unless-stopped -# -# jellyfin-healthcheck: -# profiles: ["jellyfin"] -# image: docker:dind -# container_name: jellyfin-healthcheck -# environment: -# - TARGET_CONTAINER=jellyfin -# - SELF_CONTAINER=jellyfin-healthcheck -# - HEALTHCHECK_PATH=/mnt/buzz/raw/movies -# - HEALTHCHECK_VERBOSE=${HEALTHCHECK_VERBOSE:-false} -# volumes: -# - /var/run/docker.sock:/var/run/docker.sock -# - ./scripts/healthcheck.sh:/app/healthcheck.sh -# - /mnt/buzz/raw:/mnt/buzz/raw:ro -# entrypoint: [] -# command: "ash /app/healthcheck.sh" -# depends_on: -# rclone: -# condition: service_healthy -# jellyfin: -# condition: service_started -# restart: unless-stopped diff --git a/scripts/healthcheck.sh b/scripts/healthcheck.sh deleted file mode 100644 index 4fa713a..0000000 --- a/scripts/healthcheck.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env ash - -target_container="${TARGET_CONTAINER:-plex}" -self_container="${SELF_CONTAINER:-${HOSTNAME}}" -healthcheck_path="${HEALTHCHECK_PATH:-/mnt/buzz/movies}" -healthcheck_verbose="${HEALTHCHECK_VERBOSE:-false}" - -is_truthy() { - case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in - 1|true|yes|on) return 0 ;; - *) return 1 ;; - esac -} - -while true; do - if ! ls "$healthcheck_path" 2>&1 >/dev/null; then - echo rclone mountpoint seems to be down, restarting... - docker container restart "$target_container" - docker container restart "$self_container" - else - if is_truthy "$healthcheck_verbose"; then - echo rclone mountpoint seems to be working for now... - fi - fi - sleep 10 -done diff --git a/scripts/presentation_builder.py b/scripts/presentation_builder.py deleted file mode 100644 index dcc6b7f..0000000 --- a/scripts/presentation_builder.py +++ /dev/null @@ -1,115 +0,0 @@ -import json -import sys -import traceback -from http import HTTPStatus -from http.server import BaseHTTPRequestHandler - -from buzz.core import curator as curator_mod -from buzz.core.curator import Curator, PresentationConfig as Config, RebuildError - - -def _log_mapping_event(diff: dict, report: dict, mapping_entries: int): - print( - json.dumps( - { - "event": "presentation_builder_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, - ) - - -def build_library(config: Config): - original_log_mapping_event = curator_mod.log_mapping_event - curator_mod.log_mapping_event = _log_mapping_event - try: - return curator_mod.build_library(config) - finally: - curator_mod.log_mapping_event = original_log_mapping_event - - -def trigger_jellyfin_scan(config: Config): - return curator_mod.trigger_jellyfin_scan(config) - - -def rebuild_and_trigger(config: Config): - report = build_library(config) - if config.skip_jellyfin_scan: - report["jellyfin_scan_triggered"] = False - report["jellyfin_scan_status"] = "skipped_configured" - report["jellyfin_scan_error"] = None - return report - if not config.jellyfin_api_key: - report["jellyfin_scan_triggered"] = False - report["jellyfin_scan_status"] = "skipped_missing_auth" - report["jellyfin_scan_error"] = None - return report - try: - trigger_jellyfin_scan(config) - 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 - return report - - -class Handler(BaseHTTPRequestHandler): - app = None - - def do_POST(self): - if self.path != "/rebuild": - self.respond(HTTPStatus.NOT_FOUND, {"error": "not found"}) - return - length = int(self.headers.get("Content-Length", "0")) - if length: - self.rfile.read(length) - try: - report = self.app.handle_rebuild() - except Exception as exc: - payload = {"error": str(exc)} - if isinstance(exc, RebuildError): - payload.update(exc.payload) - print( - f"presentation-builder rebuild failed: {exc}\n" - f"{traceback.format_exc()}", - file=sys.stderr, - flush=True, - ) - self.respond(HTTPStatus.INTERNAL_SERVER_ERROR, payload) - return - self.respond(HTTPStatus.OK, report) - - def log_message(self, format, *args): - return - - def respond(self, status: HTTPStatus, payload: dict): - body = json.dumps(payload, sort_keys=True).encode("utf-8") - self.send_response(status) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - -__all__ = [ - "build_library", - "Config", - "Curator", - "Handler", - "RebuildError", - "rebuild_and_trigger", - "trigger_jellyfin_scan", -] From d7cec8893a68e80d17bd6c8d80300a7dc62a4eed Mon Sep 17 00:00:00 2001 From: Gabriel Chamon Date: Tue, 14 Apr 2026 18:11:24 -0300 Subject: [PATCH 5/8] fixed ACT elements --- buzz/curator_app.py | 14 + buzz/dav_app.py | 10 +- buzz/templates/torrents.html | 487 +++++++++++++++++++++++++++-------- buzz/templates/trashcan.html | 412 ++++++++++++++++++----------- 4 files changed, 654 insertions(+), 269 deletions(-) diff --git a/buzz/curator_app.py b/buzz/curator_app.py index 3c173d8..bf7aaa4 100644 --- a/buzz/curator_app.py +++ b/buzz/curator_app.py @@ -45,6 +45,20 @@ def rebuild(): payload = {"error": str(exc)} if isinstance(exc, RebuildError): payload.update(exc.payload) + + from urllib.error import HTTPError + + if isinstance(exc.__cause__, HTTPError) and exc.__cause__.code in ( + 401, + 403, + ): + print( + f"curator rebuild failed: Jellyfin API Token is invalid or unauthorized", + flush=True, + ) + payload["error"] = "Jellyfin API Token is invalid or unauthorized" + return JSONResponse(status_code=403, content=payload) + print( f"curator rebuild failed: {exc}\n{traceback.format_exc()}", flush=True, diff --git a/buzz/dav_app.py b/buzz/dav_app.py index a3916c4..cf34bc2 100644 --- a/buzz/dav_app.py +++ b/buzz/dav_app.py @@ -310,7 +310,7 @@ def _torrents_page(self) -> str: "" f'
' f'
' - f'
[Y]
' + f'
[Y]
' f'
[N]
' "
" f'
[X]
' @@ -356,19 +356,17 @@ def _trashcan_page(self) -> str: f"{html_escape(format_bytes(torrent['bytes']))}" f"{html_escape(str(torrent['file_count']))}" f"{html_escape(torrent['deleted_at'] or '-')}" - "" - f'
' + f'' f'
' - f'
[Y]
' + f'
[Y]
' f'
[N]
' "
" f'
[R]
' f'
' - f'
[Y]
' + f'
[Y]
' f'
[N]
' "
" f'
[D]
' - "
" "" "" ) diff --git a/buzz/templates/torrents.html b/buzz/templates/torrents.html index 9b3b32f..8f3cf32 100644 --- a/buzz/templates/torrents.html +++ b/buzz/templates/torrents.html @@ -1,10 +1,12 @@ + buzz: list torrents - + +
@@ -273,14 +488,16 @@
[torrents] {{ torrents_count }}
-
[last_sync] {{ last_sync_at }}
-
[state] {{ sync_state }}
-
[ready] {{ snapshot_ready }}
+
[last_sync] {{ last_sync_at }}
+
[state] {{ sync_state }}
+
[ready]
- +
- + {{ error_html | safe }}
@@ -288,7 +505,7 @@ Add New Torrent
- +
@@ -312,7 +529,7 @@
- +
@@ -344,7 +561,7 @@ const btn = document.getElementById('resolve-btn'); const status = document.getElementById('add-status'); - + btn.disabled = true; status.innerText = "Resolving magnet..."; status.style.color = "var(--orange)"; @@ -352,10 +569,10 @@ try { const res = await fetch('/api/torrents/add', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ magnet }) + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({magnet}) }); - + const data = await res.json(); if (data.error) throw new Error(data.error); @@ -368,15 +585,15 @@ currentTorrentId = data.id; document.getElementById('torrent-name-display').innerText = data.filename || "Torrent Files"; - + const fileList = document.getElementById('file-list'); fileList.innerHTML = ''; - + data.files.forEach(file => { const div = document.createElement('div'); div.className = 'file-item'; const isVideo = /\.(mkv|mp4|avi|m4v|mov)$/i.test(file.path); - + div.innerHTML = `
@@ -402,7 +619,7 @@ async function confirmTorrent() { const checkboxes = document.querySelectorAll('#file-list input[type="checkbox"]:checked'); const fileIds = Array.from(checkboxes).map(cb => cb.value); - + if (fileIds.length === 0) { alert("Please select at least one file"); return; @@ -410,27 +627,27 @@ const btn = document.getElementById('confirm-btn'); const status = document.getElementById('add-status'); - + btn.disabled = true; status.innerText = "Starting cache..."; try { const res = await fetch('/api/torrents/select', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ torrent_id: currentTorrentId, file_ids: fileIds }) + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({torrent_id: currentTorrentId, file_ids: fileIds}) }); - + const data = await res.json(); if (data.error) throw new Error(data.error); status.innerText = "Torrent added! Refreshing..."; status.style.color = "var(--green)"; - + resetAddForm(); // Trigger sync and refresh page - await fetch('/sync', { method: 'POST' }); + await fetch('/sync', {method: 'POST'}); setTimeout(() => location.reload(), 1000); } catch (err) { status.innerText = "Error: " + err.message; @@ -444,7 +661,7 @@ status.innerText = "Resyncing library..."; status.style.color = "var(--orange)"; try { - const res = await fetch('/api/curator/rebuild', { method: 'POST' }); + const res = await fetch('/api/curator/rebuild', {method: 'POST'}); const data = await res.json(); if (data.error) throw new Error(data.error); status.innerText = "Library resynced!"; @@ -474,19 +691,28 @@ function toggleDelete(id, show) { document.getElementById('confirm-' + id).style.display = show ? 'flex' : 'none'; + document.getElementById('btn-x-' + id).style.display = show ? 'none' : 'flex'; } - async function deleteTorrent(id) { + async function deleteTorrent(id, el) { + if (el) { + el.innerText = '...'; + el.style.pointerEvents = 'none'; + } try { const res = await fetch('/api/torrents/delete', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ torrent_id: id }) + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({torrent_id: id}) }); const data = await res.json(); if (data.error) throw new Error(data.error); location.reload(); } catch (err) { + if (el) { + el.innerText = '[Y]'; + el.style.pointerEvents = 'auto'; + } alert("Delete failed: " + err.message); } } @@ -499,12 +725,50 @@ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } + async function pollStatus() { + try { + const res = await fetch('/healthz'); + if (!res.ok) throw new Error('Offline'); + const data = await res.json(); + + document.getElementById('status-sync').innerText = data.sync_in_progress ? 'syncing' : 'idle'; + document.getElementById('status-last-sync').innerText = data.last_sync_at || 'never'; + + const readyLabel = document.getElementById('status-ready-label'); + if (data.snapshot_loaded) { + readyLabel.innerText = '[ready]'; + readyLabel.style.color = 'var(--green)'; + } else { + readyLabel.innerText = '[starting]'; + readyLabel.style.color = 'var(--orange)'; + } + } catch (err) { + document.getElementById('status-sync').innerText = 'unknown'; + const readyLabel = document.getElementById('status-ready-label'); + readyLabel.innerText = '[offline]'; + readyLabel.style.color = 'var(--cyan)'; + } + } + + // Initial setup based on template variables + const initReady = document.getElementById('status-ready').innerText === 'true'; + const readyLabel = document.getElementById('status-ready-label'); + if (initReady) { + readyLabel.innerText = '[ready]'; + readyLabel.style.color = 'var(--green)'; + } else { + readyLabel.innerText = '[starting]'; + readyLabel.style.color = 'var(--orange)'; + } + + setInterval(pollStatus, 3000); + function sortTable(n) { const table = document.getElementById("torrent-table"); let rows, switching, i, x, y, shouldSwitch, dir, switchcount = 0; switching = true; dir = "asc"; - + const headers = table.getElementsByTagName("th"); for (let h = 0; h < headers.length; h++) { if (h === n) { @@ -530,10 +794,10 @@ shouldSwitch = false; x = rows[i].getElementsByTagName("TD")[n]; y = rows[i + 1].getElementsByTagName("TD")[n]; - + let valX = x.getAttribute("data-value") || x.innerText.toLowerCase(); let valY = y.getAttribute("data-value") || y.innerText.toLowerCase(); - + const numX = parseFloat(valX); const numY = parseFloat(valY); if (!isNaN(numX) && !isNaN(numY)) { @@ -567,4 +831,5 @@ } + diff --git a/buzz/templates/trashcan.html b/buzz/templates/trashcan.html index c717044..dd86628 100644 --- a/buzz/templates/trashcan.html +++ b/buzz/templates/trashcan.html @@ -3,7 +3,7 @@ - buzz: list torrents + buzz: trashcan
+
[torrents] {{ torrents_count }}
-
[last_sync] {{ last_sync_at }}
-
[state] {{ sync_state }}
-
[ready] {{ snapshot_ready }}
+
[last_sync] {{ last_sync_at }}
+
[state] {{ sync_state }}
+
+ [ready] + +
- +
- + {{ error_html | safe }}
@@ -307,6 +360,7 @@ status.innerText = " Resyncing library..."; status.style.color = "var(--orange)"; document.querySelector('.prompt').appendChild(status); + try { const res = await fetch('/api/curator/rebuild', { method: 'POST' }); const data = await res.json(); @@ -317,18 +371,28 @@ status.innerText = " Resync failed: " + err.message; status.style.color = "var(--red)"; } + setTimeout(() => status.remove(), 3000); } function toggleRestore(id, show) { document.getElementById('confirm-restore-' + id).style.display = show ? 'flex' : 'none'; + document.getElementById('btn-r-' + id).style.display = show ? 'none' : 'flex'; + document.getElementById('btn-del-' + id).style.display = show ? 'none' : 'flex'; } function toggleDel(id, show) { document.getElementById('confirm-del-' + id).style.display = show ? 'flex' : 'none'; + document.getElementById('btn-r-' + id).style.display = show ? 'none' : 'flex'; + document.getElementById('btn-del-' + id).style.display = show ? 'none' : 'flex'; } - async function restoreTrash(hash) { + async function restoreTrash(hash, el) { + if (el) { + el.innerText = '...'; + el.style.pointerEvents = 'none'; + } + try { const res = await fetch('/api/torrents/restore', { method: 'POST', @@ -337,15 +401,24 @@ }); const data = await res.json(); if (data.error) throw new Error(data.error); - // Trigger sync and refresh page + await fetch('/sync', { method: 'POST' }); location.reload(); } catch (err) { + if (el) { + el.innerText = '[Y]'; + el.style.pointerEvents = 'auto'; + } alert("Restore failed: " + err.message); } } - async function deleteTrash(hash) { + async function deleteTrash(hash, el) { + if (el) { + el.innerText = '...'; + el.style.pointerEvents = 'none'; + } + try { const res = await fetch('/api/torrents/delete_permanently', { method: 'POST', @@ -356,24 +429,57 @@ if (data.error) throw new Error(data.error); location.reload(); } catch (err) { + if (el) { + el.innerText = '[Y]'; + el.style.pointerEvents = 'auto'; + } alert("Delete failed: " + err.message); } } - function formatBytes(bytes) { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + async function pollStatus() { + try { + const res = await fetch('/healthz'); + if (!res.ok) throw new Error('Offline'); + const data = await res.json(); + + document.getElementById('status-sync').innerText = data.sync_in_progress ? 'syncing' : 'idle'; + document.getElementById('status-last-sync').innerText = data.last_sync_at || 'never'; + + const readyLabel = document.getElementById('status-ready-label'); + if (data.snapshot_loaded) { + readyLabel.innerText = '[ready]'; + readyLabel.style.color = 'var(--green)'; + } else { + readyLabel.innerText = '[starting]'; + readyLabel.style.color = 'var(--orange)'; + } + } catch (err) { + document.getElementById('status-sync').innerText = 'unknown'; + const readyLabel = document.getElementById('status-ready-label'); + readyLabel.innerText = '[offline]'; + readyLabel.style.color = 'var(--cyan)'; + } + } + + const initReady = document.getElementById('status-ready').innerText === 'true'; + const readyLabel = document.getElementById('status-ready-label'); + if (initReady) { + readyLabel.innerText = '[ready]'; + readyLabel.style.color = 'var(--green)'; + } else { + readyLabel.innerText = '[starting]'; + readyLabel.style.color = 'var(--orange)'; } + setInterval(pollStatus, 3000); + function sortTable(n) { const table = document.getElementById("torrent-table"); let rows, switching, i, x, y, shouldSwitch, dir, switchcount = 0; switching = true; dir = "asc"; - + const headers = table.getElementsByTagName("th"); for (let h = 0; h < headers.length; h++) { if (h === n) { @@ -395,14 +501,15 @@ while (switching) { switching = false; rows = table.rows; + for (i = 1; i < (rows.length - 1); i++) { shouldSwitch = false; x = rows[i].getElementsByTagName("TD")[n]; y = rows[i + 1].getElementsByTagName("TD")[n]; - + let valX = x.getAttribute("data-value") || x.innerText.toLowerCase(); let valY = y.getAttribute("data-value") || y.innerText.toLowerCase(); - + const numX = parseFloat(valX); const numY = parseFloat(valY); if (!isNaN(numX) && !isNaN(numY)) { @@ -422,6 +529,7 @@ } } } + if (shouldSwitch) { rows[i].parentNode.insertBefore(rows[i + 1], rows[i]); switching = true; From 64d97eb91b672c974a8b22b1f5f946ad9e047046 Mon Sep 17 00:00:00 2001 From: Gabriel Chamon Date: Tue, 14 Apr 2026 19:17:54 -0300 Subject: [PATCH 6/8] multiple changes - remove presentation builder leftovers - transplant the tests to curator - extract css and js lib - refactor leftovers from the fastapi migration, when we didn't have jinja2 --- README.md | 2 +- buzz/dav_app.py | 122 ++---- buzz/static/buzz.css | 312 ++++++++++++++ buzz/static/buzz.js | 213 ++++++++++ buzz/templates/torrents.html | 657 +++++------------------------ buzz/templates/trashcan.html | 517 +++-------------------- tests/test_buzz.py | 45 ++ tests/test_curator_app.py | 164 ++++++- tests/test_presentation_builder.py | 189 --------- 9 files changed, 951 insertions(+), 1270 deletions(-) create mode 100644 buzz/static/buzz.css create mode 100644 buzz/static/buzz.js delete mode 100644 tests/test_presentation_builder.py diff --git a/README.md b/README.md index 6ec7854..19fadd0 100644 --- a/README.md +++ b/README.md @@ -119,5 +119,5 @@ python3 scripts/migrate_config.py --from buzz --to zurg buzz.yml -o config.yml Run tests locally with: ```sh -uv run python -m unittest tests.test_buzz tests.test_curator_app +uv run python -m unittest discover -s tests ``` diff --git a/buzz/dav_app.py b/buzz/dav_app.py index cf34bc2..6ebf1b4 100644 --- a/buzz/dav_app.py +++ b/buzz/dav_app.py @@ -10,12 +10,12 @@ from fastapi import FastAPI, Request, Response from fastapi.exceptions import RequestValidationError from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles from rdapi import RD from .dav_protocol import open_remote_media, propfind_body from .core.utils import ( format_bytes, - html_escape, http_date, ) from .models import ( @@ -59,7 +59,13 @@ async def lifespan(app: FastAPI): 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._setup_routes() @@ -294,105 +300,61 @@ def stream_generator(): def _torrents_page(self) -> str: status = self.state.status() torrents = self.state.torrents() - rows = [] + page_torrents = [] for torrent in torrents: - torrent_id = torrent["id"] - rows.append( - "
" - f"" - f'' - f"" - f"" - f"" - f"" - f"" - f"" - "" - "" - ) - if not rows: - rows.append( - '" + page_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], + } ) sync_state = "syncing" if status.get("sync_in_progress") else "idle" - error_html = "" - if status.get("last_error"): - error_html = ( - '
[ERROR] ' - f"{html_escape(status['last_error'])}
" - ) template = self.templates.get_template("torrents.html") return template.render( torrents_count=len(torrents), - last_sync_at=html_escape(status.get("last_sync_at") or "never"), - sync_state=html_escape(sync_state), - snapshot_ready=html_escape( - "true" if status.get("snapshot_loaded") else "false" - ), - error_html=error_html, - rows="".join(rows), + 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"), + torrents=page_torrents, ) def _trashcan_page(self) -> str: status = self.state.status() torrents = self.state.trash_torrents() - rows = [] + trash_torrents = [] for torrent in torrents: - thash = torrent["hash"] - rows.append( - "" - f"" - f"" - f"" - f"" - f'" - "" - ) - if not rows: - rows.append( - '' + trash_torrents.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" - error_html = "" - if status.get("last_error"): - error_html = ( - '
[ERROR] ' - f"{html_escape(status['last_error'])}
" - ) template = self.templates.get_template("trashcan.html") return template.render( torrents_count=len(torrents), - last_sync_at=html_escape(status.get("last_sync_at") or "never"), - sync_state=html_escape(sync_state), - snapshot_ready=html_escape( - "true" if status.get("snapshot_loaded") else "false" - ), - error_html=error_html, - rows="".join(rows), + 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, ) async def _handle_validation_error( diff --git a/buzz/static/buzz.css b/buzz/static/buzz.css new file mode 100644 index 0000000..7b9ae84 --- /dev/null +++ b/buzz/static/buzz.css @@ -0,0 +1,312 @@ +:root { + --bg: #282a36; + --fg: #f8f8f2; + --selection: #44475a; + --comment: #6272a4; + --cyan: #8be9fd; + --green: #50fa7b; + --orange: #ffb86c; + --pink: #ff79c6; + --purple: #bd93f9; + --red: #ff5555; + --yellow: #f1fa8c; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + background: var(--bg); + color: var(--fg); + line-height: 1.5; +} + +main { + padding: 20px; + max-width: 1400px; + margin: 0 auto; +} + +.prompt { + margin-bottom: 4px; + font-size: 1.1rem; +} + +.prompt b { + color: var(--purple); + font-weight: bold; +} + +.prompt span { + color: var(--fg); +} + +.nav-link { + color: var(--comment); + text-decoration: none; + cursor: pointer; + transition: all 0.2s ease; +} + +.nav-link:hover { + color: var(--cyan); + text-shadow: 0 0 5px rgba(139, 233, 253, 0.5); +} + +.nav-link.active { + color: var(--green); + text-shadow: 0 0 5px rgba(80, 250, 123, 0.5); +} + +.nav-sep { + color: var(--comment); + margin: 0 4px; +} + +.meta-bar { + display: flex; + flex-wrap: wrap; + gap: 20px; + margin-bottom: 24px; + font-size: 0.9rem; + color: var(--comment); +} + +.meta-item b { + color: var(--orange); + font-weight: normal; +} + +.meta-item span { + color: var(--cyan); +} + +button { + background: var(--purple); + color: var(--bg); + border: none; + padding: 8px 16px; + cursor: pointer; + font-family: inherit; + font-weight: bold; + text-transform: uppercase; + font-size: 0.8rem; +} + +button:hover { + filter: brightness(1.1); +} + +button:disabled { + background: var(--comment); + cursor: not-allowed; +} + +button.secondary { + background: var(--comment); + color: var(--fg); +} + +.error { + margin-bottom: 20px; + color: var(--red); + border-left: 3px solid var(--red); + padding-left: 10px; +} + +.label-red { + color: var(--red); + font-weight: bold; +} + +.table-wrap { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; + table-layout: fixed; +} + +th { + text-align: left; + padding: 8px 4px; + border-bottom: 1px solid var(--selection); + color: var(--pink); + text-transform: uppercase; + letter-spacing: 1px; + cursor: pointer; + user-select: none; + position: relative; + text-overflow: ellipsis; + white-space: nowrap; +} + +th:hover { + color: var(--purple); +} + +th.sort-asc::after { + content: "▲"; + font-size: 0.6rem; + margin-left: 2px; +} + +th.sort-desc::after { + content: "▼"; + font-size: 0.6rem; + margin-left: 2px; +} + +td { + padding: 0; + border-bottom: 1px solid rgba(68, 71, 90, 0.3); + vertical-align: middle; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + height: 35px; +} + +.td-content { + padding: 8px 4px; + display: flex; + align-items: center; + height: 100%; + width: 100%; +} + +tr:hover { + background: var(--selection); +} + +.trunc-cell { + position: relative; + max-width: 1px; +} + +.trunc-content { + display: inline-block; + white-space: nowrap; +} + +.trunc-cell:hover .trunc-content { + animation: scroll-text 4s linear infinite; +} + +@keyframes scroll-text { + 0%, + 10% { + transform: translateX(0); + } + + 90%, + 100% { + transform: translateX(calc(-100% + 200px)); + } +} + +.name { + color: var(--fg); +} + +.comment { + color: var(--comment); +} + +.yellow { + color: var(--yellow); +} + +code { + font-family: inherit; +} + +.empty { + color: var(--comment); + text-align: center; + padding: 40px; +} + +.delete-container { + display: flex; + width: 100%; + height: 100%; + min-height: 35px; + align-items: stretch; +} + +.confirm-opts { + display: none; + flex-direction: row; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + background: rgba(255, 184, 108, 0.2); + gap: 15px; +} + +.opt { + cursor: pointer; + text-align: center; + font-weight: bold; + color: var(--orange); +} + +.opt-y:hover { + color: var(--green); + text-shadow: 0 0 5px rgba(80, 250, 123, 0.5); +} + +.opt-n:hover { + color: var(--red); + text-shadow: 0 0 5px rgba(255, 85, 85, 0.5); +} + +.btn-r, +.btn-del, +.btn-x { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + height: 100%; + cursor: pointer; + font-weight: bold; + padding: 0; + color: var(--comment); +} + +.btn-r:hover { + color: var(--green); + background: rgba(80, 250, 123, 0.1); +} + +.btn-del:hover, +.btn-x:hover { + color: var(--red); + background: rgba(255, 85, 85, 0.1); +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg); +} + +::-webkit-scrollbar-thumb { + background: var(--selection); +} + +::-webkit-scrollbar-thumb:hover { + background: var(--comment); +} diff --git a/buzz/static/buzz.js b/buzz/static/buzz.js new file mode 100644 index 0000000..3e2fc47 --- /dev/null +++ b/buzz/static/buzz.js @@ -0,0 +1,213 @@ +let buzzPageConfig = { + tableId: "torrent-table", + rebuildStatusTarget: null, + statusSyncId: "status-sync", + statusLastSyncId: "status-last-sync", + statusReadyId: "status-ready", + statusReadyLabelId: "status-ready-label", + pollIntervalMs: 3000, +}; + +function getBuzzElement(id) { + return id ? document.getElementById(id) : null; +} + +function setReadyLabel(isReady, offline) { + const readyLabel = getBuzzElement(buzzPageConfig.statusReadyLabelId); + if (!readyLabel) { + return; + } + + if (offline) { + readyLabel.innerText = "[offline]"; + readyLabel.style.color = "var(--cyan)"; + return; + } + + if (isReady) { + readyLabel.innerText = "[ready]"; + readyLabel.style.color = "var(--green)"; + } else { + readyLabel.innerText = "[starting]"; + readyLabel.style.color = "var(--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; +} + +async function triggerManualRebuild() { + const status = getRebuildStatusNode(); + if (status) { + status.innerText = buzzPageConfig.rebuildStatusTarget === "prompt" + ? " Resyncing library..." + : "Resyncing library..."; + status.style.color = "var(--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 = buzzPageConfig.rebuildStatusTarget === "prompt" + ? " Library resynced!" + : "Library resynced!"; + status.style.color = "var(--green)"; + } + } catch (err) { + if (status) { + status.innerText = buzzPageConfig.rebuildStatusTarget === "prompt" + ? " Resync failed: " + err.message + : "Resync failed: " + err.message; + status.style.color = "var(--red)"; + } + } + + if (status && buzzPageConfig.rebuildStatusTarget === "prompt") { + setTimeout(() => status.remove(), 3000); + } +} + +async function pollStatus() { + const statusSync = getBuzzElement(buzzPageConfig.statusSyncId); + const statusLastSync = getBuzzElement(buzzPageConfig.statusLastSyncId); + + try { + const res = await fetch("/healthz"); + if (!res.ok) { + throw new Error("Offline"); + } + + const data = await res.json(); + if (statusSync) { + statusSync.innerText = data.sync_in_progress ? "syncing" : "idle"; + } + if (statusLastSync) { + statusLastSync.innerText = data.last_sync_at || "never"; + } + setReadyLabel(data.snapshot_loaded, false); + } catch (err) { + if (statusSync) { + statusSync.innerText = "unknown"; + } + setReadyLabel(false, true); + } +} + +function initializeReadyLabel() { + const statusReady = getBuzzElement(buzzPageConfig.statusReadyId); + if (!statusReady) { + return; + } + + setReadyLabel(statusReady.innerText === "true", false); +} + +function initBuzzPage(config) { + buzzPageConfig = { + ...buzzPageConfig, + ...config, + }; + + initializeReadyLabel(); + + if (buzzPageConfig.pollIntervalMs > 0) { + setInterval(pollStatus, buzzPageConfig.pollIntervalMs); + } +} + +function sortTable(n) { + const table = getBuzzElement(buzzPageConfig.tableId); + if (!table) { + return; + } + + let rows; + let switching = true; + let i; + let shouldSwitch; + let dir = "asc"; + let switchcount = 0; + + const headers = table.getElementsByTagName("th"); + 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"); + } + } + + while (switching) { + switching = false; + rows = table.rows; + + for (i = 1; i < rows.length - 1; i++) { + shouldSwitch = false; + const x = rows[i].getElementsByTagName("td")[n]; + const y = rows[i + 1].getElementsByTagName("td")[n]; + + let valX = x.getAttribute("data-value") || x.innerText.toLowerCase(); + let valY = y.getAttribute("data-value") || y.innerText.toLowerCase(); + + const numX = parseFloat(valX); + const numY = parseFloat(valY); + if (!Number.isNaN(numX) && !Number.isNaN(numY)) { + valX = numX; + valY = numY; + } + + if (dir === "asc") { + if (valX > valY) { + shouldSwitch = true; + break; + } + } else if (valX < valY) { + shouldSwitch = true; + break; + } + } + + if (shouldSwitch) { + rows[i].parentNode.insertBefore(rows[i + 1], rows[i]); + switching = true; + switchcount++; + } else if (switchcount === 0 && dir === "asc") { + dir = "desc"; + switching = true; + } + } +} diff --git a/buzz/templates/torrents.html b/buzz/templates/torrents.html index 8f3cf32..18a4feb 100644 --- a/buzz/templates/torrents.html +++ b/buzz/templates/torrents.html @@ -1,112 +1,12 @@ - buzz: list torrents - + + -
@@ -486,19 +128,25 @@ 🗑 trashcan
+
[torrents] {{ torrents_count }}
[last_sync] {{ last_sync_at }}
[state] {{ sync_state }}
-
[ready]
- + [ready] + +
+
+
- {{ error_html | safe }} + {% if last_error %} +
[ERROR] {{ last_error }}
+ {% endif %}
@@ -546,35 +194,64 @@
- {{ rows | safe }} + {% for torrent in torrents %} + + + + + + + + + + + + {% else %} + + + + {% endfor %}
{html_escape(torrent['name'])}
[{html_escape(torrent["status"])}]{html_escape(torrent['progress'])}%{html_escape(format_bytes(torrent['bytes']))}{html_escape(str(torrent['selected_files']))}{html_escape(str(torrent['links']))}{html_escape(torrent['ended'] or '-')}{html_escape(torrent_id[:8])}" - f'
' - f'
' - f'
[Y]
' - f'
[N]
' - "
" - f'
[X]
' - "
" - "
No cached torrents yet. ' - "Wait for the first sync or trigger POST /sync.
{html_escape(torrent['name'])}
{html_escape(format_bytes(torrent['bytes']))}{html_escape(str(torrent['file_count']))}{html_escape(torrent['deleted_at'] or '-')}' - f'
' - f'
[Y]
' - f'
[N]
' - "
" - f'
[R]
' - f'
' - f'
[Y]
' - f'
[N]
' - "
" - f'
[D]
' - "
Trashcan is empty.
{{ torrent.name }}
[{{ torrent.status }}]{{ torrent.progress }}%{{ torrent.size }}{{ torrent.selected_files }}{{ torrent.links }}{{ torrent.ended }}{{ torrent.short_id }} +
+
+
[Y]
+
[N]
+
+
[X]
+
+
No cached torrents yet. Wait for the first sync or trigger POST /sync.
+ - diff --git a/buzz/templates/trashcan.html b/buzz/templates/trashcan.html index dd86628..3733846 100644 --- a/buzz/templates/trashcan.html +++ b/buzz/templates/trashcan.html @@ -5,309 +5,13 @@ buzz: trashcan + @@ -334,7 +38,9 @@ - {{ error_html | safe }} + {% if last_error %} +
[ERROR] {{ last_error }}
+ {% endif %}
@@ -348,66 +54,72 @@ - {{ rows | safe }} + {% for torrent in trash_torrents %} + + + + + + + + {% else %} + + {% endfor %}
{{ torrent.name }}
{{ torrent.size }}{{ torrent.file_count }}{{ torrent.deleted_at }} +
+
+
[Y]
+
[N]
+
+
[R]
+
+
[Y]
+
[N]
+
+
[D]
+
+
Trashcan is empty.
+ diff --git a/tests/test_buzz.py b/tests/test_buzz.py index acb4905..e20d78c 100644 --- a/tests/test_buzz.py +++ b/tests/test_buzz.py @@ -769,6 +769,51 @@ def test_torrents_page_renders_cached_torrents(self): 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) + + def test_trashcan_page_renders_shared_assets(self): + self.state.trashcan = { + "trash-1": { + "name": "Old & Gone", + "bytes": 4096, + "file_count": 3, + "deleted_at": "2026-01-03T00:00:00Z", + "magnet": "magnet:?xt=urn:btih:trash-1", + } + } + + response = self.client.get("/trashcan") + body = response.text + + self.assertEqual(response.status_code, 200) + self.assertIn("buzz: trashcan", body) + self.assertIn("Old & Gone", body) + self.assertIn('href="/static/buzz.css"', body) + self.assertIn('src="/static/buzz.js"', body) + + def test_torrents_page_renders_empty_state_and_error_banner(self): + self.state.last_error = "Boom & stuff" + + response = self.client.get("/torrents") + body = response.text + + self.assertEqual(response.status_code, 200) + self.assertIn("No cached torrents yet.", body) + self.assertIn("Boom & stuff", body) + + def test_trashcan_page_renders_empty_state(self): + response = self.client.get("/trashcan") + body = response.text + + self.assertEqual(response.status_code, 200) + self.assertIn("Trashcan is empty.", 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) 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 1a77f29..6766eae 100644 --- a/tests/test_curator_app.py +++ b/tests/test_curator_app.py @@ -1,3 +1,5 @@ +import io +import json import tempfile import unittest from pathlib import Path @@ -5,7 +7,7 @@ from fastapi.testclient import TestClient -from buzz.core.curator import RebuildError, build_library +from buzz.core.curator import RebuildError, build_library, rebuild_and_trigger from buzz.curator_app import CuratorApp from buzz.models import PresentationConfig @@ -25,6 +27,15 @@ def _config(self, root: Path, **overrides) -> PresentationConfig: **defaults, ) + def _create_source_tree(self, source_root: Path): + movies = source_root / "movies" + shows = source_root / "shows" + anime = source_root / "anime" + movies.mkdir(parents=True, exist_ok=True) + shows.mkdir(parents=True, exist_ok=True) + anime.mkdir(parents=True, exist_ok=True) + (movies / "Movie.2026.1080p.mkv").write_text("video", encoding="utf-8") + def test_build_library_accepts_canonical_presentation_config(self): with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) @@ -64,9 +75,10 @@ def test_curator_lifespan_runs_startup_build(self): (root / "raw" / "anime").mkdir(parents=True) app = CuratorApp(self._config(root, build_on_start=True)) - - with TestClient(app.app) as client: - response = client.get("/healthz") + stdout = io.StringIO() + with patch("sys.stdout", stdout): + with TestClient(app.app) as client: + response = client.get("/healthz") self.assertEqual(response.status_code, 200) self.assertTrue((root / "state" / "report.json").exists()) @@ -89,12 +101,154 @@ def test_curator_rebuild_error_payload_is_preserved(self): {"jellyfin_scan_status": "failed", "jellyfin_scan_triggered": False}, ), ): - response = client.post("/rebuild") + with patch("sys.stdout", io.StringIO()): + response = client.post("/rebuild") self.assertEqual(response.status_code, 500) self.assertEqual(response.json()["error"], "scan failed") self.assertEqual(response.json()["jellyfin_scan_status"], "failed") + def test_rebuild_and_trigger_skips_scan_when_configured(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + config = self._config(root, skip_jellyfin_scan=True) + self._create_source_tree(config.source_root) + + report = rebuild_and_trigger(config) + + self.assertEqual(report["movies"], 1) + self.assertFalse(report["jellyfin_scan_triggered"]) + self.assertEqual(report["jellyfin_scan_status"], "skipped_configured") + self.assertIsNone(report["jellyfin_scan_error"]) + + def test_rebuild_and_trigger_skips_scan_when_api_key_is_missing(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + config = self._config(root, skip_jellyfin_scan=False, jellyfin_api_key="") + self._create_source_tree(config.source_root) + + stdout = io.StringIO() + with patch("sys.stdout", stdout): + report = rebuild_and_trigger(config) + + self.assertEqual(report["movies"], 1) + self.assertFalse(report["jellyfin_scan_triggered"]) + self.assertEqual(report["jellyfin_scan_status"], "skipped_missing_auth") + self.assertIsNone(report["jellyfin_scan_error"]) + self.assertEqual(stdout.getvalue(), "") + + def test_rebuild_logs_mapping_when_verbose_is_enabled(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + config = self._config(root, verbose=True) + self._create_source_tree(config.source_root) + + stdout = io.StringIO() + with patch("sys.stdout", stdout): + report = rebuild_and_trigger(config) + + self.assertEqual(report["movies"], 1) + lines = [line for line in stdout.getvalue().splitlines() if line] + mapping_log = json.loads(lines[-1]) + self.assertEqual(mapping_log["event"], "curator_mapping_diff") + self.assertEqual(mapping_log["mapping_entries"], 1) + self.assertEqual(mapping_log["removed"], []) + self.assertEqual(mapping_log["changed"], []) + self.assertEqual( + mapping_log["added"], + [ + { + "source": "movies/Movie.2026.1080p.mkv", + "target": "movies/Movie (2026)/Movie (2026).mkv", + "type": "movie", + } + ], + ) + + def test_rebuild_logs_empty_diff_when_mapping_is_unchanged(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + config = self._config(root, verbose=True) + self._create_source_tree(config.source_root) + + with patch("sys.stdout", io.StringIO()): + rebuild_and_trigger(config) + + stdout = io.StringIO() + with patch("sys.stdout", stdout): + report = rebuild_and_trigger(config) + + self.assertEqual(report["movies"], 1) + lines = [line for line in stdout.getvalue().splitlines() if line] + mapping_log = json.loads(lines[-1]) + 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): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + config = self._config( + root, + skip_jellyfin_scan=False, + jellyfin_api_key="token", + jellyfin_scan_task_id="scan-task", + ) + self._create_source_tree(config.source_root) + + with patch("buzz.core.curator.trigger_jellyfin_scan") as trigger_scan: + report = rebuild_and_trigger(config) + + trigger_scan.assert_called_once_with(config) + self.assertTrue(report["jellyfin_scan_triggered"]) + self.assertEqual(report["jellyfin_scan_status"], "triggered") + self.assertIsNone(report["jellyfin_scan_error"]) + + def test_rebuild_and_trigger_raises_structured_error_for_scan_failure(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + config = self._config( + root, + skip_jellyfin_scan=False, + jellyfin_api_key="token", + jellyfin_scan_task_id="scan-task", + ) + self._create_source_tree(config.source_root) + + with patch( + "buzz.core.curator.trigger_jellyfin_scan", + side_effect=RuntimeError("scan failed"), + ): + with self.assertRaises(RebuildError) as ctx: + 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"]) + + def test_curator_rebuild_logs_unexpected_errors(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)) + client = TestClient(app.app) + + stdout = io.StringIO() + with patch.object(app.curator, "handle_rebuild", side_effect=RuntimeError("boom")): + with patch("sys.stdout", stdout): + response = client.post("/rebuild") + + self.assertEqual(response.status_code, 500) + self.assertEqual(response.json()["error"], "boom") + logged = stdout.getvalue() + self.assertIn("curator rebuild failed: boom", logged) + self.assertIn("Traceback", logged) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_presentation_builder.py b/tests/test_presentation_builder.py deleted file mode 100644 index 593f2f3..0000000 --- a/tests/test_presentation_builder.py +++ /dev/null @@ -1,189 +0,0 @@ -import io -import json -import tempfile -import unittest -from pathlib import Path -from unittest import mock - -from scripts import presentation_builder as pb - - -class PresentationBuilderTests(unittest.TestCase): - def make_config(self, root: Path, **overrides): - base = { - "bind": "127.0.0.1", - "port": 8400, - "source_root": root / "source", - "target_root": root / "target" / "jellyfin-library", - "state_root": root / "state", - "overrides_path": root / "overrides.yml", - "jellyfin_url": "http://jellyfin:8096", - "jellyfin_api_key": "", - "jellyfin_scan_task_id": "", - "skip_jellyfin_scan": False, - "build_on_start": False, - "verbose": False, - } - base.update(overrides) - return pb.Config(**base) - - def create_source_tree(self, source_root: Path): - movies = source_root / "movies" - movies.mkdir(parents=True, exist_ok=True) - (movies / "Movie.2026.1080p.mkv").write_text("video", encoding="utf-8") - - def test_rebuild_skips_scan_when_api_key_is_missing(self): - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - config = self.make_config(root) - self.create_source_tree(config.source_root) - - stdout = io.StringIO() - with mock.patch("sys.stdout", stdout): - report = pb.rebuild_and_trigger(config) - - self.assertEqual(report["movies"], 1) - self.assertFalse(report["jellyfin_scan_triggered"]) - self.assertEqual(report["jellyfin_scan_status"], "skipped_missing_auth") - self.assertIsNone(report["jellyfin_scan_error"]) - self.assertEqual(stdout.getvalue(), "") - - def test_rebuild_logs_mapping_when_verbose_is_enabled(self): - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - config = self.make_config(root, verbose=True) - self.create_source_tree(config.source_root) - - stdout = io.StringIO() - with mock.patch("sys.stdout", stdout): - report = pb.rebuild_and_trigger(config) - - self.assertEqual(report["movies"], 1) - lines = [line for line in stdout.getvalue().splitlines() if line] - mapping_log = json.loads(lines[-1]) - self.assertEqual(mapping_log["event"], "presentation_builder_mapping_diff") - self.assertEqual(mapping_log["mapping_entries"], 1) - self.assertEqual(mapping_log["removed"], []) - self.assertEqual(mapping_log["changed"], []) - self.assertEqual( - mapping_log["added"], - [{"source": "movies/Movie.2026.1080p.mkv", "target": "movies/Movie (2026)/Movie (2026).mkv", "type": "movie"}], - ) - - def test_rebuild_logs_empty_diff_when_mapping_is_unchanged(self): - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - config = self.make_config(root, verbose=True) - self.create_source_tree(config.source_root) - - with mock.patch("sys.stdout", io.StringIO()): - pb.rebuild_and_trigger(config) - - stdout = io.StringIO() - with mock.patch("sys.stdout", stdout): - report = pb.rebuild_and_trigger(config) - - self.assertEqual(report["movies"], 1) - lines = [line for line in stdout.getvalue().splitlines() if line] - mapping_log = json.loads(lines[-1]) - self.assertEqual(mapping_log["event"], "presentation_builder_mapping_diff") - self.assertEqual(mapping_log["added"], []) - self.assertEqual(mapping_log["removed"], []) - self.assertEqual(mapping_log["changed"], []) - - def test_rebuild_triggers_scan_when_auth_is_configured(self): - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - config = self.make_config(root, jellyfin_api_key="token", jellyfin_scan_task_id="scan-task") - self.create_source_tree(config.source_root) - - with mock.patch.object(pb, "trigger_jellyfin_scan") as trigger_scan: - report = pb.rebuild_and_trigger(config) - - trigger_scan.assert_called_once_with(config) - self.assertTrue(report["jellyfin_scan_triggered"]) - self.assertEqual(report["jellyfin_scan_status"], "triggered") - self.assertIsNone(report["jellyfin_scan_error"]) - - def test_rebuild_raises_structured_error_for_real_scan_failure(self): - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - config = self.make_config(root, jellyfin_api_key="token", jellyfin_scan_task_id="scan-task") - self.create_source_tree(config.source_root) - - with mock.patch.object(pb, "trigger_jellyfin_scan", side_effect=RuntimeError("scan failed")): - with self.assertRaises(pb.RebuildError) as ctx: - pb.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"]) - - def test_rebuild_handler_returns_structured_payload_for_rebuild_errors(self): - payload = { - "movies": 1, - "jellyfin_scan_triggered": False, - "jellyfin_scan_status": "failed", - "jellyfin_scan_error": "scan failed", - } - - class FakeApp: - def handle_rebuild(self): - raise pb.RebuildError("scan failed", payload) - - handler = pb.Handler.__new__(pb.Handler) - handler.path = "/rebuild" - handler.headers = {"Content-Length": "0"} - handler.rfile = io.BytesIO(b"") - handler.wfile = io.BytesIO() - handler.app = FakeApp() - recorded = {"status": None, "headers": []} - - def send_response(status): - recorded["status"] = status - - def send_header(name, value): - recorded["headers"].append((name, value)) - - def end_headers(): - return None - - handler.send_response = send_response - handler.send_header = send_header - handler.end_headers = end_headers - - handler.do_POST() - - self.assertEqual(recorded["status"], 500) - body = json.loads(handler.wfile.getvalue().decode("utf-8")) - self.assertEqual(body["error"], "scan failed") - self.assertEqual(body["jellyfin_scan_status"], "failed") - self.assertEqual(body["jellyfin_scan_error"], "scan failed") - - def test_rebuild_handler_logs_rebuild_errors(self): - class FakeApp: - def handle_rebuild(self): - raise RuntimeError("boom") - - handler = pb.Handler.__new__(pb.Handler) - handler.path = "/rebuild" - handler.headers = {"Content-Length": "0"} - handler.rfile = io.BytesIO(b"") - handler.wfile = io.BytesIO() - handler.app = FakeApp() - handler.send_response = lambda status: None - handler.send_header = lambda name, value: None - handler.end_headers = lambda: None - - stderr = io.StringIO() - with mock.patch("sys.stderr", stderr): - handler.do_POST() - - logged = stderr.getvalue() - self.assertIn("presentation-builder rebuild failed: boom", logged) - self.assertIn("Traceback", logged) - - -if __name__ == "__main__": - unittest.main() From 8938a5b3dfcfe637fcf7b468e66cd3b361bf3ae8 Mon Sep 17 00:00:00 2001 From: Gabriel Chamon Date: Tue, 14 Apr 2026 20:05:32 -0300 Subject: [PATCH 7/8] improve info panel visual and change text box header --- buzz/static/buzz.css | 11 ++++- buzz/templates/torrents.html | 89 +++++++++++++++++++++++++++--------- 2 files changed, 77 insertions(+), 23 deletions(-) diff --git a/buzz/static/buzz.css b/buzz/static/buzz.css index 7b9ae84..eba8530 100644 --- a/buzz/static/buzz.css +++ b/buzz/static/buzz.css @@ -70,7 +70,7 @@ main { display: flex; flex-wrap: wrap; gap: 20px; - margin-bottom: 24px; + margin: 20px 15px 20px 0px; font-size: 0.9rem; color: var(--comment); } @@ -84,6 +84,14 @@ main { color: var(--cyan); } +.meta-item:last-of-type { + margin-left: auto +} + +.meta-item:nth-of-type(3) { + width: 150px +} + button { background: var(--purple); color: var(--bg); @@ -200,6 +208,7 @@ tr:hover { } @keyframes scroll-text { + 0%, 10% { transform: translateX(0); diff --git a/buzz/templates/torrents.html b/buzz/templates/torrents.html index 18a4feb..7e4710a 100644 --- a/buzz/templates/torrents.html +++ b/buzz/templates/torrents.html @@ -1,10 +1,12 @@ + buzz: list torrents - + +
@@ -150,7 +189,7 @@
- Add New Torrent + ADD NEW MAGNET TO THE CACHE:
@@ -196,7 +235,9 @@ {% for torrent in torrents %} -
{{ torrent.name }}
+ +
{{ torrent.name }}
+ [{{ torrent.status }}] {{ torrent.progress }}% {{ torrent.size }} @@ -207,16 +248,19 @@
-
[Y]
+
[Y]
[N]
-
[X]
+
[X]
{% else %} - No cached torrents yet. Wait for the first sync or trigger POST /sync. + No cached torrents yet. Wait for the first sync or trigger POST + /sync. {% endfor %} @@ -244,8 +288,8 @@ try { const res = await fetch("/api/torrents/add", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ magnet }) + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({magnet}) }); const data = await res.json(); @@ -311,8 +355,8 @@ try { const res = await fetch("/api/torrents/select", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ torrent_id: currentTorrentId, file_ids: fileIds }) + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({torrent_id: currentTorrentId, file_ids: fileIds}) }); const data = await res.json(); @@ -325,7 +369,7 @@ resetAddForm(); - await fetch("/sync", { method: "POST" }); + await fetch("/sync", {method: "POST"}); setTimeout(() => location.reload(), 1000); } catch (err) { status.innerText = "Error: " + err.message; @@ -369,8 +413,8 @@ try { const res = await fetch("/api/torrents/delete", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ torrent_id: id }) + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({torrent_id: id}) }); const data = await res.json(); if (data.error) { @@ -403,4 +447,5 @@ }); + From 356b00b157f1dc35c74f9043c0041ed4173f0e18 Mon Sep 17 00:00:00 2001 From: Gabriel Chamon Date: Tue, 14 Apr 2026 20:10:05 -0300 Subject: [PATCH 8/8] fix performance issue when sorting rows by clicking column headers --- buzz/static/buzz.js | 80 +++++++++++++++++------------------- buzz/templates/torrents.html | 8 ++-- buzz/templates/trashcan.html | 4 +- 3 files changed, 44 insertions(+), 48 deletions(-) diff --git a/buzz/static/buzz.js b/buzz/static/buzz.js index 3e2fc47..59d1e9a 100644 --- a/buzz/static/buzz.js +++ b/buzz/static/buzz.js @@ -146,14 +146,8 @@ function sortTable(n) { return; } - let rows; - let switching = true; - let i; - let shouldSwitch; - let dir = "asc"; - let switchcount = 0; - 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")) { @@ -171,43 +165,45 @@ function sortTable(n) { } } - while (switching) { - switching = false; - rows = table.rows; - - for (i = 1; i < rows.length - 1; i++) { - shouldSwitch = false; - const x = rows[i].getElementsByTagName("td")[n]; - const y = rows[i + 1].getElementsByTagName("td")[n]; - - let valX = x.getAttribute("data-value") || x.innerText.toLowerCase(); - let valY = y.getAttribute("data-value") || y.innerText.toLowerCase(); - - const numX = parseFloat(valX); - const numY = parseFloat(valY); - if (!Number.isNaN(numX) && !Number.isNaN(numY)) { - valX = numX; - valY = numY; - } + const tbody = table.tBodies[0]; + if (!tbody) { + return; + } - if (dir === "asc") { - if (valX > valY) { - shouldSwitch = true; - break; - } - } else if (valX < valY) { - shouldSwitch = true; - break; - } + 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 (shouldSwitch) { - rows[i].parentNode.insertBefore(rows[i + 1], rows[i]); - switching = true; - switchcount++; - } else if (switchcount === 0 && dir === "asc") { - dir = "desc"; - switching = true; + 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); } diff --git a/buzz/templates/torrents.html b/buzz/templates/torrents.html index 7e4710a..274788d 100644 --- a/buzz/templates/torrents.html +++ b/buzz/templates/torrents.html @@ -235,16 +235,16 @@ {% for torrent in torrents %} - +
{{ torrent.name }}
- [{{ torrent.status }}] + [{{ torrent.status }}] {{ torrent.progress }}% {{ torrent.size }} {{ torrent.selected_files }} {{ torrent.links }} - {{ torrent.ended }} - {{ torrent.short_id }} + {{ torrent.ended }} + {{ torrent.short_id }}
diff --git a/buzz/templates/trashcan.html b/buzz/templates/trashcan.html index 3733846..ba267c1 100644 --- a/buzz/templates/trashcan.html +++ b/buzz/templates/trashcan.html @@ -56,10 +56,10 @@ {% for torrent in trash_torrents %} -
{{ torrent.name }}
+
{{ torrent.name }}
{{ torrent.size }} {{ torrent.file_count }} - {{ torrent.deleted_at }} + {{ torrent.deleted_at }}