Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
78 changes: 78 additions & 0 deletions buzz/core/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {}}
Expand Down Expand Up @@ -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}")
Expand All @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions buzz/curator_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
125 changes: 83 additions & 42 deletions buzz/dav_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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()
Expand All @@ -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()}
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
"<tr>"
f"<td class='name'>{html_escape(torrent['name'])}</td>"
f'<td><span class="status status-{html_escape(torrent["status"])}">[{html_escape(torrent["status"])}]</span></td>'
f"<td data-value='{torrent['progress']}'>{html_escape(torrent['progress'])}%</td>"
f"<td data-value='{torrent['bytes']}'>{html_escape(format_bytes(torrent['bytes']))}</td>"
f"<td>{html_escape(torrent['selected_files'])}</td>"
f"<td>{html_escape(torrent['links'])}</td>"
f"<td class='comment'>{html_escape(torrent['ended'] or '-')}</td>"
f"<td class='yellow'><code>{html_escape(torrent_id[:8])}</code></td>"
"<td>"
f'<div class="delete-container">'
f'<div class="confirm-opts" id="confirm-{torrent_id}">'
f'<div class="opt opt-y" onclick="deleteTorrent(\'{torrent_id}\')">[Y]</div>'
f'<div class="opt opt-n" onclick="toggleDelete(\'{torrent_id}\', false)">[N]</div>'
"</div>"
f'<div class="btn-x" id="btn-x-{torrent_id}" onclick="toggleDelete(\'{torrent_id}\', true)">[X]</div>'
"</div>"
"</td>"
"</tr>"
)
if not rows:
rows.append(
'<tr><td colspan="8" class="empty">No cached torrents yet. '
"Wait for the first sync or trigger <code>POST /sync</code>.</td></tr>"
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 = (
'<div class="error"><span class="label-red">[ERROR]</span> '
f"{html_escape(status['last_error'])}</div>"
)

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(
Expand Down
24 changes: 24 additions & 0 deletions buzz/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading