From 258f8fd2f7b0f5654862ad8039d4bfe8e6f49c60 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Sat, 11 Jul 2026 13:42:46 +0800 Subject: [PATCH] Sprint 08: Media Control backend routers, frontend dashboard view, integration tests, and SSH host command wrapper --- dashboard/backend/routers/__init__.py | 1 + dashboard/backend/routers/media_mgmt.py | 128 +++++ .../tests/integration/test_media_mgmt.py | 49 ++ dashboard/frontend/src/App.svelte | 6 +- .../frontend/src/components/Sidebar.svelte | 2 +- dashboard/frontend/src/lib/api.js | 25 + .../frontend/src/routes/media/MediaOps.svelte | 483 ++++++++++++++++++ 7 files changed, 692 insertions(+), 2 deletions(-) create mode 100644 dashboard/backend/routers/media_mgmt.py create mode 100644 dashboard/backend/tests/integration/test_media_mgmt.py create mode 100644 dashboard/frontend/src/routes/media/MediaOps.svelte 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 @@ {:else if page === "transmission" && hasPageAccess("transmission")} + {:else if page === "media-ops" && hasPageAccess("media-ops")} + {:else if page !== "terminal"} {/if} diff --git a/dashboard/frontend/src/components/Sidebar.svelte b/dashboard/frontend/src/components/Sidebar.svelte index bfaf5da..3615a1b 100644 --- a/dashboard/frontend/src/components/Sidebar.svelte +++ b/dashboard/frontend/src/components/Sidebar.svelte @@ -49,7 +49,7 @@ { label: "Audiobookshelf", remoteHref: "https://books.jimmygan.com", icon: "headphones" }, { label: "Immich", remoteHref: "https://photos.jimmygan.com", icon: "image" }, { label: "t-youtube", remoteHref: "https://yt.jimmygan.com", icon: "youtube" }, - { label: "Media Management", remoteHref: "https://media.jimmygan.com", icon: "wrench" }, + { id: "media-ops", label: "Media Ops", icon: "wrench" }, { id: "transmission", label: "Transmission", icon: "download" }, ]; diff --git a/dashboard/frontend/src/lib/api.js b/dashboard/frontend/src/lib/api.js index 1d905e7..b942561 100644 --- a/dashboard/frontend/src/lib/api.js +++ b/dashboard/frontend/src/lib/api.js @@ -217,3 +217,28 @@ export async function download(path, filename) { throw e; } } + +export function getMediaHealth() { + return get("/media/health"); +} + +export function getMediaStorage() { + return get("/media/storage"); +} + +export function startMediaScan(library = null) { + const path = library ? `/media/maintenance/scan?library=${encodeURIComponent(library)}` : "/media/maintenance/scan"; + return post(path, {}); +} + +export function startMediaRequeue() { + return post("/media/maintenance/requeue", {}); +} + +export function getMediaLogs(taskName) { + return get(`/media/logs/${taskName}`); +} + +export function getMediaStatus() { + return get("/media/status"); +} diff --git a/dashboard/frontend/src/routes/media/MediaOps.svelte b/dashboard/frontend/src/routes/media/MediaOps.svelte new file mode 100644 index 0000000..5724b33 --- /dev/null +++ b/dashboard/frontend/src/routes/media/MediaOps.svelte @@ -0,0 +1,483 @@ + + +
+
+
+

Media Ops

+

Synology Immich ingestion pipelines, health, storage reports, and maintenance.

+
+
+ + +
+
+ +
+ +
+ + +
+

Ingestion & Maintenance Tasks

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Task NameStatusActions
+
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')} + + + +
+
+
+ + +
+
+
+

Task Output Console

+

Showing last 200 lines for: {selectedTask}

+
+
+ + +
+
+ +
{logs}
+
+ +
+ + +
+ + +
+

Live Diagnostics Status

+ + {#if health} +
+ + {#if health.status === 'ok'} + + + +
+
All Checks Green
+
Immich API and Docker containers running properly.
+
+ {:else if health.status === 'warning'} + + + +
+
Passed with Warnings
+
Checks passed but disk warning threshold was met.
+
+ {:else} + + + +
+
Services Degraded
+
One or more components are offline or failing pings.
+
+ {/if} +
+ +
+ + Diagnostics Details + + +
{health.stdout || health.stderr}
+
+ {:else} +
+ {diagLoading ? 'Running check...' : 'No diagnostics run yet.'} +
+ {/if} +
+ + +
+

Storage Breakdown

+ + {#if storage} +
+ +
+
+ Volume Usage ({storage.volume.path}) + {storage.volume.used} / {storage.volume.total} +
+
+
+
+
+ {storage.volume.available} free + {storage.volume.used_pct}% used +
+
+ + +
+

Libraries

+ + {#each storage.libraries as lib} +
+
+ {lib.name} + {lib.total_size} +
+ +
+ Files: {lib.total_files} + ({lib.images} imgs, {lib.videos} vids) +
+ +
+
+
+
+ {lib.volume_pct}% of total storage +
+
+ {/each} +
+ + +
+

Ingest Folders

+
+ video/DJI_001 + {storage.directories.dji_001.size} +
+
+ immich/upload + {storage.directories.immich_upload.size} +
+
+ +
+ {:else} +
+ Loading storage details... +
+ {/if} +
+ +
+
+