diff --git a/dashboard/backend/config.py b/dashboard/backend/config.py
index d031afe..8aef98a 100644
--- a/dashboard/backend/config.py
+++ b/dashboard/backend/config.py
@@ -71,6 +71,7 @@ EXTERNAL_SERVICES = {
"stirling_pdf": os.environ.get("STIRLING_PDF_URL", "https://pdf.jimmygan.com"),
"vaultwarden": os.environ.get("VAULTWARDEN_URL", "https://vault.jimmygan.com"),
"n8n": os.environ.get("N8N_URL", "https://n8n.jimmygan.com"),
+ "reasonix": os.environ.get("REASONIX_URL", "https://code.jimmygan.com"),
"speedtest": os.environ.get("SPEEDTEST_URL", "https://speed.jimmygan.com"),
"openconnector": os.environ.get("OPENCONNECTOR_URL", "https://connector.jimmygan.com"),
}
diff --git a/dashboard/backend/routers/__init__.py b/dashboard/backend/routers/__init__.py
index 65c44f5..d6b0d05 100644
--- a/dashboard/backend/routers/__init__.py
+++ b/dashboard/backend/routers/__init__.py
@@ -17,6 +17,7 @@ ROUTER_CONFIGS = [
{"module": "opc_tasks", "prefix": "/api/opc", "deps": ["_inject_user", "require_page(\"opc\")"]},
{"module": "opc_agents", "prefix": "/api/opc", "deps": ["_inject_user", "require_page(\"opc\")"]},
{"module": "opc_projects", "prefix": "/api/opc", "deps": ["_inject_user", "require_page(\"opc\")"]},
+ {"module": "media_mgmt", "prefix": "/api/media", "deps": ["_inject_user", "require_page(\"media-ops\")"]},
]
WS_ROUTES = [
{"module": "terminal", "path": "/ws/terminal", "handler": "ws_endpoint"},
diff --git a/dashboard/backend/routers/media_mgmt.py b/dashboard/backend/routers/media_mgmt.py
new file mode 100644
index 0000000..839ebb3
--- /dev/null
+++ b/dashboard/backend/routers/media_mgmt.py
@@ -0,0 +1,128 @@
+import json
+import logging
+import os
+import asyncssh
+from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Query
+import config
+
+router = APIRouter()
+logger = logging.getLogger(__name__)
+
+# Absolute paths to media-mgmt scripts on NAS host
+HEALTH_CHECK_SCRIPT = "/volume1/homes/zjgump/media-mgmt/immich/scripts/health-check.sh"
+STORAGE_REPORT_SCRIPT = "/volume1/homes/zjgump/media-mgmt/diagnostics/storage-report.sh"
+SCAN_LIBRARY_SCRIPT = "/volume1/homes/zjgump/media-mgmt/immich/scripts/scan-library.sh"
+THUMBNAIL_CLINIC_SCRIPT = "/volume1/homes/zjgump/media-mgmt/diagnostics/thumbnail-clinic.sh"
+SHUTTLE_SCRIPT = "/volume1/homes/zjgump/media-mgmt/transfer/ssd-shuttle.sh"
+PHONE_INGEST_SCRIPT = "/volume1/homes/zjgump/media-mgmt/transfer/phone-to-nas.sh"
+
+LOG_DIR = "/volume1/homes/zjgump/media-mgmt/logs"
+
+async def run_host_command(cmd: str) -> tuple[int, str, str]:
+ """Execute a command on the NAS host via SSH."""
+ try:
+ # Prepend PATH and NAS_HOST variables for host execution
+ full_cmd = f'PATH="/volume1/homes/zjgump/bin:$PATH" NAS_HOST="naslan" {cmd}'
+ async with asyncssh.connect(
+ config.SSH_HOST,
+ username=config.SSH_USER,
+ client_keys=[config.SSH_KEY_PATH],
+ known_hosts=None
+ ) as conn:
+ result = await conn.run(full_cmd)
+ return result.exit_status, result.stdout, result.stderr
+ except Exception as e:
+ logger.error(f"SSH command failed ({cmd}): {e}")
+ return 999, "", str(e)
+
+@router.get("/health")
+async def get_health():
+ """Runs health-check.sh and returns the exit status and stdout/stderr."""
+ code, stdout, stderr = await run_host_command(f"bash {HEALTH_CHECK_SCRIPT}")
+ return {
+ "status": "ok" if code == 0 else "warning" if code == 1 else "error",
+ "exit_code": code,
+ "stdout": stdout,
+ "stderr": stderr
+ }
+
+@router.get("/storage")
+async def get_storage():
+ """Runs storage-report.sh -j and returns the parsed JSON storage report."""
+ code, stdout, stderr = await run_host_command(f"bash {STORAGE_REPORT_SCRIPT} -j")
+ if code != 0:
+ raise HTTPException(status_code=500, detail=f"Storage report failed: {stderr}")
+ try:
+ return json.loads(stdout)
+ except json.JSONDecodeError:
+ return {
+ "error": "Failed to parse JSON from storage report",
+ "raw_output": stdout,
+ "stderr": stderr
+ }
+
+@router.post("/maintenance/scan")
+async def run_scan(background_tasks: BackgroundTasks, library: str = Query(None)):
+ """Triggers scan-library.sh in the background and writes stdout/stderr to scan.log."""
+ cmd = f"bash {SCAN_LIBRARY_SCRIPT}"
+ if library:
+ cmd += f" {library}"
+
+ log_file = f"{LOG_DIR}/scan.log"
+ run_cmd = f"mkdir -p {LOG_DIR} && echo '=== Scan Started at $(date) ===' > {log_file} && nohup {cmd} >> {log_file} 2>&1 &"
+
+ code, stdout, stderr = await run_host_command(run_cmd)
+ if code != 0:
+ raise HTTPException(status_code=500, detail=f"Failed to start scan: {stderr}")
+
+ return {"message": "Scan started in background", "log_file": log_file}
+
+@router.post("/maintenance/requeue")
+async def run_requeue(background_tasks: BackgroundTasks):
+ """Triggers thumbnail-clinic.sh -r in the background and writes stdout/stderr to requeue.log."""
+ cmd = f"bash {THUMBNAIL_CLINIC_SCRIPT} -r"
+ log_file = f"{LOG_DIR}/requeue.log"
+
+ run_cmd = f"mkdir -p {LOG_DIR} && echo '=== Requeue Started at $(date) ===' > {log_file} && nohup {cmd} >> {log_file} 2>&1 &"
+
+ code, stdout, stderr = await run_host_command(run_cmd)
+ if code != 0:
+ raise HTTPException(status_code=500, detail=f"Failed to start thumbnail requeue: {stderr}")
+
+ return {"message": "Thumbnail requeue started in background", "log_file": log_file}
+
+@router.get("/logs/{task_name}")
+async def get_logs(task_name: str):
+ """Reads logs for a task from /volume1/homes/zjgump/media-mgmt/logs/{task_name}.log."""
+ if task_name not in ["scan", "requeue", "shuttle", "phone-ingest"]:
+ raise HTTPException(status_code=400, detail="Invalid task name")
+
+ log_path = f"/volume1/homes/zjgump/media-mgmt/logs/{task_name}.log"
+ if not os.path.exists(log_path):
+ return {"content": "No log file found."}
+
+ try:
+ with open(log_path, "r") as f:
+ content = f.readlines()
+ return {"content": "".join(content[-200:])}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Failed to read log file: {e}")
+
+@router.get("/status")
+async def get_active_status():
+ """Checks if any ingestion/maintenance processes are currently running on the host."""
+ processes = {
+ "scan": SCAN_LIBRARY_SCRIPT,
+ "requeue": THUMBNAIL_CLINIC_SCRIPT,
+ "shuttle": SHUTTLE_SCRIPT,
+ "phone-ingest": PHONE_INGEST_SCRIPT
+ }
+
+ status = {}
+ for name, script_path in processes.items():
+ code, stdout, stderr = await run_host_command(f"pgrep -f {script_path}")
+ status[name] = {
+ "running": code == 0,
+ "pid": stdout.strip() if code == 0 else None
+ }
+ return status
diff --git a/dashboard/backend/tests/integration/test_media_mgmt.py b/dashboard/backend/tests/integration/test_media_mgmt.py
new file mode 100644
index 0000000..4f2c7bc
--- /dev/null
+++ b/dashboard/backend/tests/integration/test_media_mgmt.py
@@ -0,0 +1,49 @@
+import pytest
+from unittest.mock import patch, AsyncMock
+
+@pytest.mark.asyncio
+async def test_get_health(test_app, admin_headers):
+ with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run:
+ mock_run.return_value = (0, "All checks passed\n", "")
+ response = test_app.get("/api/media/health", headers=admin_headers)
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "ok"
+ assert data["exit_code"] == 0
+ assert "All checks passed" in data["stdout"]
+
+@pytest.mark.asyncio
+async def test_get_storage(test_app, admin_headers):
+ with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run:
+ mock_run.return_value = (0, '{"timestamp": "2026-07-11T05:37:57Z", "volume": {"path": "/volume1", "total": "14T", "used": "12T", "available": "2.2T", "used_pct": 85}}', "")
+ response = test_app.get("/api/media/storage", headers=admin_headers)
+ assert response.status_code == 200
+ data = response.json()
+ assert "volume" in data
+ assert data["volume"]["total"] == "14T"
+
+@pytest.mark.asyncio
+async def test_run_scan(test_app, admin_headers):
+ with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run:
+ mock_run.return_value = (0, "", "")
+ response = test_app.post("/api/media/maintenance/scan?library=DJI-Action6", headers=admin_headers)
+ assert response.status_code == 200
+ data = response.json()
+ assert "Scan started" in data["message"]
+ mock_run.assert_called_once()
+ assert "scan-library.sh DJI-Action6" in mock_run.call_args[0][0]
+
+@pytest.mark.asyncio
+async def test_get_active_status(test_app, admin_headers):
+ with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run:
+ def side_effect(cmd):
+ if "scan-library.sh" in cmd:
+ return (0, "1234\n", "")
+ return (1, "", "")
+ mock_run.side_effect = side_effect
+ response = test_app.get("/api/media/status", headers=admin_headers)
+ assert response.status_code == 200
+ data = response.json()
+ assert data["scan"]["running"] is True
+ assert data["scan"]["pid"] == "1234"
+ assert data["requeue"]["running"] is False
diff --git a/dashboard/frontend/src/App.svelte b/dashboard/frontend/src/App.svelte
index dceb082..2dd3205 100644
--- a/dashboard/frontend/src/App.svelte
+++ b/dashboard/frontend/src/App.svelte
@@ -13,6 +13,7 @@
import OPC from "./routes/tools/OPC.svelte";
import Transmission from "./routes/media/Transmission.svelte";
import Login from "./routes/Login.svelte";
+ import MediaOps from "./routes/media/MediaOps.svelte";
import { onMount } from "svelte";
import { getToken, checkAuth, setToken, currentUser, setCurrentUser, getPreferences, savePreferences, tryRefreshSession, logout as logoutSession } from "./lib/api.js";
@@ -28,6 +29,7 @@
"litellm",
"opc",
"transmission",
+ "media-ops",
]);
const navItems = [
@@ -40,12 +42,12 @@
{ id: "security", label: "Security", section: "main", description: "WebAuthn passkeys and TOTP" },
// Media
{ id: "transmission", label: "Transmission", section: "media", description: "BitTorrent client" },
+ { id: "media-ops", label: "Media Ops", section: "media", description: "Media library health, storage, and maintenance control panel" },
{ label: "Navidrome", section: "media", remoteHref: "https://music.jimmygan.com", description: "Music streaming" },
{ label: "Jellyfin", section: "media", remoteHref: "https://media.jimmygan.com", description: "Media server" },
{ label: "Audiobookshelf", section: "media", remoteHref: "https://books.jimmygan.com", description: "Audiobooks & podcasts" },
{ label: "Immich", section: "media", remoteHref: "https://photos.jimmygan.com", description: "Photo and video backup" },
{ label: "t-youtube", section: "media", remoteHref: "https://yt.jimmygan.com", description: "YouTube subscription reader" },
- { label: "Media Management", section: "media", remoteHref: "https://media.jimmygan.com", description: "Media library management" },
// Tools
{ id: "chat-digest", label: "Chat Digest", section: "tools", description: "Telegram group summaries" },
{ id: "conversations", label: "Code Sessions", section: "tools", description: "Claude Code session browser" },
@@ -236,6 +238,8 @@
Synology Immich ingestion pipelines, health, storage reports, and maintenance.
+| Task Name | +Status | +Actions | +
|---|---|---|
|
+ Library Scan
+ Trigger Immich scanning API for ingest paths.
+ |
+ + + {#if status.scan?.running} + + {/if} + {getStatusLabel('scan')} + + | +
+
+
+
+
+
+ |
+
|
+ Thumbnail Clinic
+ Detect missing thumbnails and requeue Bull jobs.
+ |
+ + + {#if status.requeue?.running} + + {/if} + {getStatusLabel('requeue')} + + | +
+
+
+
+
+ |
+
|
+ SSD Shuttle
+ Ingest/move files from portable SSD shuttle to NAS.
+ |
+ + + {#if status.shuttle?.running} + + {/if} + {getStatusLabel('shuttle')} + + | ++ + | +
|
+ Phone Sync
+ Upload/sync phone media folders to target storage.
+ |
+ + + {#if status['phone-ingest']?.running} + + {/if} + {getStatusLabel('phone-ingest')} + + | ++ + | +
Showing last 200 lines for: {selectedTask}
+{logs}
+ {health.stdout || health.stderr}
+