Add FastAPI backend and React frontend subprojects
Backend: - FastAPI app with 17 REST endpoints covering dashboard, monitoring, media index, file browser, and jobs - Reuses existing clients/domain/services unchanged - pydantic-settings config, dependency injection, CORS setup - Auto-generated OpenAPI docs at /docs Frontend: - Vite + React + TypeScript SPA - @tanstack/react-query for data fetching with polling - ag-grid-react for media table and file browser - recharts for monitoring charts - Tailwind CSS styling - 4 pages: Dashboard, Monitoring, Media, File Browser - Typed API client matching all backend endpoints Also: - docs/MIGRATION_PLAN.md with full architecture plan - Updated .gitignore for both subprojects - Streamlit app preserved for now (can coexist)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Routers package."""
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Dashboard router — media counts, per-library breakdown, now-playing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from dependencies import get_jellyfin_client, get_user_id
|
||||
from clients.jellyfin import JellyfinClient
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
@router.get("/counts")
|
||||
def get_counts(
|
||||
client: JellyfinClient = Depends(get_jellyfin_client),
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> dict[str, int]:
|
||||
"""Return total movie/series/episode counts."""
|
||||
return client.media_counts(user_id)
|
||||
|
||||
|
||||
@router.get("/libraries")
|
||||
def get_library_counts(
|
||||
client: JellyfinClient = Depends(get_jellyfin_client),
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return per-library item counts broken down by type."""
|
||||
libraries = client.libraries(user_id)
|
||||
return client.library_item_counts(user_id, libraries)
|
||||
|
||||
|
||||
@router.get("/now-playing")
|
||||
def get_now_playing(
|
||||
client: JellyfinClient = Depends(get_jellyfin_client),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return currently active playback sessions with transcode info."""
|
||||
sessions = client.active_sessions()
|
||||
results = []
|
||||
for session in sessions:
|
||||
item = session.get("NowPlayingItem") or {}
|
||||
play_state = session.get("PlayState") or {}
|
||||
transcoding = session.get("TranscodingInfo") or {}
|
||||
|
||||
series = item.get("SeriesName") or ""
|
||||
title = f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown")
|
||||
|
||||
is_transcoding = bool(transcoding)
|
||||
transcode_type = []
|
||||
if is_transcoding:
|
||||
if transcoding.get("IsVideoDirect") is False:
|
||||
transcode_type.append("video")
|
||||
if transcoding.get("IsAudioDirect") is False:
|
||||
transcode_type.append("audio")
|
||||
if not transcode_type:
|
||||
transcode_type.append("active")
|
||||
|
||||
results.append({
|
||||
"user": session.get("UserName") or "Unknown",
|
||||
"title": title,
|
||||
"type": item.get("Type", ""),
|
||||
"state": "paused" if play_state.get("IsPaused") else "playing",
|
||||
"transcoding": "yes" if is_transcoding else "no",
|
||||
"transcoding_type": ", ".join(transcode_type),
|
||||
"device": session.get("DeviceName") or session.get("Client") or "",
|
||||
"session_id": session.get("Id") or "",
|
||||
})
|
||||
return results
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Files router — directory listing, ffprobe, stat, path resolution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
|
||||
from dependencies import get_ssh_client
|
||||
from clients.ssh import RemoteSSHClient
|
||||
from config import get_settings
|
||||
from path_utils import resolve_remote_media_path
|
||||
|
||||
router = APIRouter(prefix="/api/files", tags=["files"])
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
def list_directory(
|
||||
path: str = Query(..., description="Remote directory path to list"),
|
||||
ssh: RemoteSSHClient = Depends(get_ssh_client),
|
||||
) -> dict[str, Any]:
|
||||
"""List a remote directory."""
|
||||
result = ssh.list_dir(path)
|
||||
if result.exit_status != 0:
|
||||
raise HTTPException(status_code=400, detail=result.stderr or result.stdout or "Failed to list directory")
|
||||
entries = json.loads(result.stdout)
|
||||
return {
|
||||
"path": path,
|
||||
"entries": entries,
|
||||
"count": len(entries),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/ffprobe")
|
||||
def get_ffprobe(
|
||||
path: str = Query(..., description="Remote file path to probe"),
|
||||
ssh: RemoteSSHClient = Depends(get_ssh_client),
|
||||
) -> dict[str, Any]:
|
||||
"""Run ffprobe on a remote file and return parsed JSON."""
|
||||
try:
|
||||
data = ssh.ffprobe_json(path)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/stat")
|
||||
def get_stat(
|
||||
path: str = Query(..., description="Remote path to stat"),
|
||||
ssh: RemoteSSHClient = Depends(get_ssh_client),
|
||||
) -> dict[str, str]:
|
||||
"""Run stat on a remote path."""
|
||||
result = ssh.stat_path(path)
|
||||
if result.exit_status != 0:
|
||||
raise HTTPException(status_code=400, detail=result.stderr or result.stdout or "stat failed")
|
||||
return {"path": path, "output": result.stdout}
|
||||
|
||||
|
||||
@router.get("/resolve-path")
|
||||
def resolve_path(
|
||||
path: str = Query(..., description="Jellyfin path to resolve to SSH path"),
|
||||
) -> dict[str, str]:
|
||||
"""Resolve a Jellyfin path to its SSH-visible equivalent."""
|
||||
settings = get_settings()
|
||||
resolved = resolve_remote_media_path(path, settings.media_root, settings.path_prefix)
|
||||
return {"original": path, "resolved": resolved}
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Jobs router — list templates and run jobs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from dependencies import get_ssh_client
|
||||
from clients.ssh import RemoteSSHClient
|
||||
from jobs import JOB_TEMPLATES, run_job
|
||||
|
||||
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
|
||||
|
||||
|
||||
class RunJobRequest(BaseModel):
|
||||
job_key: str
|
||||
path: str
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
def get_templates() -> list[dict[str, str]]:
|
||||
"""Return available job templates."""
|
||||
return [
|
||||
{
|
||||
"key": key,
|
||||
"name": template.name,
|
||||
"description": template.description,
|
||||
}
|
||||
for key, template in JOB_TEMPLATES.items()
|
||||
]
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
def post_run_job(
|
||||
request: RunJobRequest,
|
||||
ssh: RemoteSSHClient = Depends(get_ssh_client),
|
||||
) -> dict[str, Any]:
|
||||
"""Run a job template on a remote path."""
|
||||
if request.job_key not in JOB_TEMPLATES:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown job key: {request.job_key}")
|
||||
|
||||
result = run_job(ssh, request.job_key, request.path)
|
||||
return {
|
||||
"job_key": request.job_key,
|
||||
"path": request.path,
|
||||
"exit_status": result.exit_status,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Media router — index status, build, and query."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from dependencies import get_jellyfin_client, get_user_id
|
||||
from clients.jellyfin import JellyfinClient
|
||||
from services.media_index import MediaIndex, build_media_index
|
||||
|
||||
router = APIRouter(prefix="/api/media", tags=["media"])
|
||||
|
||||
|
||||
def get_media_index() -> MediaIndex:
|
||||
return MediaIndex()
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def get_index_status(index: MediaIndex = Depends(get_media_index)) -> dict[str, Any]:
|
||||
"""Return media index status (exists, count, last updated, build duration)."""
|
||||
status = index.status()
|
||||
return {
|
||||
"exists": status.exists,
|
||||
"item_count": status.item_count,
|
||||
"updated_at": status.updated_at,
|
||||
"updated_at_label": status.updated_at_label,
|
||||
"build_duration_seconds": status.build_duration_seconds,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/build")
|
||||
def post_build_index(
|
||||
client: JellyfinClient = Depends(get_jellyfin_client),
|
||||
user_id: str = Depends(get_user_id),
|
||||
index: MediaIndex = Depends(get_media_index),
|
||||
) -> dict[str, Any]:
|
||||
"""Rebuild the media index from Jellyfin."""
|
||||
libraries = client.libraries(user_id)
|
||||
count = build_media_index(client, user_id, libraries, index)
|
||||
return {"indexed_items": count}
|
||||
|
||||
|
||||
@router.get("/query")
|
||||
def query_media(
|
||||
libraries: str = Query("", description="Comma-separated library IDs"),
|
||||
types: str = Query("Movie,Episode", description="Comma-separated media types"),
|
||||
search: str = Query("", description="Search term"),
|
||||
hdr_filter: str = Query("All", description="All, HDR only, SDR/unknown only"),
|
||||
sort_key: str = Query("title", description="Sort field"),
|
||||
sort_order: str = Query("Ascending", description="Ascending or Descending"),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
client: JellyfinClient = Depends(get_jellyfin_client),
|
||||
user_id: str = Depends(get_user_id),
|
||||
index: MediaIndex = Depends(get_media_index),
|
||||
) -> dict[str, Any]:
|
||||
"""Query the media index with filters, sorting, and pagination."""
|
||||
# If no library IDs provided, use all libraries
|
||||
library_ids = [lid.strip() for lid in libraries.split(",") if lid.strip()] if libraries else None
|
||||
if not library_ids:
|
||||
all_libs = client.libraries(user_id)
|
||||
library_ids = [lib["Id"] for lib in all_libs]
|
||||
|
||||
media_types = [t.strip() for t in types.split(",") if t.strip()]
|
||||
|
||||
rows, total = index.query(
|
||||
library_ids=library_ids,
|
||||
media_types=media_types,
|
||||
search=search,
|
||||
hdr_filter=hdr_filter,
|
||||
sort_key=sort_key,
|
||||
sort_order=sort_order,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return {
|
||||
"items": rows,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Monitoring router — metrics, collector controls, disk space."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from dependencies import get_ssh_client
|
||||
from clients.ssh import RemoteSSHClient
|
||||
from clients.resources import (
|
||||
disk_space,
|
||||
read_resource_metrics,
|
||||
resource_collector_debug_info,
|
||||
resource_collector_status,
|
||||
restart_resource_collector,
|
||||
start_resource_collector,
|
||||
stop_resource_collector,
|
||||
)
|
||||
from config import get_settings
|
||||
|
||||
router = APIRouter(prefix="/api/monitoring", tags=["monitoring"])
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def get_status(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
|
||||
"""Return collector running status."""
|
||||
return {"status": resource_collector_status(ssh)}
|
||||
|
||||
|
||||
@router.get("/metrics")
|
||||
def get_metrics(
|
||||
max_lines: int = 1000,
|
||||
last_seconds: int = 3600,
|
||||
ssh: RemoteSSHClient = Depends(get_ssh_client),
|
||||
) -> dict[str, Any]:
|
||||
"""Return resource metric samples from the remote collector."""
|
||||
rows = read_resource_metrics(ssh, max_lines=max_lines)
|
||||
cutoff_ts = time.time() - last_seconds
|
||||
filtered = [row for row in rows if float(row.get("ts", 0)) >= cutoff_ts]
|
||||
return {
|
||||
"samples": filtered,
|
||||
"total_samples": len(rows),
|
||||
"filtered_samples": len(filtered),
|
||||
"cutoff_ts": cutoff_ts,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/disk")
|
||||
def get_disk_space(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, Any]:
|
||||
"""Return disk space for the configured media root."""
|
||||
settings = get_settings()
|
||||
path = settings.media_root or "/"
|
||||
return disk_space(ssh, path)
|
||||
|
||||
|
||||
@router.post("/start")
|
||||
def post_start(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
|
||||
"""Start the remote resource collector."""
|
||||
message = start_resource_collector(ssh)
|
||||
return {"message": message}
|
||||
|
||||
|
||||
@router.post("/stop")
|
||||
def post_stop(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
|
||||
"""Stop the remote resource collector."""
|
||||
message = stop_resource_collector(ssh)
|
||||
return {"message": message}
|
||||
|
||||
|
||||
@router.post("/restart")
|
||||
def post_restart(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
|
||||
"""Restart the remote resource collector."""
|
||||
message = restart_resource_collector(ssh)
|
||||
return {"message": message}
|
||||
|
||||
|
||||
@router.get("/diagnostics")
|
||||
def get_diagnostics(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
|
||||
"""Return collector debug info for troubleshooting."""
|
||||
return {"diagnostics": resource_collector_debug_info(ssh)}
|
||||
Reference in New Issue
Block a user