diff --git a/dashboard/backend/main.py b/dashboard/backend/main.py
index ef22e5f..28b2a88 100644
--- a/dashboard/backend/main.py
+++ b/dashboard/backend/main.py
@@ -39,6 +39,7 @@ from routers import (
system,
terminal,
totp,
+ transmission,
)
_tz_cst = timezone(timedelta(hours=8))
@@ -303,6 +304,9 @@ app.include_router(
app.include_router(
security.router, prefix="/api/security", dependencies=[Depends(_inject_user), Depends(require_page("security"))]
)
+app.include_router(
+ transmission.router, dependencies=[Depends(_inject_user), Depends(require_page("transmission"))]
+)
# OPC endpoints
app.include_router(
diff --git a/dashboard/backend/routers/transmission.py b/dashboard/backend/routers/transmission.py
new file mode 100644
index 0000000..83eae72
--- /dev/null
+++ b/dashboard/backend/routers/transmission.py
@@ -0,0 +1,211 @@
+import subprocess
+from typing import Any, Dict, List
+
+import httpx
+from fastapi import APIRouter, HTTPException
+
+router = APIRouter(prefix="/api/transmission", tags=["transmission"])
+
+
+def get_session_id() -> str:
+ """Get Transmission RPC session ID"""
+ try:
+ result = subprocess.run(
+ ["ssh", "nas", "curl -si -u 'admin:admin' 'http://127.0.0.1:9091/transmission/rpc' 2>&1 | grep -i 'X-Transmission-Session-Id:' | cut -d' ' -f2 | tr -d '\\r'"],
+ capture_output=True,
+ text=True,
+ shell=True,
+ timeout=5
+ )
+ session_id = result.stdout.strip()
+ if not session_id:
+ raise Exception("Failed to get session ID")
+ return session_id
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Failed to get Transmission session ID: {str(e)}")
+
+
+def transmission_rpc(method: str, arguments: Dict[str, Any] = None) -> Dict[str, Any]:
+ """Make Transmission RPC call"""
+ try:
+ session_id = get_session_id()
+
+ payload = {"method": method}
+ if arguments:
+ payload["arguments"] = arguments
+
+ # Use httpx to make the request
+ with httpx.Client(timeout=10.0) as client:
+ response = client.post(
+ "http://100.78.131.124:9091/transmission/rpc",
+ json=payload,
+ auth=("admin", "admin"),
+ headers={"X-Transmission-Session-Id": session_id}
+ )
+
+ if response.status_code == 200:
+ data = response.json()
+ if data.get("result") == "success":
+ return data.get("arguments", {})
+ else:
+ raise Exception(f"RPC error: {data.get('result')}")
+ else:
+ raise Exception(f"HTTP {response.status_code}: {response.text}")
+
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Transmission RPC failed: {str(e)}")
+
+
+@router.get("/status")
+async def get_status():
+ """Get Transmission daemon status"""
+ try:
+ # Check if process is running
+ result = subprocess.run(
+ ["ssh", "nas", "ps aux | grep transmission-daemon | grep sc-transmission | grep -v grep"],
+ capture_output=True,
+ text=True,
+ shell=True,
+ timeout=5
+ )
+
+ is_running = bool(result.stdout.strip())
+
+ if not is_running:
+ return {
+ "running": False,
+ "message": "Transmission daemon is not running"
+ }
+
+ # Get session stats
+ try:
+ stats = transmission_rpc("session-stats")
+ return {
+ "running": True,
+ "stats": stats
+ }
+ except:
+ return {
+ "running": True,
+ "message": "Daemon running but RPC not accessible"
+ }
+
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/torrents")
+async def get_torrents():
+ """Get list of torrents with tracker status"""
+ try:
+ data = transmission_rpc("torrent-get", {
+ "fields": [
+ "id", "name", "status", "percentDone",
+ "rateDownload", "rateUpload",
+ "uploadRatio", "trackerStats",
+ "error", "errorString"
+ ]
+ })
+
+ torrents = data.get("torrents", [])
+
+ # Process tracker stats
+ for torrent in torrents:
+ tracker_errors = []
+ for tracker in torrent.get("trackerStats", []):
+ if tracker.get("lastAnnounceResult") and tracker.get("lastAnnounceResult") != "Success":
+ tracker_errors.append({
+ "host": tracker.get("host"),
+ "error": tracker.get("lastAnnounceResult")
+ })
+ torrent["trackerErrors"] = tracker_errors
+ torrent["hasTrackerErrors"] = len(tracker_errors) > 0
+
+ return {"torrents": torrents}
+
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/trackers")
+async def get_tracker_summary():
+ """Get summary of tracker connectivity"""
+ try:
+ data = transmission_rpc("torrent-get", {
+ "fields": ["trackerStats"]
+ })
+
+ torrents = data.get("torrents", [])
+ tracker_summary = {}
+
+ for torrent in torrents:
+ for tracker in torrent.get("trackerStats", []):
+ host = tracker.get("host")
+ if host not in tracker_summary:
+ tracker_summary[host] = {
+ "host": host,
+ "total": 0,
+ "errors": 0,
+ "lastError": None
+ }
+
+ tracker_summary[host]["total"] += 1
+
+ if tracker.get("lastAnnounceResult") and tracker.get("lastAnnounceResult") != "Success":
+ tracker_summary[host]["errors"] += 1
+ tracker_summary[host]["lastError"] = tracker.get("lastAnnounceResult")
+
+ return {"trackers": list(tracker_summary.values())}
+
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/stats")
+async def get_stats():
+ """Get Transmission statistics"""
+ try:
+ stats = transmission_rpc("session-stats")
+ return stats
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/start/{torrent_id}")
+async def start_torrent(torrent_id: int):
+ """Start a torrent"""
+ try:
+ transmission_rpc("torrent-start", {"ids": [torrent_id]})
+ return {"success": True}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/stop/{torrent_id}")
+async def stop_torrent(torrent_id: int):
+ """Stop a torrent"""
+ try:
+ transmission_rpc("torrent-stop", {"ids": [torrent_id]})
+ return {"success": True}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/verify/{torrent_id}")
+async def verify_torrent(torrent_id: int):
+ """Verify a torrent"""
+ try:
+ transmission_rpc("torrent-verify", {"ids": [torrent_id]})
+ return {"success": True}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/reannounce/{torrent_id}")
+async def reannounce_torrent(torrent_id: int):
+ """Force reannounce to tracker"""
+ try:
+ transmission_rpc("torrent-reannounce", {"ids": [torrent_id]})
+ return {"success": True}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
diff --git a/dashboard/frontend/src/App.svelte b/dashboard/frontend/src/App.svelte
index 2c71443..68eff72 100644
--- a/dashboard/frontend/src/App.svelte
+++ b/dashboard/frontend/src/App.svelte
@@ -13,6 +13,7 @@
import CcConnect from "./routes/CcConnect.svelte";
import InfoEngine from "./routes/InfoEngine.svelte";
import OPC from "./routes/OPC.svelte";
+ import Transmission from "./routes/Transmission.svelte";
import Login from "./routes/Login.svelte";
import { onMount } from "svelte";
import { getToken, checkAuth, setToken, currentUser, setCurrentUser, getPreferences, savePreferences, tryRefreshSession, logout as logoutSession } from "./lib/api.js";
@@ -31,6 +32,7 @@
"cc-connect",
"info-engine",
"opc",
+ "transmission",
]);
let page = $state("dashboard");
@@ -211,6 +213,8 @@
BitTorrent client status and management
+Daemon
++ {status?.running ? 'Running' : 'Stopped'} +
+Active Torrents
++ {torrents.filter(t => t.status === 4 || t.status === 6).length} +
+Download
++ {fmtSpeed(torrents.reduce((sum, t) => sum + (t.rateDownload || 0), 0))} +
+Upload
++ {fmtSpeed(torrents.reduce((sum, t) => sum + (t.rateUpload || 0), 0))} +
+| Name | +Status | +Progress | +Down | +Up | +Ratio | +Tracker | +Actions | +
|---|---|---|---|---|---|---|---|
| {torrent.name} | ++ + {statusText(torrent.status)} + + | +{(torrent.percentDone * 100).toFixed(1)}% | +{fmtSpeed(torrent.rateDownload)} | +{fmtSpeed(torrent.rateUpload)} | +{torrent.uploadRatio?.toFixed(2) || '0.00'} | ++ {#if torrent.hasTrackerErrors} + + + Error + + {:else} + OK + {/if} + | ++ + | +
| Tracker | +Total Torrents | +Errors | +Status | +Last Error | +
|---|---|---|---|---|
| {tracker.host} | +{tracker.total} | +{tracker.errors} | ++ {#if tracker.errors > 0} + + Error + + {:else} + + OK + + {/if} + | +{tracker.lastError || '-'} | +