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,167 @@
|
||||
"""Jellyfin HTTP API client.
|
||||
|
||||
This module is deliberately independent from Streamlit. It wraps only the API
|
||||
calls the app currently needs and returns plain Python dictionaries/lists so a
|
||||
future FastAPI/React frontend can reuse the same client.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
# Jellyfin validates Fields against its ItemFields enum. Keep this list to
|
||||
# documented/commonly supported optional fields; invalid names cause 400s.
|
||||
DEFAULT_FIELDS = ",".join(
|
||||
[
|
||||
"DateCreated",
|
||||
"Genres",
|
||||
"MediaSources",
|
||||
"Overview",
|
||||
"Path",
|
||||
"People",
|
||||
"PremiereDate",
|
||||
"ProviderIds",
|
||||
"Tags",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class JellyfinClient:
|
||||
"""Small wrapper around the Jellyfin/Emby-compatible HTTP API."""
|
||||
|
||||
def __init__(self, base_url: str, api_key: str, timeout: int = 30):
|
||||
if not base_url:
|
||||
raise ValueError("Jellyfin URL is required")
|
||||
if not api_key:
|
||||
raise ValueError("Jellyfin API key is required")
|
||||
|
||||
# Use the server root, not the web UI path. Users often paste
|
||||
# https://host/web; API endpoints live at https://host/...
|
||||
self.base_url = base_url.rstrip("/")
|
||||
if self.base_url.endswith("/web"):
|
||||
self.base_url = self.base_url[:-4]
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{
|
||||
"X-Emby-Token": api_key,
|
||||
"Accept": "application/json",
|
||||
"X-Emby-Authorization": 'MediaBrowser Client="MediaLibraryViewer", Device="Streamlit", DeviceId="streamlit", Version="0.1"',
|
||||
}
|
||||
)
|
||||
|
||||
def get(self, path: str, **params: Any) -> dict[str, Any]:
|
||||
"""GET a Jellyfin endpoint and include useful response text on errors."""
|
||||
clean_params = {k: v for k, v in params.items() if v is not None and v != ""}
|
||||
response = self.session.get(
|
||||
f"{self.base_url}{path}", params=clean_params, timeout=self.timeout
|
||||
)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
detail = response.text[:500]
|
||||
raise requests.HTTPError(
|
||||
f"{response.status_code} for {response.url}: {detail}",
|
||||
response=response,
|
||||
) from exc
|
||||
return response.json()
|
||||
|
||||
def users(self) -> list[dict[str, Any]]:
|
||||
"""List users visible to this API key.
|
||||
|
||||
Jellyfin API keys are server-level tokens, not user session tokens, so
|
||||
/Users/Me often fails with API-key auth. The user id selected here is
|
||||
then used for user-scoped library endpoints.
|
||||
"""
|
||||
return self.get("/Users")
|
||||
|
||||
def libraries(self, user_id: str) -> list[dict[str, Any]]:
|
||||
"""Return top-level library views visible to the selected Jellyfin user."""
|
||||
return self.get(f"/Users/{user_id}/Views").get("Items", [])
|
||||
|
||||
def items(
|
||||
self,
|
||||
user_id: str,
|
||||
parent_id: str | None = None,
|
||||
start_index: int = 0,
|
||||
limit: int = 50,
|
||||
search: str | None = None,
|
||||
include_item_types: str | None = None,
|
||||
recursive: bool = True,
|
||||
sort_by: str = "SortName",
|
||||
sort_order: str = "Ascending",
|
||||
) -> dict[str, Any]:
|
||||
"""Return a paginated item list for a user/library.
|
||||
|
||||
This is used by both the visual library browser and the media-index
|
||||
builder. Keep arguments close to Jellyfin's own query parameters so the
|
||||
service layer can request server-side pagination and basic sorting.
|
||||
"""
|
||||
return self.get(
|
||||
f"/Users/{user_id}/Items",
|
||||
ParentId=parent_id,
|
||||
StartIndex=start_index,
|
||||
Limit=limit,
|
||||
SearchTerm=search,
|
||||
IncludeItemTypes=include_item_types,
|
||||
Recursive=str(recursive).lower(),
|
||||
Fields=DEFAULT_FIELDS,
|
||||
SortBy=sort_by,
|
||||
SortOrder=sort_order,
|
||||
)
|
||||
|
||||
def item_count(self, user_id: str, include_item_types: str, parent_id: str | None = None) -> int:
|
||||
"""Return a count using Jellyfin's TotalRecordCount without fetching rows."""
|
||||
response = self.get(
|
||||
f"/Users/{user_id}/Items",
|
||||
ParentId=parent_id,
|
||||
Recursive="true",
|
||||
IncludeItemTypes=include_item_types,
|
||||
Limit=0,
|
||||
)
|
||||
return int(response.get("TotalRecordCount", 0))
|
||||
|
||||
def media_counts(self, user_id: str) -> dict[str, int]:
|
||||
"""Return dashboard-level counts for the main media types."""
|
||||
return {
|
||||
"movies": self.item_count(user_id, "Movie"),
|
||||
"series": self.item_count(user_id, "Series"),
|
||||
"episodes": self.item_count(user_id, "Episode"),
|
||||
}
|
||||
|
||||
def library_item_counts(self, user_id: str, libraries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Return per-library item counts broken down by type for the dashboard."""
|
||||
results = []
|
||||
for lib in libraries:
|
||||
lib_id = lib.get("Id")
|
||||
lib_name = lib.get("Name", "Unknown")
|
||||
lib_type = lib.get("CollectionType", "")
|
||||
if not lib_id:
|
||||
continue
|
||||
movies = self.item_count(user_id, "Movie", parent_id=lib_id)
|
||||
series = self.item_count(user_id, "Series", parent_id=lib_id)
|
||||
episodes = self.item_count(user_id, "Episode", parent_id=lib_id)
|
||||
total = self.item_count(user_id, "Movie,Episode,Video,Audio,Series", parent_id=lib_id)
|
||||
results.append({
|
||||
"library": lib_name,
|
||||
"type": lib_type,
|
||||
"movies": movies,
|
||||
"series": series,
|
||||
"episodes": episodes,
|
||||
"total": total,
|
||||
})
|
||||
return results
|
||||
|
||||
def active_sessions(self, active_within_seconds: int = 300) -> list[dict[str, Any]]:
|
||||
"""Return currently active sessions that have a now-playing item."""
|
||||
payload = self.get("/Sessions", ActiveWithinSeconds=active_within_seconds)
|
||||
sessions = payload if isinstance(payload, list) else []
|
||||
return [session for session in sessions if session.get("NowPlayingItem")]
|
||||
|
||||
def image_url(self, item_id: str, image_type: str = "Primary") -> str:
|
||||
"""Build an authenticated image URL suitable for st.image/browser use."""
|
||||
return f"{self.base_url}/Items/{item_id}/Images/{image_type}?api_key={self.api_key}"
|
||||
@@ -0,0 +1,316 @@
|
||||
"""Remote resource collection helpers.
|
||||
|
||||
The app does not require Prometheus, Netdata, or sysstat. Instead it can install
|
||||
and manage a tiny POSIX-sh collector under /tmp on the remote server. The
|
||||
collector samples Linux /proc and /sys counters every 10 seconds and appends JSON
|
||||
Lines. This module starts/stops the collector and reads those JSONL samples.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from clients.ssh import RemoteSSHClient
|
||||
|
||||
# POSIX shell script copied to the remote server by start_resource_collector().
|
||||
# Keep this script bash-free because many NAS/media servers have minimal shells.
|
||||
COLLECTOR_SCRIPT = r'''#!/bin/sh
|
||||
set -u
|
||||
OUT="${1:-/tmp/media_library_viewer_metrics.jsonl}"
|
||||
INTERVAL="${2:-10}"
|
||||
RETENTION_SECONDS="${3:-604800}"
|
||||
MAX_LINES="${4:-70000}"
|
||||
PRUNE_EVERY_SAMPLES="${5:-60}"
|
||||
mkdir -p "$(dirname "$OUT")"
|
||||
|
||||
echo "collector starting at $(date -Is 2>/dev/null || date), interval=${INTERVAL}s, retention=${RETENTION_SECONDS}s, max_lines=${MAX_LINES}, out=${OUT}"
|
||||
|
||||
read_cpu() {
|
||||
awk '/^cpu / {print $2+$3+$4+$5+$6+$7+$8+$9+$10, $5+$6, $6}' /proc/stat
|
||||
}
|
||||
|
||||
read_mem_pct() {
|
||||
awk '
|
||||
/^MemTotal:/ {total=$2}
|
||||
/^MemAvailable:/ {avail=$2}
|
||||
END {if (total > 0) printf "%.2f", (total-avail)*100/total; else printf "0"}
|
||||
' /proc/meminfo
|
||||
}
|
||||
|
||||
read_net_bytes() {
|
||||
awk '
|
||||
NR > 2 {
|
||||
split($0, parts, ":")
|
||||
iface = parts[1]
|
||||
stats = parts[2]
|
||||
gsub(/^[ \t]+|[ \t]+$/, "", iface)
|
||||
gsub(/^[ \t]+|[ \t]+$/, "", stats)
|
||||
if (iface == "lo" || iface == "" || stats == "") next
|
||||
split(stats, values, /[ \t]+/)
|
||||
# /proc/net/dev after the colon:
|
||||
# receive bytes are field 1, transmit bytes are field 9.
|
||||
# Trim the stats block before split; otherwise leading whitespace can make
|
||||
# values[1] empty in some awk implementations, resulting in zero rates.
|
||||
rx += values[1] + 0
|
||||
tx += values[9] + 0
|
||||
}
|
||||
END {printf "%.0f %.0f", rx, tx}
|
||||
' /proc/net/dev
|
||||
}
|
||||
|
||||
read_disk_bytes() {
|
||||
read_sectors=0
|
||||
written_sectors=0
|
||||
for dev in /sys/block/*; do
|
||||
[ -r "$dev/stat" ] || continue
|
||||
name="$(basename "$dev")"
|
||||
case "$name" in
|
||||
loop*|ram*|fd*|sr*) continue ;;
|
||||
esac
|
||||
# Linux /sys/block/<dev>/stat fields: 3=sectors read, 7=sectors written.
|
||||
# Use POSIX sh parsing instead of bash arrays so this works on minimal systems.
|
||||
set -- $(cat "$dev/stat")
|
||||
sectors_read="${3:-0}"
|
||||
sectors_written="${7:-0}"
|
||||
read_sectors=$((read_sectors + sectors_read))
|
||||
written_sectors=$((written_sectors + sectors_written))
|
||||
done
|
||||
printf "%s %s" "$((read_sectors * 512))" "$((written_sectors * 512))"
|
||||
}
|
||||
|
||||
set -- $(read_cpu)
|
||||
prev_total="${1:-0}"
|
||||
prev_idle="${2:-0}"
|
||||
prev_iowait="${3:-0}"
|
||||
set -- $(read_net_bytes)
|
||||
prev_rx="${1:-0}"
|
||||
prev_tx="${2:-0}"
|
||||
set -- $(read_disk_bytes)
|
||||
prev_disk_read="${1:-0}"
|
||||
prev_disk_write="${2:-0}"
|
||||
prev_ts="$(date +%s)"
|
||||
sample_count=0
|
||||
|
||||
prune_metrics_file() {
|
||||
[ -f "$OUT" ] || return 0
|
||||
cutoff="$1"
|
||||
tmp="${OUT}.$$.tmp"
|
||||
awk -v cutoff="$cutoff" '
|
||||
match($0, /"ts":[0-9]+/) {
|
||||
ts = substr($0, RSTART + 5, RLENGTH - 5)
|
||||
if (ts >= cutoff) print $0
|
||||
}
|
||||
' "$OUT" | tail -n "$MAX_LINES" > "$tmp" && mv "$tmp" "$OUT"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
while true; do
|
||||
sleep "$INTERVAL"
|
||||
now_ts="$(date +%s)"
|
||||
dt=$((now_ts - prev_ts))
|
||||
if [ "$dt" -le 0 ]; then dt=1; fi
|
||||
|
||||
set -- $(read_cpu)
|
||||
total="${1:-0}"
|
||||
idle="${2:-0}"
|
||||
iowait="${3:-0}"
|
||||
set -- $(read_net_bytes)
|
||||
rx="${1:-0}"
|
||||
tx="${2:-0}"
|
||||
set -- $(read_disk_bytes)
|
||||
disk_read="${1:-0}"
|
||||
disk_write="${2:-0}"
|
||||
mem_pct="$(read_mem_pct)"
|
||||
|
||||
total_delta=$((total - prev_total))
|
||||
idle_delta=$((idle - prev_idle))
|
||||
iowait_delta=$((iowait - prev_iowait))
|
||||
rx_delta=$((rx - prev_rx))
|
||||
tx_delta=$((tx - prev_tx))
|
||||
disk_read_delta=$((disk_read - prev_disk_read))
|
||||
disk_write_delta=$((disk_write - prev_disk_write))
|
||||
|
||||
cpu_pct="$(awk -v total="$total_delta" -v idle="$idle_delta" 'BEGIN {if (total > 0) printf "%.2f", (total-idle)*100/total; else printf "0"}')"
|
||||
iowait_pct="$(awk -v total="$total_delta" -v iow="$iowait_delta" 'BEGIN {if (total > 0) printf "%.2f", iow*100/total; else printf "0"}')"
|
||||
rx_bytes_per_sec="$(awk -v bytes="$rx_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes/dt; else printf "0"}')"
|
||||
tx_bytes_per_sec="$(awk -v bytes="$tx_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes/dt; else printf "0"}')"
|
||||
rx_bps="$(awk -v bytes="$rx_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes*8/dt; else printf "0"}')"
|
||||
tx_bps="$(awk -v bytes="$tx_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes*8/dt; else printf "0"}')"
|
||||
disk_read_bps="$(awk -v bytes="$disk_read_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes/dt; else printf "0"}')"
|
||||
disk_write_bps="$(awk -v bytes="$disk_write_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes/dt; else printf "0"}')"
|
||||
|
||||
printf '{"ts":%s,"cpu_pct":%s,"iowait_pct":%s,"mem_pct":%s,"net_rx_bytes_per_sec":%s,"net_tx_bytes_per_sec":%s,"net_rx_bps":%s,"net_tx_bps":%s,"disk_read_bps":%s,"disk_write_bps":%s}\n' \
|
||||
"$now_ts" "$cpu_pct" "$iowait_pct" "$mem_pct" "$rx_bytes_per_sec" "$tx_bytes_per_sec" "$rx_bps" "$tx_bps" "$disk_read_bps" "$disk_write_bps" >> "$OUT"
|
||||
|
||||
sample_count=$((sample_count + 1))
|
||||
if [ $((sample_count % PRUNE_EVERY_SAMPLES)) -eq 0 ]; then
|
||||
prune_metrics_file "$((now_ts - RETENTION_SECONDS))"
|
||||
fi
|
||||
|
||||
prev_total="$total"
|
||||
prev_idle="$idle"
|
||||
prev_iowait="$iowait"
|
||||
prev_rx="$rx"
|
||||
prev_tx="$tx"
|
||||
prev_disk_read="$disk_read"
|
||||
prev_disk_write="$disk_write"
|
||||
prev_ts="$now_ts"
|
||||
done
|
||||
'''
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResourceMonitorPaths:
|
||||
"""Remote file locations used by the lightweight resource collector."""
|
||||
|
||||
metrics_file: str = "/tmp/media_library_viewer_metrics.jsonl"
|
||||
pid_file: str = "/tmp/media_library_viewer_metrics.pid"
|
||||
script_file: str = "/tmp/media_library_viewer_metrics_collector.sh"
|
||||
log_file: str = "/tmp/media_library_viewer_metrics.log"
|
||||
|
||||
|
||||
def start_resource_collector(
|
||||
ssh: RemoteSSHClient,
|
||||
interval_seconds: int = 10,
|
||||
retention_seconds: int = 7 * 24 * 60 * 60,
|
||||
max_lines: int = 70_000,
|
||||
paths: ResourceMonitorPaths = ResourceMonitorPaths(),
|
||||
) -> str:
|
||||
"""Install and start the remote metrics collector if it is not running.
|
||||
|
||||
Starting a fresh collector removes old metrics/log files because schema
|
||||
changes during development can otherwise leave mixed JSONL records behind.
|
||||
The collector prunes its own metrics file to 7 days / max_lines.
|
||||
"""
|
||||
command = f"""
|
||||
cat > {shlex.quote(paths.script_file)} <<'MLV_RESOURCE_COLLECTOR'
|
||||
{COLLECTOR_SCRIPT}
|
||||
MLV_RESOURCE_COLLECTOR
|
||||
chmod +x {shlex.quote(paths.script_file)}
|
||||
if [ -f {shlex.quote(paths.pid_file)} ] && kill -0 "$(cat {shlex.quote(paths.pid_file)})" 2>/dev/null; then
|
||||
echo "already running pid=$(cat {shlex.quote(paths.pid_file)})"
|
||||
else
|
||||
rm -f {shlex.quote(paths.metrics_file)} {shlex.quote(paths.log_file)}
|
||||
nohup {shlex.quote(paths.script_file)} {shlex.quote(paths.metrics_file)} {int(interval_seconds)} {int(retention_seconds)} {int(max_lines)} >> {shlex.quote(paths.log_file)} 2>&1 &
|
||||
echo $! > {shlex.quote(paths.pid_file)}
|
||||
echo "started pid=$(cat {shlex.quote(paths.pid_file)})"
|
||||
fi
|
||||
"""
|
||||
result = ssh.run(command, timeout=20)
|
||||
if result.exit_status != 0:
|
||||
raise RuntimeError(result.stderr or result.stdout or "failed to start resource collector")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def stop_resource_collector(ssh: RemoteSSHClient, paths: ResourceMonitorPaths = ResourceMonitorPaths()) -> str:
|
||||
"""Stop the remote collector process if the pid file points to one."""
|
||||
command = f"""
|
||||
if [ -f {shlex.quote(paths.pid_file)} ]; then
|
||||
pid="$(cat {shlex.quote(paths.pid_file)})"
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
kill "$pid"
|
||||
echo "stopped pid=$pid"
|
||||
else
|
||||
echo "not running"
|
||||
fi
|
||||
rm -f {shlex.quote(paths.pid_file)}
|
||||
else
|
||||
echo "not running"
|
||||
fi
|
||||
"""
|
||||
result = ssh.run(command, timeout=20)
|
||||
if result.exit_status != 0:
|
||||
raise RuntimeError(result.stderr or result.stdout or "failed to stop resource collector")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def restart_resource_collector(
|
||||
ssh: RemoteSSHClient,
|
||||
interval_seconds: int = 10,
|
||||
retention_seconds: int = 7 * 24 * 60 * 60,
|
||||
max_lines: int = 70_000,
|
||||
paths: ResourceMonitorPaths = ResourceMonitorPaths(),
|
||||
) -> str:
|
||||
stop_message = stop_resource_collector(ssh, paths)
|
||||
start_message = start_resource_collector(ssh, interval_seconds, retention_seconds, max_lines, paths)
|
||||
return f"{stop_message}\n{start_message}"
|
||||
|
||||
|
||||
def resource_collector_status(ssh: RemoteSSHClient, paths: ResourceMonitorPaths = ResourceMonitorPaths()) -> str:
|
||||
"""Return a short human-readable status string for the dashboard."""
|
||||
command = f"""
|
||||
if [ -f {shlex.quote(paths.pid_file)} ] && kill -0 "$(cat {shlex.quote(paths.pid_file)})" 2>/dev/null; then
|
||||
echo "running pid=$(cat {shlex.quote(paths.pid_file)})"
|
||||
else
|
||||
echo "not running"
|
||||
fi
|
||||
"""
|
||||
result = ssh.run(command, timeout=10)
|
||||
if result.exit_status != 0:
|
||||
raise RuntimeError(result.stderr or result.stdout or "failed to check collector status")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def resource_collector_debug_info(ssh: RemoteSSHClient, paths: ResourceMonitorPaths = ResourceMonitorPaths()) -> str:
|
||||
"""Collect remote diagnostics for troubleshooting missing metrics."""
|
||||
command = f"""
|
||||
echo "status:"
|
||||
if [ -f {shlex.quote(paths.pid_file)} ]; then
|
||||
pid="$(cat {shlex.quote(paths.pid_file)})"
|
||||
echo "pid_file=$pid"
|
||||
if kill -0 "$pid" 2>/dev/null; then echo "process=running"; else echo "process=not-running"; fi
|
||||
else
|
||||
echo "pid_file=missing"
|
||||
fi
|
||||
|
||||
echo "files:"
|
||||
ls -l {shlex.quote(paths.script_file)} {shlex.quote(paths.metrics_file)} {shlex.quote(paths.log_file)} 2>&1 || true
|
||||
|
||||
echo "sample_count:"
|
||||
if [ -f {shlex.quote(paths.metrics_file)} ]; then wc -l < {shlex.quote(paths.metrics_file)}; else echo 0; fi
|
||||
|
||||
echo "last_samples:"
|
||||
if [ -f {shlex.quote(paths.metrics_file)} ]; then tail -n 5 {shlex.quote(paths.metrics_file)}; fi
|
||||
|
||||
echo "log_tail:"
|
||||
if [ -f {shlex.quote(paths.log_file)} ]; then tail -n 40 {shlex.quote(paths.log_file)}; fi
|
||||
|
||||
echo "netdev_snapshot:"
|
||||
cat /proc/net/dev 2>&1 || true
|
||||
"""
|
||||
result = ssh.run(command, timeout=20)
|
||||
return (result.stdout or "") + (result.stderr or "")
|
||||
|
||||
|
||||
def read_resource_metrics(ssh: RemoteSSHClient, max_lines: int = 1000, paths: ResourceMonitorPaths = ResourceMonitorPaths()) -> list[dict[str, Any]]:
|
||||
"""Read recent JSONL metric samples from the remote collector file."""
|
||||
command = f"test -f {shlex.quote(paths.metrics_file)} && tail -n {int(max_lines)} {shlex.quote(paths.metrics_file)} || true"
|
||||
result = ssh.run(command, timeout=20)
|
||||
if result.exit_status != 0:
|
||||
raise RuntimeError(result.stderr or result.stdout or "failed to read resource metrics")
|
||||
rows = []
|
||||
for line in result.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rows.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return rows
|
||||
|
||||
|
||||
def disk_space(ssh: RemoteSSHClient, path: str = "/") -> dict[str, Any]:
|
||||
"""Return df information for the filesystem containing ``path``."""
|
||||
command = (
|
||||
"df -P -B1 -- "
|
||||
+ shlex.quote(path or "/")
|
||||
+ " | awk 'NR==2 {printf \"{\\\"filesystem\\\":\\\"%s\\\",\\\"size\\\":%s,\\\"used\\\":%s,\\\"available\\\":%s,\\\"used_pct\\\":\\\"%s\\\",\\\"mount\\\":\\\"%s\\\"}\", $1,$2,$3,$4,$5,$6}'"
|
||||
)
|
||||
result = ssh.run(command, timeout=20)
|
||||
if result.exit_status != 0 or not result.stdout.strip():
|
||||
raise RuntimeError(result.stderr or result.stdout or "failed to read disk space")
|
||||
return json.loads(result.stdout)
|
||||
@@ -0,0 +1,146 @@
|
||||
"""SSH client helpers for remote filesystem and media inspection.
|
||||
|
||||
All command execution goes through ``/bin/sh -c`` and all paths inserted into
|
||||
commands are shell-quoted by callers. This is important for two reasons:
|
||||
|
||||
1. The remote login shell may be fish/csh/etc.; internal commands are POSIX sh.
|
||||
2. Media paths frequently contain spaces and punctuation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import posixpath
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import paramiko
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
"""Plain result object returned by remote command execution."""
|
||||
|
||||
command: str
|
||||
exit_status: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
|
||||
|
||||
class RemoteSSHClient:
|
||||
"""SSH helper for read-only inspection plus explicit job execution."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
username: str,
|
||||
port: int = 22,
|
||||
key_filename: str | None = None,
|
||||
password: str | None = None,
|
||||
timeout: int = 20,
|
||||
):
|
||||
if not host or not username:
|
||||
raise ValueError("SSH host and username are required")
|
||||
self.host = host
|
||||
self.username = username
|
||||
self.port = port
|
||||
self.key_filename = key_filename or None
|
||||
self.password = password or None
|
||||
self.timeout = timeout
|
||||
self._client: paramiko.SSHClient | None = None
|
||||
|
||||
def connect(self) -> paramiko.SSHClient:
|
||||
"""Create or reuse the Paramiko connection.
|
||||
|
||||
Unknown host keys are rejected. Users should connect once manually with
|
||||
ssh so the server is present in known_hosts.
|
||||
"""
|
||||
if self._client:
|
||||
return self._client
|
||||
client = paramiko.SSHClient()
|
||||
client.load_system_host_keys()
|
||||
client.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
client.connect(
|
||||
self.host,
|
||||
port=self.port,
|
||||
username=self.username,
|
||||
key_filename=self.key_filename,
|
||||
password=self.password,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
self._client = client
|
||||
return client
|
||||
|
||||
def close(self) -> None:
|
||||
if self._client:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
|
||||
def run(self, command: str, timeout: int | None = None) -> CommandResult:
|
||||
"""Run a command through POSIX sh, independent of the user's login shell.
|
||||
|
||||
Paramiko asks the SSH server to execute a command using the account's
|
||||
default shell. If that shell is fish/csh/etc., POSIX snippets containing
|
||||
`if ...; then`, pipes, redirects, or heredocs can fail. All internal app
|
||||
commands and job templates are written for POSIX shell, so explicitly
|
||||
dispatch through `/bin/sh -c`.
|
||||
"""
|
||||
client = self.connect()
|
||||
shell_command = f"/bin/sh -c {shlex.quote(command)}"
|
||||
stdin, stdout, stderr = client.exec_command(shell_command, timeout=timeout or self.timeout)
|
||||
exit_status = stdout.channel.recv_exit_status()
|
||||
return CommandResult(
|
||||
command=command,
|
||||
exit_status=exit_status,
|
||||
stdout=stdout.read().decode(errors="replace"),
|
||||
stderr=stderr.read().decode(errors="replace"),
|
||||
)
|
||||
|
||||
def list_dir(self, path: str) -> CommandResult:
|
||||
"""List one remote directory as JSON.
|
||||
|
||||
The command first verifies that ``path`` is a directory. Without that
|
||||
guard, running ``find`` on a file can look like an empty directory, which
|
||||
was a source of file-browser confusion. Output is NUL-delimited before
|
||||
Python serializes it, making spaces in filenames safe.
|
||||
"""
|
||||
# JSON-ish output: type, size, mtime epoch, filename. Handles spaces/newlines reasonably via NUL boundaries.
|
||||
quoted = shlex.quote(path)
|
||||
not_dir_message = shlex.quote(f"Not a directory: {path}")
|
||||
command = (
|
||||
f"test -d {quoted} || "
|
||||
f"{{ echo {not_dir_message} >&2; exit 20; }}; "
|
||||
f"find {quoted} -maxdepth 1 -mindepth 1 -printf "
|
||||
"'%y\\t%s\\t%T@\\t%f\\0' | python3 -c "
|
||||
+ shlex.quote(
|
||||
"import sys,json; data=sys.stdin.buffer.read().split(b'\\0'); "
|
||||
"rows=[]\n"
|
||||
"for row in data:\n"
|
||||
" if not row: continue\n"
|
||||
" t,s,m,n=row.decode('utf-8','replace').split('\\t',3)\n"
|
||||
" rows.append({'type':t,'size':int(s),'mtime':float(m),'name':n})\n"
|
||||
"print(json.dumps(rows))"
|
||||
)
|
||||
)
|
||||
return self.run(command)
|
||||
|
||||
def stat_path(self, path: str) -> CommandResult:
|
||||
"""Run stat for a remote file or directory path."""
|
||||
quoted = shlex.quote(path)
|
||||
return self.run(f"stat --printf='%F\\n%s bytes\\n%y\\n%n\\n' {quoted}")
|
||||
|
||||
def ffprobe_json(self, path: str) -> dict[str, Any]:
|
||||
"""Run ffprobe and parse JSON output for a remote media file."""
|
||||
quoted = shlex.quote(path)
|
||||
result = self.run(
|
||||
"ffprobe -v error -show_format -show_streams -print_format json " + quoted,
|
||||
timeout=60,
|
||||
)
|
||||
if result.exit_status != 0:
|
||||
raise RuntimeError(result.stderr or result.stdout or "ffprobe failed")
|
||||
return json.loads(result.stdout)
|
||||
|
||||
@staticmethod
|
||||
def join(parent: str, child: str) -> str:
|
||||
return posixpath.normpath(posixpath.join(parent, child))
|
||||
Reference in New Issue
Block a user