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/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/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 af45a5e..6ebf1b4 100644
--- a/buzz/dav_app.py
+++ b/buzz/dav_app.py
@@ -10,18 +10,20 @@
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 (
AddTorrentRequest,
DavConfig,
DeleteTorrentRequest,
+ RestoreTrashRequest,
+ DeleteTrashRequest,
ErrorResponse,
SelectFilesRequest,
)
@@ -57,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()
@@ -68,6 +76,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 +136,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:
@@ -266,54 +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"| {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""
- f"{html_escape(torrent_id[:8])} | "
- ""
- f'"
- " | "
- "
"
- )
- if not rows:
- rows.append(
- 'No cached torrents yet. '
- "Wait for the first sync or trigger POST /sync. |
"
+ 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()
+ trash_torrents = []
+ for torrent in torrents:
+ 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"
+
+ template = self.templates.get_template("trashcan.html")
+ return template.render(
+ torrents_count=len(torrents),
+ last_sync_at=status.get("last_sync_at") or "never",
+ sync_state=sync_state,
+ snapshot_ready="true" if status.get("snapshot_loaded") else "false",
+ last_error=status.get("last_error"),
+ trash_torrents=trash_torrents,
)
async def _handle_validation_error(
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/static/buzz.css b/buzz/static/buzz.css
new file mode 100644
index 0000000..eba8530
--- /dev/null
+++ b/buzz/static/buzz.css
@@ -0,0 +1,321 @@
+: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: 20px 15px 20px 0px;
+ font-size: 0.9rem;
+ color: var(--comment);
+}
+
+.meta-item b {
+ color: var(--orange);
+ font-weight: normal;
+}
+
+.meta-item span {
+ 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);
+ 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..59d1e9a
--- /dev/null
+++ b/buzz/static/buzz.js
@@ -0,0 +1,209 @@
+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;
+ }
+
+ const headers = table.getElementsByTagName("th");
+ let dir = "asc";
+ for (let h = 0; h < headers.length; h++) {
+ if (h === n) {
+ if (headers[h].classList.contains("sort-asc")) {
+ headers[h].classList.replace("sort-asc", "sort-desc");
+ dir = "desc";
+ } else if (headers[h].classList.contains("sort-desc")) {
+ headers[h].classList.replace("sort-desc", "sort-asc");
+ dir = "asc";
+ } else {
+ headers[h].classList.add("sort-asc");
+ dir = "asc";
+ }
+ } else {
+ headers[h].classList.remove("sort-asc", "sort-desc");
+ }
+ }
+
+ const tbody = table.tBodies[0];
+ if (!tbody) {
+ return;
+ }
+
+ const rows = Array.from(tbody.rows);
+ const rowData = rows.map((row, index) => {
+ const cell = row.cells[n];
+ const rawValue = cell
+ ? cell.getAttribute("data-value") || cell.textContent || ""
+ : "";
+ const trimmed = rawValue.trim();
+ const numValue = trimmed === "" ? Number.NaN : Number(trimmed);
+ return {
+ row,
+ index,
+ value: Number.isNaN(numValue) ? trimmed.toLowerCase() : numValue,
+ isNumber: !Number.isNaN(numValue),
+ };
+ });
+
+ rowData.sort((a, b) => {
+ let result;
+ if (a.isNumber && b.isNumber) {
+ result = a.value - b.value;
+ } else {
+ result = String(a.value).localeCompare(String(b.value));
+ }
+
+ if (result === 0) {
+ result = a.index - b.index;
+ }
+
+ return dir === "asc" ? result : -result;
+ });
+
+ const fragment = document.createDocumentFragment();
+ rowData.forEach(entry => {
+ fragment.appendChild(entry.row);
+ });
+ tbody.appendChild(fragment);
+}
diff --git a/buzz/templates/torrents.html b/buzz/templates/torrents.html
index 2c5bdbf..274788d 100644
--- a/buzz/templates/torrents.html
+++ b/buzz/templates/torrents.html
@@ -1,61 +1,14 @@
+
buzz: list torrents
-
+
+
+
- buzz: list torrents
+
+
-
- {{ error_html | safe }}
+
+ {% if last_error %}
+ [ERROR] {{ last_error }}
+ {% endif %}
-
+
@@ -289,35 +233,69 @@
- {{ rows | safe }}
+ {% for torrent in torrents %}
+
+ |
+ {{ torrent.name }}
+ |
+ [{{ torrent.status }}] |
+ {{ torrent.progress }}% |
+ {{ torrent.size }} |
+ {{ torrent.selected_files }} |
+ {{ torrent.links }} |
+
+ {{ torrent.short_id }} |
+
+
+ |
+
+ {% else %}
+
+ No cached torrents yet. Wait for the first sync or trigger POST
+ /sync. |
+
+ {% endfor %}
+
+
diff --git a/buzz/templates/trashcan.html b/buzz/templates/trashcan.html
new file mode 100644
index 0000000..ba267c1
--- /dev/null
+++ b/buzz/templates/trashcan.html
@@ -0,0 +1,160 @@
+
+
+
+
+
+ buzz: trashcan
+
+
+
+
+
+
+
+
+
+
+ {% if last_error %}
+ [ERROR] {{ last_error }}
+ {% endif %}
+
+
+
+
+
+ | Name |
+ Size |
+ Files |
+ Date Removed |
+ Act |
+
+
+
+ {% for torrent in trash_torrents %}
+
+ {{ torrent.name }} |
+ {{ torrent.size }} |
+ {{ torrent.file_count }} |
+
+
+
+ |
+
+ {% else %}
+ | Trashcan is empty. |
+ {% endfor %}
+
+
+
+
+
+
+
+
+
diff --git a/docker-compose.yml b/docker-compose.yml
index 03ff37d..ef543e9 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:ro
networks:
default:
aliases:
@@ -84,8 +85,6 @@ services:
profiles: ["jellyfin"]
image: jellyfin/jellyfin
container_name: jellyfin
- devices:
- - "/dev/dri:/dev/dri"
depends_on:
rclone:
condition: service_healthy
@@ -131,49 +130,7 @@ services:
volumes:
- ./presentation/overrides.yml:/config/overrides.yml:ro
- ./state/curator:/state
+ - ./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",
-]
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()