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 @@ {:else if page === "opc" && hasPageAccess("opc")} + {:else if page === "transmission" && hasPageAccess("transmission")} + {:else if page !== "terminal"} {/if} diff --git a/dashboard/frontend/src/components/Sidebar.svelte b/dashboard/frontend/src/components/Sidebar.svelte index c96e333..1d62b32 100644 --- a/dashboard/frontend/src/components/Sidebar.svelte +++ b/dashboard/frontend/src/components/Sidebar.svelte @@ -42,6 +42,7 @@ { label: "Jellyfin", remoteHref: "https://media.jimmygan.com:8443", icon: "play" }, { label: "Audiobookshelf", remoteHref: "https://books.jimmygan.com:8443", icon: "headphones" }, { label: "Immich", remoteHref: "https://photos.jimmygan.com:8443", icon: "image" }, + { id: "transmission", label: "Transmission", icon: "download" }, ]; const defaultTools = [ @@ -254,6 +255,8 @@ {:else if icon === "image"} + {:else if icon === "download"} + {:else if icon === "openclaw"} {:else if icon === "chat"} diff --git a/dashboard/frontend/src/routes/Transmission.svelte b/dashboard/frontend/src/routes/Transmission.svelte new file mode 100644 index 0000000..6f03109 --- /dev/null +++ b/dashboard/frontend/src/routes/Transmission.svelte @@ -0,0 +1,282 @@ + + +
+
+

Transmission

+

BitTorrent client status and management

+
+ + {#if loading} +
+ {:else} + +
+ +
+
+
+

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))} +

+
+
+ + + +
+
+
+
+ + +
+ +
+ + + {#if selectedTab === "torrents"} +
+
+ + + + + + + + + + + + + + + {#each torrents as torrent} + + + + + + + + + + + {/each} + +
NameStatusProgressDownUpRatioTrackerActions
{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} + + +
+
+
+ {:else if selectedTab === "trackers"} +
+
+ + + + + + + + + + + + {#each trackers as tracker} + + + + + + + + {/each} + +
TrackerTotal TorrentsErrorsStatusLast Error
{tracker.host}{tracker.total}{tracker.errors} + {#if tracker.errors > 0} + + Error + + {:else} + + OK + + {/if} + {tracker.lastError || '-'}
+
+
+ {/if} + {/if} +
diff --git a/scripts/check-transmission-health.sh b/scripts/check-transmission-health.sh new file mode 100755 index 0000000..3c63d64 --- /dev/null +++ b/scripts/check-transmission-health.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Transmission Health Check Script +# Monitors Transmission daemon and tracker connectivity + +TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN}" +TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID}" + +send_alert() { + local message="$1" + if [ -n "$TELEGRAM_BOT_TOKEN" ] && [ -n "$TELEGRAM_CHAT_ID" ]; then + curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + -d "chat_id=${TELEGRAM_CHAT_ID}" \ + -d "text=🚨 Transmission Alert: ${message}" \ + -d "parse_mode=HTML" > /dev/null + fi +} + +# Check if Transmission daemon is running +if ! ps aux | grep transmission-daemon | grep sc-transmission | grep -v grep > /dev/null; then + send_alert "Transmission daemon is not running!" + echo "ERROR: Transmission daemon is not running" + exit 1 +fi + +# Check recent logs for tracker errors +LOG_FILE="/volume1/@appdata/transmission/transmission.log" +if [ -f "$LOG_FILE" ]; then + # Count recent tracker errors (last 100 lines) + ERROR_COUNT=$(tail -100 "$LOG_FILE" | grep -c "Announce error: Could not connect to tracker") + + if [ "$ERROR_COUNT" -gt 50 ]; then + send_alert "High number of tracker errors detected: ${ERROR_COUNT} in recent logs" + echo "WARNING: ${ERROR_COUNT} tracker errors found" + fi + + # Check if log is stale (no updates in last hour) + LOG_AGE=$(find "$LOG_FILE" -mmin +60 2>/dev/null) + if [ -n "$LOG_AGE" ]; then + send_alert "Transmission log hasn't been updated in over an hour - daemon may be stuck" + echo "WARNING: Log file is stale" + fi +fi + +# Check if RPC port is listening +if ! netstat -tln 2>/dev/null | grep -q ":9091 " && ! ss -tln 2>/dev/null | grep -q ":9091 "; then + send_alert "Transmission RPC port 9091 is not listening" + echo "ERROR: RPC port not listening" + exit 1 +fi + +echo "OK: Transmission health check passed" +exit 0