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:
2026-04-30 21:40:18 +02:00
parent 1acdfbc6ba
commit 3c432473e5
63 changed files with 7778 additions and 202 deletions
+8
View File
@@ -39,3 +39,11 @@ env/
# Logs/temp
*.log
tmp/
# Frontend
frontend/node_modules/
frontend/dist/
# Pi internal
.pi-lens/
.pi/
+1 -3
View File
@@ -13,10 +13,8 @@ Project policy/docs:
- Dashboard media counts for movies, series, and episodes, plus now-playing sessions (user, title, playback state, transcoding)
- SQLite-indexed Media tab with full-library sort/filter for runtime, size, bitrate, explicit HDR yes/no flag, date added, codec, resolution, series, season, episode, path, and row-based selection that automatically syncs File browser to the selected item's folder
- SSH resource overview dashboard plus detailed Resources tab for CPU, RAM, network, disk I/O, and disk space
- Server monitoring dashboard plus detailed Monitoring tab for CPU, IO wait, RAM, network, disk I/O, and disk space
- Jellyfin API-key connection using `GET /Users` plus user-scoped library endpoints
- Library selection, search, pagination, poster grid
- Item details with Jellyfin metadata and raw JSON
- Compact SSH remote directory browser with clickable rows, search, filters, sorting, pagination, and `[UP] ..` navigation
- Blocking selected-file `ffprobe` preview for known video files
- Separate container, video, audio, and subtitle metadata sections
+65
View File
@@ -0,0 +1,65 @@
# Backend - Media Library Viewer API
FastAPI backend serving the REST API for Jellyfin media browsing, SSH file inspection, and server monitoring.
## Setup
```bash
cd backend
python -m venv .venv
source .venv/bin/activate
pip install -e '.[dev]'
```
Or install dependencies directly:
```bash
pip install fastapi uvicorn[standard] pydantic-settings paramiko requests python-dotenv pandas
```
## Configuration
Create a `.env` file in the project root (or set environment variables):
```bash
JELLYFIN_URL=https://jellyfin.example.com
JELLYFIN_API_KEY=your-api-key
JELLYFIN_USER_ID=
SSH_HOST=media-server.example.com
SSH_USERNAME=username
SSH_PORT=22
SSH_KEY_FILENAME=/home/username/.ssh/id_rsa
SSH_PASSWORD=
REMOTE_MEDIA_ROOT=/srv/media
REMOTE_PATH_PREFIX=
```
## Running
```bash
cd backend
uvicorn main:app --reload --port 8000
```
API docs available at: http://localhost:8000/docs
## API Endpoints
- `GET /api/dashboard/counts` — Movie/series/episode totals
- `GET /api/dashboard/libraries` — Per-library breakdown
- `GET /api/dashboard/now-playing` — Active playback sessions
- `GET /api/monitoring/status` — Collector status
- `GET /api/monitoring/metrics` — Resource samples
- `GET /api/monitoring/disk` — Disk space
- `POST /api/monitoring/start|stop|restart` — Collector controls
- `GET /api/media/status` — Index status
- `POST /api/media/build` — Rebuild index
- `GET /api/media/query` — Query with filters/sort/pagination
- `GET /api/files/list?path=` — Directory listing
- `GET /api/files/ffprobe?path=` — ffprobe JSON
- `GET /api/files/stat?path=` — stat output
- `GET /api/files/resolve-path?path=` — Path resolution
- `GET /api/jobs/templates` — Available jobs
- `POST /api/jobs/run` — Execute a job
View File
View File
+167
View File
@@ -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}"
+316
View File
@@ -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)
+146
View File
@@ -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))
+63
View File
@@ -0,0 +1,63 @@
"""Backend configuration using pydantic-settings.
Reads from environment variables and .env file automatically.
"""
from __future__ import annotations
from pathlib import Path
from pydantic_settings import BaseSettings
class JellyfinSettings(BaseSettings):
url: str = ""
api_key: str = ""
user_id: str = ""
model_config = {"env_prefix": "JELLYFIN_"}
class SSHSettings(BaseSettings):
host: str = ""
username: str = ""
port: int = 22
key_filename: str = str(Path.home() / ".ssh" / "id_rsa")
password: str = ""
media_root: str = ""
path_prefix: str = ""
model_config = {"env_prefix": "SSH_"}
class RemoteSettings(BaseSettings):
"""Extra remote settings that don't fit the SSH_ prefix."""
media_root: str = ""
path_prefix: str = ""
model_config = {"env_prefix": "REMOTE_"}
class Settings(BaseSettings):
"""Top-level application settings."""
jellyfin: JellyfinSettings = JellyfinSettings()
ssh: SSHSettings = SSHSettings()
remote: RemoteSettings = RemoteSettings()
# Derived convenience properties
@property
def media_root(self) -> str:
return self.remote.media_root or self.ssh.media_root or ""
@property
def path_prefix(self) -> str:
return self.remote.path_prefix or self.ssh.path_prefix or ""
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}
def get_settings() -> Settings:
"""Create a Settings instance (reads env/.env on each call)."""
return Settings()
+47
View File
@@ -0,0 +1,47 @@
"""Dependency injection for FastAPI.
Provides singleton-like access to SSH and Jellyfin clients via FastAPI's
dependency system. Uses lru_cache so connections are reused across requests.
"""
from __future__ import annotations
from functools import lru_cache
from clients.jellyfin import JellyfinClient
from clients.ssh import RemoteSSHClient
from config import get_settings
@lru_cache
def get_jellyfin_client() -> JellyfinClient:
"""Return a cached Jellyfin client."""
settings = get_settings()
return JellyfinClient(settings.jellyfin.url, settings.jellyfin.api_key)
@lru_cache
def get_ssh_client() -> RemoteSSHClient:
"""Return a cached SSH client (connects on first use)."""
settings = get_settings()
client = RemoteSSHClient(
host=settings.ssh.host,
username=settings.ssh.username,
port=settings.ssh.port,
key_filename=settings.ssh.key_filename or None,
password=settings.ssh.password or None,
)
client.connect()
return client
def get_user_id() -> str:
"""Return the configured Jellyfin user ID, or discover the first available user."""
settings = get_settings()
if settings.jellyfin.user_id:
return settings.jellyfin.user_id
client = get_jellyfin_client()
users = client.users()
if not users:
raise RuntimeError("No Jellyfin users found and JELLYFIN_USER_ID not set")
return users[0]["Id"]
View File
+161
View File
@@ -0,0 +1,161 @@
"""Media-domain normalization helpers.
Jellyfin item JSON is nested and inconsistent across item types. This module
flattens Jellyfin items into stable dictionaries suitable for storage in the
SQLite media index and display by any frontend.
"""
from __future__ import annotations
from typing import Any
import pandas as pd
from utils import human_size, ticks_to_minutes
def first_media_source(item: dict[str, Any]) -> dict[str, Any]:
"""Return the first Jellyfin media source, or an empty dict."""
sources = item.get("MediaSources") or []
return sources[0] if sources else {}
def media_streams(item: dict[str, Any], stream_type: str | None = None) -> list[dict[str, Any]]:
"""Return flattened media streams from all media sources.
Jellyfin usually nests streams under MediaSources, while some endpoints may
expose stream-like fields differently. This function gives callers one place
to get streams and optionally filter by type.
"""
streams = []
for source in item.get("MediaSources") or []:
streams.extend(source.get("MediaStreams") or [])
if stream_type is None:
return streams
return [stream for stream in streams if str(stream.get("Type") or stream.get("codec_type") or "").lower() == stream_type.lower()]
def stream_value(stream: dict[str, Any], *keys: str) -> Any:
for key in keys:
if key in stream and stream[key] not in (None, ""):
return stream[key]
return None
def is_hdr_item(item: dict[str, Any]) -> bool:
"""Best-effort HDR detection from Jellyfin video stream metadata."""
hdr_markers = {"hdr", "hdr10", "hdr10+", "dolbyvision", "dovi", "hlg", "pq", "smpte2084", "bt2020"}
for stream in media_streams(item, "Video"):
values = [
stream_value(stream, "VideoRange", "video_range"),
stream_value(stream, "VideoRangeType", "video_range_type"),
stream_value(stream, "ColorTransfer", "color_transfer"),
stream_value(stream, "ColorPrimaries", "color_primaries"),
stream_value(stream, "ColorSpace", "color_space"),
stream_value(stream, "DvVersionMajor", "dv_version_major"),
stream_value(stream, "Hdr10PlusPresent", "hdr10_plus_present"),
]
normalized = " ".join(str(value).lower() for value in values if value not in (None, "", False, 0))
if any(marker in normalized for marker in hdr_markers):
return True
return False
def format_date_added(value: str | None) -> str:
if not value:
return ""
try:
return pd.to_datetime(value).strftime("%Y-%m-%d")
except Exception:
return str(value)
def timestamp_date_added(value: str | None) -> int | None:
if not value:
return None
try:
return int(pd.to_datetime(value).timestamp())
except Exception:
return None
def format_rate_bits_decimal(bits_per_second: float | int | str | None) -> str:
if bits_per_second in (None, ""):
return ""
try:
value = float(bits_per_second)
except (TypeError, ValueError):
return str(bits_per_second)
for unit in ["bps", "Kbps", "Mbps", "Gbps", "Tbps"]:
if value < 1000 or unit == "Tbps":
return f"{value:.1f} {unit}"
value /= 1000
return f"{value:.1f} Tbps"
def normalize_media_item(item: dict[str, Any], library_id: str = "", library_name: str = "") -> dict[str, Any]:
"""Flatten one Jellyfin item into an indexable row.
The returned row contains both display strings (``size``, ``bitrate``) and
numeric sort fields (``size_bytes``, ``bitrate_bps``, ``date_added_ts``).
"""
source = first_media_source(item)
video_streams = media_streams(item, "Video")
video = video_streams[0] if video_streams else {}
size = source.get("Size") or source.get("size")
bitrate = source.get("Bitrate") or source.get("bitrate") or item.get("Bitrate")
width = stream_value(video, "Width", "width")
height = stream_value(video, "Height", "height")
season_number = item.get("ParentIndexNumber")
episode_number = item.get("IndexNumber")
hdr = is_hdr_item(item)
return {
"id": item.get("Id", ""),
"title": item.get("Name", ""),
"series": item.get("SeriesName", ""),
"season": f"S{int(season_number):02d}" if season_number is not None else item.get("SeasonName", ""),
"season_number": int(season_number) if season_number is not None else None,
"episode": int(episode_number) if episode_number is not None else None,
"type": item.get("Type", ""),
"year": item.get("ProductionYear"),
"runtime_ticks": item.get("RunTimeTicks"),
"runtime_min": ticks_to_minutes(item.get("RunTimeTicks")),
"size_bytes": int(size) if size not in (None, "") else None,
"size": human_size(size),
"bitrate_bps": int(bitrate) if bitrate not in (None, "") else None,
"bitrate": format_rate_bits_decimal(bitrate),
"hdr": 1 if hdr else 0,
"hdr_label": "yes" if hdr else "",
"video": video.get("Codec") or video.get("codec_name") or "",
"width": int(width) if width not in (None, "") else None,
"height": int(height) if height not in (None, "") else None,
"resolution": f"{width}x{height}" if width and height else "",
"date_added": format_date_added(item.get("DateCreated")),
"date_added_ts": timestamp_date_added(item.get("DateCreated")),
"path": item.get("Path") or source.get("Path") or "",
"library_id": library_id,
"library_name": library_name,
}
def display_media_row(row: dict[str, Any]) -> dict[str, Any]:
"""Convert a SQLite row back into frontend display fields."""
return {
"title": row.get("title", ""),
"series": row.get("series", ""),
"season": row.get("season", ""),
"episode": row.get("episode", ""),
"type": row.get("type", ""),
"year": row.get("year", ""),
"runtime_min": row.get("runtime_min", ""),
"size": row.get("size") or human_size(row.get("size_bytes")),
"bitrate": row.get("bitrate") or format_rate_bits_decimal(row.get("bitrate_bps")),
"hdr": "yes" if row.get("hdr") else "no",
"video": row.get("video", ""),
"resolution": row.get("resolution", ""),
"date_added": row.get("date_added", ""),
"library": row.get("library_name", ""),
"path": row.get("path", ""),
"id": row.get("id", ""),
}
+59
View File
@@ -0,0 +1,59 @@
"""Template-based remote jobs.
Remote jobs are intentionally explicit templates instead of free-form shell input.
This keeps the UI safer and makes future destructive operations easier to wrap in
confirmations/dry-runs.
"""
from __future__ import annotations
import shlex
from dataclasses import dataclass
from typing import Mapping
from clients.ssh import CommandResult, RemoteSSHClient
@dataclass(frozen=True)
class JobTemplate:
"""Description and command template for one remote job."""
name: str
description: str
command_template: str
destructive: bool = False
def render(self, values: Mapping[str, str]) -> str:
"""Render the command with shell-quoted template values.
This is what keeps paths with spaces safe when inserted into job commands.
"""
safe_values = {key: shlex.quote(value) for key, value in values.items()}
return self.command_template.format(**safe_values)
# Phase 1 jobs are intentionally conservative. Add your own templates here later.
JOB_TEMPLATES: dict[str, JobTemplate] = {
"disk_usage": JobTemplate(
name="Disk usage for selected path",
description="Runs du -sh on the selected remote path.",
command_template="du -sh {path}",
),
"ffprobe": JobTemplate(
name="ffprobe JSON",
description="Prints raw ffprobe stream/format metadata.",
command_template="ffprobe -v error -show_format -show_streams -print_format json {path}",
),
"dry_run_find_empty_dirs": JobTemplate(
name="Find empty directories dry-run",
description="Lists empty directories under the selected path. Does not delete anything.",
command_template="find {path} -type d -empty -print",
),
}
def run_job(ssh: RemoteSSHClient, job_key: str, path: str, timeout: int = 600) -> CommandResult:
"""Render and execute a configured job template for a selected remote path."""
template = JOB_TEMPLATES[job_key]
command = template.render({"path": path})
return ssh.run(command, timeout=timeout)
+51
View File
@@ -0,0 +1,51 @@
"""FastAPI application entrypoint."""
from __future__ import annotations
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from routers import dashboard, monitoring, media, files, jobs
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan — startup/shutdown."""
yield
app = FastAPI(
title="Media Library Viewer API",
version="0.1.0",
description="Backend API for Jellyfin media browsing, SSH file inspection, and server monitoring.",
lifespan=lifespan,
)
# CORS for development (Vite runs on :5173)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5173",
"http://localhost:3000",
"http://127.0.0.1:5173",
"http://127.0.0.1:3000",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Register routers
app.include_router(dashboard.router)
app.include_router(monitoring.router)
app.include_router(media.router)
app.include_router(files.router)
app.include_router(jobs.router)
@app.get("/api/health")
def health_check() -> dict[str, str]:
"""Simple health check endpoint."""
return {"status": "ok"}
+68
View File
@@ -0,0 +1,68 @@
"""Path resolution utilities for Jellyfin → SSH path mapping."""
from __future__ import annotations
import posixpath
def apply_remote_path_prefix(path: str, prefix: str) -> str:
"""Apply an optional fallback prefix for Jellyfin->SSH path handoff."""
if not path:
return path
normalized_prefix = (prefix or "").strip()
if not normalized_prefix:
return path
normalized_prefix = normalized_prefix.rstrip("/")
if path == normalized_prefix or path.startswith(normalized_prefix + "/"):
return posixpath.normpath(path)
if path.startswith("/"):
return posixpath.normpath(normalized_prefix + path)
return posixpath.normpath(posixpath.join(normalized_prefix, path))
def map_path_to_media_root(path: str, media_root: str) -> str:
"""Map a Jellyfin path to the configured SSH media root when possible.
If the final segment of media_root (e.g. 'media') appears in the Jellyfin path,
the prefix up to that segment is replaced by media_root.
"""
if not path:
return path
normalized_root = (media_root or "").strip()
if not normalized_root:
return path
normalized_root = posixpath.normpath(normalized_root)
raw_parts = [part for part in str(path).split("/") if part]
if not raw_parts:
return path
path_absolute = "/" + "/".join(raw_parts)
if path_absolute == normalized_root or path_absolute.startswith(normalized_root + "/"):
return path_absolute
root_anchor = posixpath.basename(normalized_root)
if not root_anchor:
return path
if root_anchor in raw_parts:
anchor_index = raw_parts.index(root_anchor)
remainder_parts = raw_parts[anchor_index + 1:]
return posixpath.join(normalized_root, *remainder_parts) if remainder_parts else normalized_root
return path
def resolve_remote_media_path(path: str, media_root: str, fallback_prefix: str) -> str:
"""Resolve Jellyfin paths to SSH-visible paths.
Strategy:
1. Prefer mapping to media_root when it can anchor on the root basename.
2. If no mapping happened, apply optional fallback prefix.
"""
if not path:
return path
mapped = map_path_to_media_root(path, media_root)
if mapped and mapped != path:
return mapped
return apply_remote_path_prefix(mapped or path, fallback_prefix)
+24
View File
@@ -0,0 +1,24 @@
[project]
name = "media-library-viewer-backend"
version = "0.1.0"
description = "FastAPI backend for Media Library Viewer"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.100",
"uvicorn[standard]>=0.20",
"pydantic-settings>=2.0",
"paramiko>=3.0",
"requests>=2.28",
"python-dotenv>=1.0",
"pandas>=2.0",
]
[project.optional-dependencies]
dev = ["httpx", "pytest", "pytest-asyncio", "ruff"]
[tool.ruff]
line-length = 120
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "W"]
+1
View File
@@ -0,0 +1 @@
"""Routers package."""
+69
View File
@@ -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
+67
View File
@@ -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}
+51
View File
@@ -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,
}
+84
View File
@@ -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,
}
+82
View File
@@ -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)}
View File
+278
View File
@@ -0,0 +1,278 @@
"""SQLite-backed media inventory service.
This is the main step toward a frontend-agnostic architecture. The Streamlit UI
asks this service to build/query an index, but the same class could be exposed
through FastAPI to a React frontend without rewriting Jellyfin indexing logic.
"""
from __future__ import annotations
import sqlite3
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable
from clients.jellyfin import JellyfinClient
from domain.media import display_media_row, normalize_media_item
# Local generated database. It is ignored by git and can be rebuilt from
# Jellyfin metadata whenever needed.
DEFAULT_INDEX_PATH = Path(".cache/media_library_viewer/media_index.sqlite")
MEDIA_TYPES = "Movie,Episode,Video"
# Only values from this whitelist are interpolated into ORDER BY. User-selected
# sort keys map to these known SQL snippets to avoid SQL injection.
SORT_COLUMNS = {
"title": "title COLLATE NOCASE",
"series": "series COLLATE NOCASE",
"season": "season_number",
"episode": "episode",
"type": "type COLLATE NOCASE",
"year": "year",
"runtime": "runtime_min",
"size": "size_bytes",
"bitrate": "bitrate_bps",
"hdr": "hdr",
"video": "video COLLATE NOCASE",
"resolution": "height",
"date_added": "date_added_ts",
"library": "library_name COLLATE NOCASE",
"path": "path COLLATE NOCASE",
}
@dataclass(frozen=True)
class MediaIndexStatus:
"""Lightweight status object displayed by the Media tab."""
exists: bool
item_count: int = 0
updated_at: int | None = None
updated_at_label: str = ""
build_duration_seconds: float | None = None
class MediaIndex:
"""SQLite-backed media inventory.
This class is UI-framework independent. Streamlit, a future FastAPI backend,
or a React-facing API can all use this service.
"""
def __init__(self, db_path: Path | str = DEFAULT_INDEX_PATH):
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
def connect(self) -> sqlite3.Connection:
"""Open a sqlite connection configured to return Row objects."""
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
def init_schema(self) -> None:
"""Create tables/indexes if this is the first use of the index."""
with self.connect() as conn:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS media_items (
id TEXT PRIMARY KEY,
title TEXT,
series TEXT,
season TEXT,
season_number INTEGER,
episode INTEGER,
type TEXT,
year INTEGER,
runtime_ticks INTEGER,
runtime_min INTEGER,
size_bytes INTEGER,
bitrate_bps INTEGER,
hdr INTEGER,
video TEXT,
width INTEGER,
height INTEGER,
resolution TEXT,
date_added TEXT,
date_added_ts INTEGER,
path TEXT,
library_id TEXT,
library_name TEXT
);
CREATE TABLE IF NOT EXISTS index_metadata (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE INDEX IF NOT EXISTS idx_media_type ON media_items(type);
CREATE INDEX IF NOT EXISTS idx_media_library ON media_items(library_id);
CREATE INDEX IF NOT EXISTS idx_media_title ON media_items(title COLLATE NOCASE);
CREATE INDEX IF NOT EXISTS idx_media_series ON media_items(series COLLATE NOCASE);
CREATE INDEX IF NOT EXISTS idx_media_date_added ON media_items(date_added_ts);
CREATE INDEX IF NOT EXISTS idx_media_size ON media_items(size_bytes);
CREATE INDEX IF NOT EXISTS idx_media_bitrate ON media_items(bitrate_bps);
"""
)
def set_metadata(self, key: str, value: str | int | float) -> None:
"""Store a small string metadata value, e.g. build duration."""
self.init_schema()
with self.connect() as conn:
conn.execute(
"INSERT OR REPLACE INTO index_metadata (key, value) VALUES (?, ?)",
(key, str(value)),
)
def replace_items(self, rows: Iterable[dict[str, Any]]) -> int:
"""Atomically replace indexed media rows with a freshly built set."""
self.init_schema()
row_list = list(rows)
columns = [
"id",
"title",
"series",
"season",
"season_number",
"episode",
"type",
"year",
"runtime_ticks",
"runtime_min",
"size_bytes",
"bitrate_bps",
"hdr",
"video",
"width",
"height",
"resolution",
"date_added",
"date_added_ts",
"path",
"library_id",
"library_name",
]
placeholders = ",".join(["?"] * len(columns))
with self.connect() as conn:
conn.execute("DELETE FROM media_items")
conn.executemany(
f"INSERT OR REPLACE INTO media_items ({','.join(columns)}) VALUES ({placeholders})",
[[row.get(column) for column in columns] for row in row_list],
)
conn.execute(
"INSERT OR REPLACE INTO index_metadata (key, value) VALUES ('updated_at', ?)",
(str(int(time.time())),),
)
return len(row_list)
def status(self) -> MediaIndexStatus:
"""Return existence, count, update time, and last build duration."""
if not self.db_path.exists():
return MediaIndexStatus(exists=False)
try:
with self.connect() as conn:
item_count = int(conn.execute("SELECT COUNT(*) FROM media_items").fetchone()[0])
updated_row = conn.execute("SELECT value FROM index_metadata WHERE key='updated_at'").fetchone()
duration_row = conn.execute("SELECT value FROM index_metadata WHERE key='build_duration_seconds'").fetchone()
except sqlite3.Error:
return MediaIndexStatus(exists=False)
updated_at = int(updated_row[0]) if updated_row and str(updated_row[0]).isdigit() else None
label = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(updated_at)) if updated_at else ""
build_duration = None
if duration_row:
try:
build_duration = float(duration_row[0])
except (TypeError, ValueError):
build_duration = None
return MediaIndexStatus(
exists=True,
item_count=item_count,
updated_at=updated_at,
updated_at_label=label,
build_duration_seconds=build_duration,
)
def query(
self,
library_id: str | None = None,
library_ids: list[str] | None = None,
media_types: list[str] | None = None,
search: str = "",
hdr_filter: str = "All",
sort_key: str = "title",
sort_order: str = "Ascending",
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict[str, Any]], int]:
"""Query indexed media with full-index filters, sorting, and pagination."""
self.init_schema()
where = []
params: list[Any] = []
if library_ids:
where.append("library_id IN (" + ",".join(["?"] * len(library_ids)) + ")")
params.extend(library_ids)
elif library_id:
where.append("library_id = ?")
params.append(library_id)
if media_types:
where.append("type IN (" + ",".join(["?"] * len(media_types)) + ")")
params.extend(media_types)
if search:
needle = f"%{search.lower()}%"
where.append("(LOWER(title) LIKE ? OR LOWER(series) LIKE ? OR LOWER(path) LIKE ?)")
params.extend([needle, needle, needle])
if hdr_filter == "HDR only":
where.append("hdr = 1")
elif hdr_filter == "SDR/unknown only":
where.append("(hdr IS NULL OR hdr = 0)")
where_sql = " WHERE " + " AND ".join(where) if where else ""
sort_sql = SORT_COLUMNS.get(sort_key, SORT_COLUMNS["title"])
direction = "DESC" if sort_order == "Descending" else "ASC"
# Always add stable tie-breakers.
order_sql = f" ORDER BY {sort_sql} {direction}, series COLLATE NOCASE ASC, season_number ASC, episode ASC, title COLLATE NOCASE ASC"
with self.connect() as conn:
total = int(conn.execute("SELECT COUNT(*) FROM media_items" + where_sql, params).fetchone()[0])
rows = conn.execute(
"SELECT * FROM media_items" + where_sql + order_sql + " LIMIT ? OFFSET ?",
[*params, int(limit), int(offset)],
).fetchall()
return [display_media_row(dict(row)) for row in rows], total
def build_media_index(
client: JellyfinClient,
user_id: str,
libraries: list[dict[str, Any]],
index: MediaIndex | None = None,
page_size: int = 500,
) -> int:
"""Fetch Jellyfin pages for all selected libraries and rebuild the index."""
index = index or MediaIndex()
started_at = time.perf_counter()
normalized_rows: list[dict[str, Any]] = []
for library in libraries:
library_id = library.get("Id")
library_name = library.get("Name", "")
if not library_id:
continue
start = 0
while True:
response = client.items(
user_id=user_id,
parent_id=library_id,
start_index=start,
limit=page_size,
include_item_types=MEDIA_TYPES,
recursive=True,
sort_by="SortName",
sort_order="Ascending",
)
items = response.get("Items", [])
normalized_rows.extend(normalize_media_item(item, library_id, library_name) for item in items)
start += len(items)
total = int(response.get("TotalRecordCount", start))
if not items or start >= total:
break
count = index.replace_items(normalized_rows)
index.set_metadata("build_duration_seconds", f"{time.perf_counter() - started_at:.3f}")
return count
+227
View File
@@ -0,0 +1,227 @@
"""Formatting and ffprobe summarization helpers.
These helpers are intentionally UI-framework independent. Streamlit renders the
returned dictionaries/dataframes, but another frontend can reuse the same
summaries.
"""
from __future__ import annotations
from datetime import datetime
from pathlib import PurePosixPath
from typing import Any
VIDEO_FILE_EXTENSIONS = {
".3g2",
".3gp",
".avi",
".divx",
".flv",
".m2ts",
".m4v",
".mkv",
".mov",
".mp4",
".mpeg",
".mpg",
".mts",
".ogm",
".ogv",
".rmvb",
".ts",
".vob",
".webm",
".wmv",
}
def ticks_to_minutes(ticks: int | None) -> int | None:
"""Convert Jellyfin/Emby 100-nanosecond ticks to rounded minutes."""
if not ticks:
return None
return round(ticks / 10_000_000 / 60)
def human_size(num: int | float | None) -> str:
"""Format a byte count as B/KB/MB/GB/etc."""
if num is None:
return ""
value = float(num)
for unit in ["B", "KB", "MB", "GB", "TB", "PB"]:
if value < 1024 or unit == "PB":
return f"{value:.1f} {unit}" if unit != "B" else f"{int(value)} B"
value /= 1024
return f"{value:.1f} PB"
def timestamp_to_local(ts: float | None) -> str:
if ts is None:
return ""
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
def is_known_video_file(path: str | None) -> bool:
"""Return True when a path extension is one we should ffprobe automatically."""
if not path:
return False
return PurePosixPath(path).suffix.lower() in VIDEO_FILE_EXTENSIONS
def format_duration(seconds: str | int | float | None) -> str:
if seconds in (None, ""):
return ""
try:
total = float(seconds)
except (TypeError, ValueError):
return str(seconds)
hours = int(total // 3600)
minutes = int((total % 3600) // 60)
secs = int(total % 60)
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
def format_bitrate(bit_rate: str | int | float | None) -> str:
if bit_rate in (None, ""):
return ""
try:
value = float(bit_rate)
except (TypeError, ValueError):
return str(bit_rate)
if value >= 1_000_000:
return f"{value / 1_000_000:.2f} Mbps"
if value >= 1_000:
return f"{value / 1_000:.0f} kbps"
return f"{value:.0f} bps"
def _tags(stream: dict[str, Any]) -> dict[str, Any]:
return stream.get("tags") or {}
def _disposition(stream: dict[str, Any], key: str) -> str:
value = (stream.get("disposition") or {}).get(key)
return "yes" if value == 1 else ""
def _side_data_types(stream: dict[str, Any]) -> str:
values = []
for item in stream.get("side_data_list") or []:
if item.get("side_data_type"):
values.append(item["side_data_type"])
return ", ".join(values)
def ffprobe_format_summary(ffprobe: dict[str, Any]) -> dict[str, str]:
"""Summarize ffprobe container/format-level metadata."""
fmt = ffprobe.get("format") or {}
return {
"filename": fmt.get("filename", ""),
"format": fmt.get("format_name", ""),
"format_long": fmt.get("format_long_name", ""),
"duration": format_duration(fmt.get("duration")),
"size": human_size(float(fmt["size"])) if fmt.get("size") else "",
"bit_rate": format_bitrate(fmt.get("bit_rate")),
"stream_count": str(fmt.get("nb_streams", "")),
}
def summarize_video_streams(ffprobe: dict[str, Any]) -> list[dict[str, Any]]:
"""Return video-only stream rows with video/HDR-related fields."""
rows = []
for stream in ffprobe.get("streams", []):
if stream.get("codec_type") != "video":
continue
tags = _tags(stream)
rows.append(
{
"index": stream.get("index"),
"codec": stream.get("codec_name"),
"profile": stream.get("profile"),
"resolution": f"{stream.get('width', '')}x{stream.get('height', '')}",
"pix_fmt": stream.get("pix_fmt"),
"bit_rate": format_bitrate(stream.get("bit_rate")),
"avg_fps": stream.get("avg_frame_rate"),
"color_range": stream.get("color_range"),
"color_space": stream.get("color_space"),
"color_transfer": stream.get("color_transfer"),
"color_primaries": stream.get("color_primaries"),
"side_data": _side_data_types(stream),
"language": tags.get("language"),
"title": tags.get("title"),
"default": _disposition(stream, "default"),
}
)
return rows
def summarize_audio_streams(ffprobe: dict[str, Any]) -> list[dict[str, Any]]:
"""Return audio-only stream rows with channel/language/default fields."""
rows = []
for stream in ffprobe.get("streams", []):
if stream.get("codec_type") != "audio":
continue
tags = _tags(stream)
rows.append(
{
"index": stream.get("index"),
"codec": stream.get("codec_name"),
"profile": stream.get("profile"),
"channels": stream.get("channels"),
"layout": stream.get("channel_layout"),
"sample_rate": stream.get("sample_rate"),
"bit_rate": format_bitrate(stream.get("bit_rate")),
"language": tags.get("language"),
"title": tags.get("title"),
"default": _disposition(stream, "default"),
"forced": _disposition(stream, "forced"),
}
)
return rows
def summarize_subtitle_streams(ffprobe: dict[str, Any]) -> list[dict[str, Any]]:
"""Return subtitle-only stream rows with language/forced/default fields."""
rows = []
for stream in ffprobe.get("streams", []):
if stream.get("codec_type") != "subtitle":
continue
tags = _tags(stream)
rows.append(
{
"index": stream.get("index"),
"codec": stream.get("codec_name"),
"codec_long": stream.get("codec_long_name"),
"language": tags.get("language"),
"title": tags.get("title"),
"default": _disposition(stream, "default"),
"forced": _disposition(stream, "forced"),
"hearing_impaired": _disposition(stream, "hearing_impaired"),
}
)
return rows
def summarize_streams(ffprobe: dict[str, Any]) -> list[dict[str, Any]]:
rows = []
for stream in ffprobe.get("streams", []):
rows.append(
{
"index": stream.get("index"),
"type": stream.get("codec_type"),
"codec": stream.get("codec_name"),
"profile": stream.get("profile"),
"width": stream.get("width"),
"height": stream.get("height"),
"pix_fmt": stream.get("pix_fmt"),
"color_transfer": stream.get("color_transfer"),
"color_primaries": stream.get("color_primaries"),
"color_space": stream.get("color_space"),
"bit_rate": format_bitrate(stream.get("bit_rate")),
"channels": stream.get("channels"),
"sample_rate": stream.get("sample_rate"),
"language": stream.get("tags", {}).get("language"),
"title": stream.get("tags", {}).get("title"),
}
)
return rows
+241
View File
@@ -0,0 +1,241 @@
# Migration Plan: Streamlit → FastAPI + React
## Overview
Split the current Streamlit monolith into:
- **Backend:** FastAPI (Python) serving a REST API
- **Frontend:** React SPA (TypeScript) consuming that API
The existing `clients/`, `domain/`, `services/`, `jobs.py`, `utils.py`, and `config.py` are already UI-independent and transfer directly to the FastAPI backend with minimal changes.
---
## Current Architecture
```
src/media_library_viewer/
├── app.py # Streamlit orchestration + caching (DELETE)
├── config.py # Env/dotenv config
├── jobs.py # SSH job templates
├── utils.py # Formatting helpers
├── clients/
│ ├── jellyfin.py # Jellyfin HTTP client
│ ├── resources.py # Remote resource collector
│ └── ssh.py # SSH command execution
├── domain/
│ └── media.py # Media normalization
├── services/
│ └── media_index.py # SQLite media index
└── ui/ # Streamlit rendering (DELETE)
├── dashboard.py
├── file_browser.py
├── media.py
└── preview.py
```
---
## Target Architecture
```
repo/
├── backend/
│ ├── main.py # FastAPI app + CORS + lifespan
│ ├── config.py # pydantic-settings based config
│ ├── dependencies.py # DI for SSH/Jellyfin clients
│ ├── routers/
│ │ ├── dashboard.py # /api/dashboard/*
│ │ ├── monitoring.py # /api/monitoring/*
│ │ ├── media.py # /api/media/*
│ │ ├── files.py # /api/files/*
│ │ └── jobs.py # /api/jobs/*
│ ├── clients/ # Copied unchanged
│ │ ├── jellyfin.py
│ │ ├── resources.py
│ │ └── ssh.py
│ ├── domain/ # Copied unchanged
│ │ └── media.py
│ ├── services/ # Copied unchanged
│ │ └── media_index.py
│ ├── jobs.py # Copied unchanged
│ ├── utils.py # Copied unchanged
│ └── pyproject.toml
├── frontend/
│ ├── package.json
│ ├── vite.config.ts
│ ├── tsconfig.json
│ └── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── api/
│ │ └── client.ts # Typed fetch wrappers
│ ├── hooks/
│ │ ├── useDashboard.ts
│ │ ├── useMonitoring.ts
│ │ ├── useMedia.ts
│ │ └── useFiles.ts
│ ├── pages/
│ │ ├── Dashboard.tsx
│ │ ├── Monitoring.tsx
│ │ ├── Media.tsx
│ │ └── FileBrowser.tsx
│ ├── components/
│ │ ├── NowPlaying.tsx
│ │ ├── MetricCard.tsx
│ │ ├── LibraryOverview.tsx
│ │ ├── MonitoringCharts.tsx
│ │ ├── MediaTable.tsx
│ │ ├── FileListing.tsx
│ │ └── FfprobePreview.tsx
│ └── types/
│ └── index.ts
├── docker-compose.yml # Optional unified deployment
└── README.md
```
---
## FastAPI Endpoints
| Endpoint | Method | Source | Description |
| ------------------------------- | ------ | ---------------------------------------- | -------------------------------- |
| `/api/dashboard/counts` | GET | `jellyfin.media_counts()` | Movie/series/episode totals |
| `/api/dashboard/libraries` | GET | `jellyfin.library_item_counts()` | Per-library breakdown |
| `/api/dashboard/now-playing` | GET | `jellyfin.active_sessions()` | Active sessions + transcode info |
| `/api/monitoring/status` | GET | `resources.resource_collector_status()` | Collector running? |
| `/api/monitoring/metrics` | GET | `resources.read_resource_metrics()` | Last-hour JSONL samples |
| `/api/monitoring/disk` | GET | `resources.disk_space()` | df for media root |
| `/api/monitoring/start` | POST | `resources.start_resource_collector()` | Start collector |
| `/api/monitoring/stop` | POST | `resources.stop_resource_collector()` | Stop collector |
| `/api/monitoring/restart` | POST | `resources.restart_resource_collector()` | Restart collector |
| `/api/media/status` | GET | `MediaIndex.status()` | Index exists/count/age |
| `/api/media/build` | POST | `build_media_index()` | Rebuild index |
| `/api/media/query` | GET | `MediaIndex.query()` | Filtered/sorted/paged query |
| `/api/files/list?path=` | GET | `ssh.list_dir()` | Directory listing |
| `/api/files/ffprobe?path=` | GET | `ssh.ffprobe_json()` | ffprobe JSON |
| `/api/files/stat?path=` | GET | `ssh.stat_path()` | stat output |
| `/api/files/resolve-path?path=` | GET | `resolve_remote_media_path()` | Jellyfin→SSH path mapping |
| `/api/jobs/templates` | GET | `JOB_TEMPLATES` | Available job list |
| `/api/jobs/run` | POST | `run_job()` | Execute a job |
---
## React Frontend
### Tech Stack
- **Vite** + React 18+ + TypeScript
- **@tanstack/react-query** — data fetching with polling
- **ag-grid-react** — media table and file browser (same grid lib)
- **recharts** — monitoring line charts
- **react-router** — page navigation
- **tailwindcss** + **shadcn/ui** — styling
### Page → Component Mapping
| Page | Components | Polling |
| ------------ | --------------------------------------------------------- | -------------------------------- |
| Dashboard | NowPlaying, MetricCard (server overview), LibraryOverview | 15s (now-playing), 30s (metrics) |
| Monitoring | MonitoringCharts, MetricCard, CollectorControls | 15s |
| Media | MediaTable (AG Grid), filter/sort/page controls | on-demand |
| File Browser | FileListing (AG Grid), FfprobePreview, JobRunner | on-demand |
### Key Interactions
- Media row click → updates client-side file browser path state (no API call)
- File browser directory click → fetches `/api/files/list?path=...`
- File browser file click → fetches `/api/files/ffprobe?path=...`
- Path input Enter → navigates directory
- Monitoring charts → recharts line chart from `/api/monitoring/metrics`
---
## What Transfers Unchanged (~1,350 lines)
| File | Lines | Notes |
| ------------------------- | ----- | ---------------------------- |
| `clients/jellyfin.py` | 167 | Remove unused methods if any |
| `clients/ssh.py` | 146 | No changes |
| `clients/resources.py` | 316 | No changes |
| `domain/media.py` | 161 | No changes |
| `services/media_index.py` | 278 | No changes |
| `jobs.py` | 59 | No changes |
| `utils.py` | 227 | No changes |
---
## What Gets Deleted
- `src/media_library_viewer/ui/` (all Streamlit rendering)
- `src/media_library_viewer/app.py` (Streamlit orchestration)
- Root `app.py` (Streamlit launcher)
- `streamlit` and `streamlit-aggrid` dependencies
---
## Migration Steps
### Step 1: Backend scaffold
- Create `backend/` with FastAPI, pydantic-settings config, DI
- Copy `clients/`, `domain/`, `services/`, `jobs.py`, `utils.py`
- Implement routers wrapping existing functions
- Test with curl
### Step 2: Frontend scaffold
- Create `frontend/` with Vite + React + TypeScript
- API client with typed wrappers
- React Query provider + hooks
- Router with 4 pages
### Step 3: Build pages (one at a time)
1. Dashboard (simplest — just fetches and displays)
2. Monitoring (charts + controls)
3. Media (AG Grid + filters)
4. File Browser (AG Grid + ffprobe + jobs)
### Step 4: Validate feature parity
- Compare behavior side-by-side with Streamlit
- Remove Streamlit code
---
## Effort Estimate
| Component | Effort |
| ------------------------------------- | ------------- |
| FastAPI backend | 1-2 days |
| React scaffold + routing + API client | 0.5 day |
| Dashboard page | 0.5 day |
| Monitoring page + charts | 1 day |
| Media page + AG Grid | 1 day |
| File browser page | 1 day |
| Polish + testing + deployment | 1-2 days |
| **Total** | **~6-9 days** |
---
## Key Decisions
- **Polling over WebSocket initially** — simpler, matches current behavior; upgrade path exists for later.
- **Auth deferred** — rely on network access control for now; add API key or JWT later.
- **CORS enabled** — for Vite dev server and production origin.
- **Path resolution stays server-side** — frontend sends Jellyfin paths, backend resolves to SSH paths.
- **Both can coexist** — Streamlit and FastAPI can run simultaneously during transition since they share the same clients/services.
---
## Optional Post-Migration Enhancements
- WebSocket push for real-time monitoring/now-playing
- JWT authentication
- Background index build with SSE progress
- Dark/light theme
- Persistent user preferences (localStorage + optional backend sync)
- Docker Compose (backend + frontend + nginx reverse proxy)
+9 -5
View File
@@ -8,7 +8,7 @@ Build a compact Streamlit application for browsing a remote Jellyfin media libra
## Current Phase
Phase 1: Jellyfin library browser plus SSH-based remote filesystem inspection and safe job templates.
Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server monitoring, and safe job templates.
## Core Requirements
@@ -91,14 +91,15 @@ Phase 1: Jellyfin library browser plus SSH-based remote filesystem inspection an
- Keep a manual `ffprobe` action available for selected paths.
- Support `stat` on selected paths.
### Dashboard / Server Resources
### Dashboard / Server Monitoring
- Provide a dashboard tab with a compact Jellyfin media library overview and server resource overview.
- Show Jellyfin media counts for movies, series, and series episodes on the dashboard.
- Show currently playing Jellyfin sessions on the dashboard, including user, media title, playback state, and whether transcoding is active.
- Provide a dashboard tab with a compact server resource overview over SSH.
- Provide a separate Resources tab for detailed resource charts, collector controls, diagnostics, and raw samples.
- Provide a separate Monitoring tab for detailed resource charts, collector controls, diagnostics, and raw samples.
- Show CPU and RAM usage for the last hour.
- Show IO wait percentage for the last hour.
- Show average and spike/peak values for network throughput and disk I/O.
- Show used, available, and total disk space for the configured media root, falling back to `/`.
- Use a lightweight remote collector that reads Linux `/proc`, `/sys/block`, and `df` data into a JSONL file under `/tmp`.
@@ -274,8 +275,8 @@ Local app dependencies are declared in `pyproject.toml`; `requirements.txt` inst
- Changed network display units from bits per second to bytes per second to avoid Kbps/KB/s ambiguity; the collector still stores bit-rate compatibility fields for old/debug consumers.
- Scaled network and disk throughput charts into readable units such as KB/s, MB/s, and GB/s instead of plotting raw base units.
- Updated collector startup to remove old temporary metrics/log files when a new collector process is started after a schema/display change.
- Moved detailed resource charts, raw samples, diagnostics, and collector controls into a dedicated Resources tab; the Dashboard now keeps a compact overview.
- Removed the CPU/RAM chart from the Dashboard and kept detailed charts in the Resources tab.
- Moved detailed resource charts, raw samples, diagnostics, and collector controls into a dedicated Monitoring tab; the Dashboard now keeps a compact overview.
- Removed the CPU/RAM chart from the Dashboard and kept detailed charts in the Monitoring tab.
- Renamed the remote files tab to File browser.
- Added Jellyfin media counts for movies, series, and episodes to the Dashboard using lightweight count queries.
- Added a Dashboard now-playing section sourced from Jellyfin sessions, showing who is currently playing what and whether each session is transcoding.
@@ -297,6 +298,9 @@ Local app dependencies are declared in `pyproject.toml`; `requirements.txt` inst
- Restored File browser table row selection with AG Grid (single-select), using a table interaction style consistent with the Media tab.
- Reintroduced open-on-select behavior in File browser: selecting a directory row (including `[UP] ..`) opens it immediately, while file rows update selected target path.
- Refined Media table column presentation with explicit user-friendly headers and null-safe display formatting to keep the grid readable and consistent.
- Renamed Resources tab to Monitoring; added IO wait (iowait) percentage to the collector script, metrics, dashboard summary, and detailed charts.
- Removed the Jellyfin library poster-grid tab and its associated cached API calls and UI module; the Media index tab now covers library browsing needs.
- Simplified File browser navigation: removed Up/Go/Select folder buttons; pressing Enter in the path text input navigates directly.
- Added broad inline/module documentation across clients, domain, services, and Streamlit adapter modules to make debugging and future frontend extraction easier.
- Simplified the File browser by removing its interactive AG Grid and using a read-only listing with explicit Open/Select controls, reducing cross-tab state interactions with the Media grid.
- Removed optional/compatibility code paths around the Media table grid and old file-browser state aliases to keep the interaction model easier to reason about during debugging.
+25
View File
@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
frontend/.pi-lens/
+61
View File
@@ -0,0 +1,61 @@
# Frontend - Media Library Viewer
React + TypeScript SPA for the Media Library Viewer, consuming the FastAPI backend.
## Tech Stack
- **Vite** — Build tool
- **React 18+** — UI framework
- **TypeScript** — Type safety
- **@tanstack/react-query** — Data fetching/caching
- **ag-grid-react** — Data tables (media, file browser)
- **recharts** — Monitoring charts
- **react-router-dom** — Client-side routing
- **Tailwind CSS** — Styling
## Setup
```bash
cd frontend
npm install
```
## Development
```bash
npm run dev
```
Runs on http://localhost:5173 with API requests proxied to http://localhost:8000.
Make sure the backend is running:
```bash
cd ../backend
uvicorn main:app --reload --port 8000
```
## Build
```bash
npm run build
```
Output goes to `frontend/dist/`.
## Pages
- **Dashboard** (`/`) — Now playing, server overview, library stats
- **Monitoring** (`/monitoring`) — CPU/IO wait/RAM/network/disk charts, collector controls
- **Media** (`/media`) — Full-library table with sort/filter/search
- **File Browser** (`/files`) — Remote directory browsing, ffprobe preview, jobs
## Environment Variables
Create a `.env` file in `frontend/` if the API is not at `http://localhost:8000`:
```bash
VITE_API_URL=http://your-backend-host:8000
```
In development, the Vite proxy handles `/api` requests automatically.
+22
View File
@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3608
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-query": "^5.100.6",
"ag-grid-community": "^35.2.1",
"ag-grid-react": "^35.2.1",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2",
"recharts": "^3.8.1",
"tailwindcss": "^4.2.4"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.10"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+62
View File
@@ -0,0 +1,62 @@
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Dashboard } from "./pages/Dashboard";
import { Monitoring } from "./pages/Monitoring";
import { Media } from "./pages/Media";
import { FileBrowser } from "./pages/FileBrowser";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
});
const navLinks = [
{ to: "/", label: "Dashboard" },
{ to: "/monitoring", label: "Monitoring" },
{ to: "/media", label: "Media" },
{ to: "/files", label: "File Browser" },
];
function NavBar() {
return (
<nav className="border-b px-6 py-3 flex gap-6 items-center bg-white sticky top-0 z-10">
<span className="font-bold text-lg mr-4">Media Library Viewer</span>
{navLinks.map((link) => (
<NavLink
key={link.to}
to={link.to}
end={link.to === "/"}
className={({ isActive }) =>
`text-sm px-2 py-1 rounded ${isActive ? "bg-gray-100 font-medium" : "text-gray-600 hover:text-gray-900"}`
}
>
{link.label}
</NavLink>
))}
</nav>
);
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<div className="min-h-screen bg-gray-50">
<NavBar />
<main className="max-w-screen-2xl mx-auto px-6 py-6">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/monitoring" element={<Monitoring />} />
<Route path="/media" element={<Media />} />
<Route path="/files" element={<FileBrowser />} />
</Routes>
</main>
</div>
</BrowserRouter>
</QueryClientProvider>
);
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Typed API client for the FastAPI backend.
*/
import type {
MediaCounts,
LibraryCount,
NowPlayingSession,
MonitoringStatus,
MonitoringMetrics,
DiskSpace,
MediaIndexStatus,
MediaQueryResponse,
DirectoryListing,
JobTemplate,
JobResult,
ResolvedPath,
} from "../types";
const BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
async function get<T>(
path: string,
params?: Record<string, string>,
): Promise<T> {
const url = new URL(path, BASE_URL);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== "") url.searchParams.set(key, value);
});
}
const response = await fetch(url.toString());
if (!response.ok) {
const detail = await response.text();
throw new Error(`${response.status}: ${detail}`);
}
return response.json();
}
async function post<T>(path: string, body?: unknown): Promise<T> {
const url = new URL(path, BASE_URL);
const response = await fetch(url.toString(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const detail = await response.text();
throw new Error(`${response.status}: ${detail}`);
}
return response.json();
}
// Dashboard
export const fetchCounts = () => get<MediaCounts>("/api/dashboard/counts");
export const fetchLibraries = () =>
get<LibraryCount[]>("/api/dashboard/libraries");
export const fetchNowPlaying = () =>
get<NowPlayingSession[]>("/api/dashboard/now-playing");
// Monitoring
export const fetchMonitoringStatus = () =>
get<MonitoringStatus>("/api/monitoring/status");
export const fetchMonitoringMetrics = (lastSeconds = 3600) =>
get<MonitoringMetrics>("/api/monitoring/metrics", {
last_seconds: String(lastSeconds),
});
export const fetchDiskSpace = () => get<DiskSpace>("/api/monitoring/disk");
export const startCollector = () =>
post<{ message: string }>("/api/monitoring/start");
export const stopCollector = () =>
post<{ message: string }>("/api/monitoring/stop");
export const restartCollector = () =>
post<{ message: string }>("/api/monitoring/restart");
// Media
export const fetchMediaStatus = () =>
get<MediaIndexStatus>("/api/media/status");
export const buildMediaIndex = () =>
post<{ indexed_items: number }>("/api/media/build");
export const queryMedia = (params: {
libraries?: string;
types?: string;
search?: string;
hdr_filter?: string;
sort_key?: string;
sort_order?: string;
limit?: number;
offset?: number;
}) =>
get<MediaQueryResponse>("/api/media/query", {
libraries: params.libraries || "",
types: params.types || "Movie,Episode",
search: params.search || "",
hdr_filter: params.hdr_filter || "All",
sort_key: params.sort_key || "title",
sort_order: params.sort_order || "Ascending",
limit: String(params.limit || 100),
offset: String(params.offset || 0),
});
// Files
export const fetchDirectoryListing = (path: string) =>
get<DirectoryListing>("/api/files/list", { path });
export const fetchFfprobe = (path: string) =>
get<Record<string, unknown>>("/api/files/ffprobe", { path });
export const fetchStat = (path: string) =>
get<{ path: string; output: string }>("/api/files/stat", { path });
export const resolvePath = (path: string) =>
get<ResolvedPath>("/api/files/resolve-path", { path });
// Jobs
export const fetchJobTemplates = () =>
get<JobTemplate[]>("/api/jobs/templates");
export const runJob = (jobKey: string, path: string) =>
post<JobResult>("/api/jobs/run", { job_key: jobKey, path });
@@ -0,0 +1,55 @@
import type { LibraryCount } from "../types";
interface Props {
libraries: LibraryCount[];
}
export function LibraryOverview({ libraries }: Props) {
const movieLibs = libraries.filter((l) => l.type === "movies");
const tvLibs = libraries.filter((l) => l.type === "tvshows");
return (
<div className="grid grid-cols-2 gap-6">
{movieLibs.length > 0 && (
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-2">
Movie libraries
</p>
{movieLibs.map((lib) => (
<div key={lib.library} className="rounded-lg border p-4 mb-2">
<p className="font-semibold">{lib.library}</p>
<div className="flex gap-6 mt-2 text-sm">
<span>
Total: <strong>{lib.total.toLocaleString()}</strong>
</span>
<span>
Movies: <strong>{lib.movies.toLocaleString()}</strong>
</span>
</div>
</div>
))}
</div>
)}
{tvLibs.length > 0 && (
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-2">
TV libraries
</p>
{tvLibs.map((lib) => (
<div key={lib.library} className="rounded-lg border p-4 mb-2">
<p className="font-semibold">{lib.library}</p>
<div className="flex gap-6 mt-2 text-sm">
<span>
Total: <strong>{lib.total.toLocaleString()}</strong>
</span>
<span>
Series: <strong>{lib.series.toLocaleString()}</strong>
</span>
</div>
</div>
))}
</div>
)}
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
interface Props {
label: string;
value: string;
subtext?: string;
}
export function MetricCard({ label, value, subtext }: Props) {
return (
<div className="rounded-lg border p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide">{label}</p>
<p className="text-2xl font-bold mt-1">{value}</p>
{subtext && (
<p className="text-xs text-gray-400 mt-1 whitespace-pre-line">
{subtext}
</p>
)}
</div>
);
}
@@ -0,0 +1,179 @@
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import type { MonitoringSample } from "../types";
interface Props {
samples: MonitoringSample[];
}
function formatTime(ts: number) {
return new Date(ts * 1000).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B/s";
const units = ["B/s", "KB/s", "MB/s", "GB/s"];
let value = bytes;
let unitIdx = 0;
while (value >= 1000 && unitIdx < units.length - 1) {
value /= 1000;
unitIdx++;
}
return `${value.toFixed(1)} ${units[unitIdx]}`;
}
export function MonitoringCharts({ samples }: Props) {
if (samples.length === 0) {
return (
<p className="text-sm text-gray-500">No monitoring samples available.</p>
);
}
const data = samples.map((s) => ({
time: formatTime(s.ts),
ts: s.ts,
cpu: s.cpu_pct,
iowait: s.iowait_pct ?? 0,
mem: s.mem_pct,
net_down: s.net_rx_bytes_per_sec,
net_up: s.net_tx_bytes_per_sec,
disk_read: s.disk_read_bps,
disk_write: s.disk_write_bps,
}));
return (
<div className="space-y-8">
<div>
<h3 className="text-sm font-semibold mb-2">
CPU, IO Wait, and RAM - last hour
</h3>
<ResponsiveContainer width="100%" height={250}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
<YAxis unit="%" domain={[0, 100]} />
<Tooltip />
<Legend />
<Line
type="monotone"
dataKey="cpu"
name="CPU %"
stroke="#2563eb"
dot={false}
strokeWidth={1.5}
/>
<Line
type="monotone"
dataKey="iowait"
name="IO Wait %"
stroke="#dc2626"
dot={false}
strokeWidth={1.5}
/>
<Line
type="monotone"
dataKey="mem"
name="RAM %"
stroke="#16a34a"
dot={false}
strokeWidth={1.5}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<h3 className="text-sm font-semibold mb-2">Network download</h3>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => formatBytes(v)} />
<Tooltip formatter={(v) => formatBytes(Number(v))} />
<Line
type="monotone"
dataKey="net_down"
name="Download"
stroke="#2563eb"
dot={false}
strokeWidth={1.5}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Network upload</h3>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => formatBytes(v)} />
<Tooltip formatter={(v) => formatBytes(Number(v))} />
<Line
type="monotone"
dataKey="net_up"
name="Upload"
stroke="#9333ea"
dot={false}
strokeWidth={1.5}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<h3 className="text-sm font-semibold mb-2">Disk read</h3>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => formatBytes(v)} />
<Tooltip formatter={(v) => formatBytes(Number(v))} />
<Line
type="monotone"
dataKey="disk_read"
name="Read"
stroke="#ea580c"
dot={false}
strokeWidth={1.5}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Disk write</h3>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => formatBytes(v)} />
<Tooltip formatter={(v) => formatBytes(Number(v))} />
<Line
type="monotone"
dataKey="disk_write"
name="Write"
stroke="#0891b2"
dot={false}
strokeWidth={1.5}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import type { NowPlayingSession } from "../types";
interface Props {
sessions: NowPlayingSession[];
}
export function NowPlaying({ sessions }: Props) {
if (sessions.length === 0) {
return (
<p className="text-sm text-gray-500">
No active playback sessions right now.
</p>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b text-left text-gray-600">
<th className="py-2 pr-4">User</th>
<th className="py-2 pr-4">Title</th>
<th className="py-2 pr-4">Type</th>
<th className="py-2 pr-4">State</th>
<th className="py-2 pr-4">Transcoding</th>
<th className="py-2 pr-4">Transcode type</th>
<th className="py-2 pr-4">Device</th>
</tr>
</thead>
<tbody>
{sessions.map((s) => (
<tr key={s.session_id} className="border-b hover:bg-gray-50">
<td className="py-2 pr-4 font-medium">{s.user}</td>
<td className="py-2 pr-4">{s.title}</td>
<td className="py-2 pr-4">{s.type}</td>
<td className="py-2 pr-4">{s.state}</td>
<td className="py-2 pr-4">{s.transcoding}</td>
<td className="py-2 pr-4">{s.transcoding_type}</td>
<td className="py-2 pr-4">{s.device}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import { fetchCounts, fetchLibraries, fetchNowPlaying } from "../api/client";
export function useCounts() {
return useQuery({
queryKey: ["dashboard", "counts"],
queryFn: fetchCounts,
staleTime: 5 * 60 * 1000,
});
}
export function useLibraries() {
return useQuery({
queryKey: ["dashboard", "libraries"],
queryFn: fetchLibraries,
staleTime: 5 * 60 * 1000,
});
}
export function useNowPlaying() {
return useQuery({
queryKey: ["dashboard", "now-playing"],
queryFn: fetchNowPlaying,
refetchInterval: 15_000,
});
}
+49
View File
@@ -0,0 +1,49 @@
import { useQuery, useMutation } from "@tanstack/react-query";
import {
fetchDirectoryListing,
fetchFfprobe,
fetchStat,
fetchJobTemplates,
runJob,
} from "../api/client";
export function useDirectoryListing(path: string) {
return useQuery({
queryKey: ["files", "list", path],
queryFn: () => fetchDirectoryListing(path),
enabled: !!path,
staleTime: 30_000,
});
}
export function useFfprobe(path: string, enabled = false) {
return useQuery({
queryKey: ["files", "ffprobe", path],
queryFn: () => fetchFfprobe(path),
enabled: enabled && !!path,
staleTime: 5 * 60_000,
});
}
export function useStat(path: string, enabled = false) {
return useQuery({
queryKey: ["files", "stat", path],
queryFn: () => fetchStat(path),
enabled: enabled && !!path,
});
}
export function useJobTemplates() {
return useQuery({
queryKey: ["jobs", "templates"],
queryFn: fetchJobTemplates,
staleTime: 60_000,
});
}
export function useRunJob() {
return useMutation({
mutationFn: ({ jobKey, path }: { jobKey: string; path: string }) =>
runJob(jobKey, path),
});
}
+40
View File
@@ -0,0 +1,40 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { fetchMediaStatus, buildMediaIndex, queryMedia } from "../api/client";
export function useMediaStatus() {
return useQuery({
queryKey: ["media", "status"],
queryFn: fetchMediaStatus,
staleTime: 60_000,
});
}
export function useMediaQuery(params: {
libraries?: string;
types?: string;
search?: string;
hdr_filter?: string;
sort_key?: string;
sort_order?: string;
limit?: number;
offset?: number;
enabled?: boolean;
}) {
const { enabled = true, ...queryParams } = params;
return useQuery({
queryKey: ["media", "query", queryParams],
queryFn: () => queryMedia(queryParams),
enabled,
staleTime: 30_000,
});
}
export function useBuildIndex() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: buildMediaIndex,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["media"] });
},
});
}
+54
View File
@@ -0,0 +1,54 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
fetchMonitoringStatus,
fetchMonitoringMetrics,
fetchDiskSpace,
startCollector,
stopCollector,
restartCollector,
} from "../api/client";
export function useMonitoringStatus() {
return useQuery({
queryKey: ["monitoring", "status"],
queryFn: fetchMonitoringStatus,
refetchInterval: 30_000,
});
}
export function useMonitoringMetrics(lastSeconds = 3600) {
return useQuery({
queryKey: ["monitoring", "metrics", lastSeconds],
queryFn: () => fetchMonitoringMetrics(lastSeconds),
refetchInterval: 15_000,
});
}
export function useDiskSpace() {
return useQuery({
queryKey: ["monitoring", "disk"],
queryFn: fetchDiskSpace,
staleTime: 60_000,
});
}
export function useCollectorControls() {
const queryClient = useQueryClient();
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["monitoring"] });
const start = useMutation({
mutationFn: startCollector,
onSuccess: invalidate,
});
const stop = useMutation({
mutationFn: stopCollector,
onSuccess: invalidate,
});
const restart = useMutation({
mutationFn: restartCollector,
onSuccess: invalidate,
});
return { start, stop, restart };
}
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+115
View File
@@ -0,0 +1,115 @@
import { useCounts, useLibraries, useNowPlaying } from "../hooks/useDashboard";
import { useMonitoringMetrics, useDiskSpace } from "../hooks/useMonitoring";
import { NowPlaying } from "../components/NowPlaying";
import { MetricCard } from "../components/MetricCard";
import { LibraryOverview } from "../components/LibraryOverview";
function formatBytes(bytes: number): string {
if (!bytes || bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unitIdx = 0;
while (value >= 1000 && unitIdx < units.length - 1) {
value /= 1000;
unitIdx++;
}
return `${value.toFixed(1)} ${units[unitIdx]}`;
}
function formatRate(bytes: number): string {
return `${formatBytes(bytes)}/s`;
}
export function Dashboard() {
const { data: counts } = useCounts();
const { data: libraries } = useLibraries();
const { data: nowPlaying } = useNowPlaying();
const { data: metrics } = useMonitoringMetrics();
const { data: disk } = useDiskSpace();
const latest = metrics?.samples?.at(-1);
return (
<div className="space-y-8">
{/* Now Playing */}
<section>
<h2 className="text-lg font-semibold mb-3">Now playing</h2>
{nowPlaying && <NowPlaying sessions={nowPlaying} />}
</section>
<hr />
{/* Server Overview */}
<section>
<h2 className="text-lg font-semibold mb-3">Server overview</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3">
<MetricCard
label="CPU"
value={latest ? `${latest.cpu_pct.toFixed(1)}%` : "-"}
/>
<MetricCard
label="IO Wait"
value={latest ? `${(latest.iowait_pct ?? 0).toFixed(1)}%` : "-"}
/>
<MetricCard
label="RAM"
value={latest ? `${latest.mem_pct.toFixed(1)}%` : "-"}
/>
<MetricCard
label="Net down"
value={latest ? formatRate(latest.net_rx_bytes_per_sec) : "-"}
/>
<MetricCard
label="Net up"
value={latest ? formatRate(latest.net_tx_bytes_per_sec) : "-"}
/>
<MetricCard
label="Disk read"
value={latest ? formatRate(latest.disk_read_bps) : "-"}
/>
<MetricCard
label="Disk write"
value={latest ? formatRate(latest.disk_write_bps) : "-"}
/>
</div>
{disk && (
<div className="mt-3 grid grid-cols-4 gap-3">
<MetricCard label="Disk used" value={formatBytes(disk.used)} />
<MetricCard
label="Disk available"
value={formatBytes(disk.available)}
/>
<MetricCard label="Disk total" value={formatBytes(disk.size)} />
<MetricCard label="Used %" value={disk.used_pct} />
</div>
)}
</section>
<hr />
{/* Media Library Overview */}
<section>
<h2 className="text-lg font-semibold mb-3">Media library overview</h2>
{counts && (
<div className="grid grid-cols-4 gap-3 mb-4">
<MetricCard
label="Total"
value={(
counts.movies +
counts.series +
counts.episodes
).toLocaleString()}
/>
<MetricCard label="Movies" value={counts.movies.toLocaleString()} />
<MetricCard label="Series" value={counts.series.toLocaleString()} />
<MetricCard
label="Episodes"
value={counts.episodes.toLocaleString()}
/>
</div>
)}
{libraries && <LibraryOverview libraries={libraries} />}
</section>
</div>
);
}
+222
View File
@@ -0,0 +1,222 @@
import { useState, useCallback, useRef } from "react";
import { AgGridReact } from "ag-grid-react";
import {
useDirectoryListing,
useFfprobe,
useJobTemplates,
useRunJob,
} from "../hooks/useFiles";
interface DisplayRow {
type: string;
name: string;
ext: string;
size: string;
modified: string;
path: string;
}
function formatSize(bytes: number): string {
if (bytes === 0) return "-";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unitIdx = 0;
while (value >= 1000 && unitIdx < units.length - 1) {
value /= 1000;
unitIdx++;
}
return `${value.toFixed(1)} ${units[unitIdx]}`;
}
function formatTime(epoch: number): string {
if (!epoch) return "";
return new Date(epoch * 1000).toLocaleString();
}
function isVideoFile(name: string): boolean {
const exts = [
".mkv",
".mp4",
".avi",
".m4v",
".ts",
".wmv",
".mov",
".flv",
".webm",
];
return exts.some((ext) => name.toLowerCase().endsWith(ext));
}
export function FileBrowser() {
const [currentDir, setCurrentDir] = useState("/");
const [pathInput, setPathInput] = useState("/");
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const { data: listing, isLoading, error } = useDirectoryListing(currentDir);
const { data: ffprobeData } = useFfprobe(
selectedPath ?? "",
!!selectedPath && isVideoFile(selectedPath),
);
const { data: templates } = useJobTemplates();
const runJob = useRunJob();
const gridRef = useRef<AgGridReact<DisplayRow>>(null);
const navigate = useCallback((path: string) => {
setCurrentDir(path);
setPathInput(path);
setSelectedPath(null);
}, []);
const handlePathSubmit = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
navigate(pathInput || "/");
}
};
// Build display rows
const rows: DisplayRow[] = [];
if (currentDir !== "/") {
const parent = currentDir.replace(/\/[^/]+\/?$/, "") || "/";
rows.push({
type: "up",
name: "..",
ext: "",
size: "-",
modified: "",
path: parent,
});
}
if (listing) {
for (const entry of listing.entries) {
const kind = entry.type === "d" ? "dir" : "file";
const ext = kind === "file" ? (entry.name.split(".").pop() ?? "") : "";
rows.push({
type: kind,
name: entry.name,
ext,
size: kind === "dir" ? "-" : formatSize(entry.size),
modified: formatTime(entry.mtime),
path: `${currentDir === "/" ? "" : currentDir}/${entry.name}`,
});
}
}
const columnDefs = [
{ field: "type" as const, headerName: "Type", width: 80 },
{ field: "name" as const, headerName: "Name", flex: 2 },
{ field: "ext" as const, headerName: "Ext", width: 80 },
{ field: "size" as const, headerName: "Size", width: 110 },
{ field: "modified" as const, headerName: "Modified", width: 180 },
];
const onRowClicked = useCallback(
(event: { data?: DisplayRow }) => {
const row = event.data;
if (!row) return;
if (row.type === "dir" || row.type === "up") {
navigate(row.path);
} else {
setSelectedPath(row.path);
}
},
[navigate],
);
return (
<div className="space-y-4">
{/* Path input */}
<div className="flex gap-2">
<input
type="text"
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
onKeyDown={handlePathSubmit}
className="border rounded px-3 py-1 text-sm flex-1"
placeholder="Remote path (press Enter to navigate)"
/>
<button
onClick={() => navigate(pathInput || "/")}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
>
Refresh
</button>
</div>
{/* Status */}
<div className="flex gap-6 text-xs text-gray-500">
<span>
Current: <code>{currentDir}</code>
</span>
{selectedPath && (
<span>
Selected: <code>{selectedPath}</code>
</span>
)}
{listing && <span>Entries: {listing.count}</span>}
</div>
{error && <p className="text-sm text-red-600">Error: {String(error)}</p>}
{/* File listing grid */}
<div className="ag-theme-alpine" style={{ height: 400, width: "100%" }}>
<AgGridReact<DisplayRow>
ref={gridRef}
rowData={rows}
columnDefs={columnDefs}
rowSelection="single"
onRowClicked={onRowClicked}
loading={isLoading}
suppressCellFocus
animateRows={false}
/>
</div>
{/* ffprobe preview */}
{selectedPath && isVideoFile(selectedPath) && (
<section className="border rounded-lg p-4">
<h3 className="text-sm font-semibold mb-2">
ffprobe preview: <code className="text-xs">{selectedPath}</code>
</h3>
{ffprobeData ? (
<pre className="text-xs bg-gray-50 p-3 rounded overflow-auto max-h-96">
{JSON.stringify(ffprobeData, null, 2)}
</pre>
) : (
<p className="text-sm text-gray-500">Loading ffprobe data...</p>
)}
</section>
)}
{/* Jobs */}
{selectedPath && templates && templates.length > 0 && (
<section className="border rounded-lg p-4">
<h3 className="text-sm font-semibold mb-2">Jobs</h3>
<div className="flex gap-2 flex-wrap">
{templates.map((tpl) => (
<button
key={tpl.key}
onClick={() =>
runJob.mutate({ jobKey: tpl.key, path: selectedPath })
}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
title={tpl.description}
>
{tpl.name}
</button>
))}
</div>
{runJob.data && (
<pre className="text-xs bg-gray-50 p-3 rounded mt-3 overflow-auto max-h-48">
Exit: {runJob.data.exit_status}
{"\n"}
{runJob.data.stdout}
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
</pre>
)}
</section>
)}
</div>
);
}
+210
View File
@@ -0,0 +1,210 @@
import { useState, useCallback, useRef } from "react";
import { AgGridReact } from "ag-grid-react";
import {
useMediaStatus,
useMediaQuery,
useBuildIndex,
} from "../hooks/useMedia";
import type { MediaItem } from "../types";
export function Media() {
const { data: status } = useMediaStatus();
const buildIndex = useBuildIndex();
const [search, setSearch] = useState("");
const [types, setTypes] = useState("Movie,Episode");
const [hdrFilter, setHdrFilter] = useState("All");
const [sortKey, setSortKey] = useState("title");
const [sortOrder, setSortOrder] = useState("Ascending");
const [limit] = useState(100);
const [offset, setOffset] = useState(0);
const { data: queryResult, isLoading } = useMediaQuery({
types,
search,
hdr_filter: hdrFilter,
sort_key: sortKey,
sort_order: sortOrder,
limit,
offset,
enabled: status?.exists ?? false,
});
const gridRef = useRef<AgGridReact<MediaItem>>(null);
const columnDefs = [
{ field: "title" as const, headerName: "Title", minWidth: 150 },
{ field: "series" as const, headerName: "Series", minWidth: 120 },
{ field: "season" as const, headerName: "Season", maxWidth: 95 },
{ field: "episode" as const, headerName: "Episode", maxWidth: 105 },
{ field: "type" as const, headerName: "Type", maxWidth: 100 },
{ field: "year" as const, headerName: "Year", maxWidth: 90 },
{
field: "runtime_min" as const,
headerName: "Runtime (min)",
maxWidth: 125,
},
{ field: "size" as const, headerName: "Size", maxWidth: 120 },
{ field: "bitrate" as const, headerName: "Bitrate", maxWidth: 125 },
{ field: "hdr" as const, headerName: "HDR", maxWidth: 80 },
{ field: "video" as const, headerName: "Video codec", maxWidth: 120 },
{ field: "resolution" as const, headerName: "Resolution", maxWidth: 120 },
{ field: "date_added" as const, headerName: "Date added", maxWidth: 120 },
{ field: "library" as const, headerName: "Library", maxWidth: 140 },
{ field: "path" as const, headerName: "Path", minWidth: 200 },
];
const onGridReady = useCallback(() => {
gridRef.current?.api?.sizeColumnsToFit();
}, []);
const page = Math.floor(offset / limit) + 1;
const totalPages = queryResult ? Math.ceil(queryResult.total / limit) : 1;
return (
<div className="space-y-4">
{/* Status and controls */}
<div className="flex items-center gap-4">
{status?.exists ? (
<span className="text-sm text-gray-600">
Index: {status.item_count.toLocaleString()} items
{status.updated_at_label && ` | updated ${status.updated_at_label}`}
</span>
) : (
<span className="text-sm text-amber-600">No index built yet.</span>
)}
<button
onClick={() => buildIndex.mutate()}
disabled={buildIndex.isPending}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50 disabled:opacity-50"
>
{buildIndex.isPending ? "Building..." : "Build index"}
</button>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 items-end">
<div>
<label className="text-xs text-gray-500 block">Search</label>
<input
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setOffset(0);
}}
className="border rounded px-2 py-1 text-sm w-48"
placeholder="Search title, series, path..."
/>
</div>
<div>
<label className="text-xs text-gray-500 block">Types</label>
<select
value={types}
onChange={(e) => {
setTypes(e.target.value);
setOffset(0);
}}
className="border rounded px-2 py-1 text-sm"
>
<option value="Movie,Episode">Movies + Episodes</option>
<option value="Movie">Movies only</option>
<option value="Episode">Episodes only</option>
<option value="Movie,Episode,Video">All video</option>
</select>
</div>
<div>
<label className="text-xs text-gray-500 block">HDR</label>
<select
value={hdrFilter}
onChange={(e) => {
setHdrFilter(e.target.value);
setOffset(0);
}}
className="border rounded px-2 py-1 text-sm"
>
<option value="All">All</option>
<option value="HDR only">HDR only</option>
<option value="SDR/unknown only">SDR/unknown only</option>
</select>
</div>
<div>
<label className="text-xs text-gray-500 block">Sort</label>
<select
value={sortKey}
onChange={(e) => setSortKey(e.target.value)}
className="border rounded px-2 py-1 text-sm"
>
<option value="title">Title</option>
<option value="series">Series</option>
<option value="size">Size</option>
<option value="bitrate">Bitrate</option>
<option value="runtime">Runtime</option>
<option value="year">Year</option>
<option value="date_added">Date added</option>
<option value="resolution">Resolution</option>
</select>
</div>
<div>
<label className="text-xs text-gray-500 block">Order</label>
<select
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
className="border rounded px-2 py-1 text-sm"
>
<option value="Ascending">Ascending</option>
<option value="Descending">Descending</option>
</select>
</div>
</div>
{/* Results info */}
{queryResult && (
<p className="text-xs text-gray-500">
Showing {queryResult.items.length} of{" "}
{queryResult.total.toLocaleString()} items | Page {page} of{" "}
{totalPages}
</p>
)}
{/* AG Grid table */}
{status?.exists && (
<div className="ag-theme-alpine" style={{ height: 600, width: "100%" }}>
<AgGridReact<MediaItem>
ref={gridRef}
rowData={queryResult?.items ?? []}
columnDefs={columnDefs}
rowSelection="single"
onGridReady={onGridReady}
loading={isLoading}
suppressCellFocus
animateRows={false}
/>
</div>
)}
{/* Pagination */}
{queryResult && totalPages > 1 && (
<div className="flex gap-2 items-center">
<button
onClick={() => setOffset(Math.max(0, offset - limit))}
disabled={page <= 1}
className="px-3 py-1 text-sm rounded border disabled:opacity-50"
>
Prev
</button>
<span className="text-sm">
Page {page} / {totalPages}
</span>
<button
onClick={() => setOffset(offset + limit)}
disabled={page >= totalPages}
className="px-3 py-1 text-sm rounded border disabled:opacity-50"
>
Next
</button>
</div>
)}
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
import {
useMonitoringStatus,
useMonitoringMetrics,
useDiskSpace,
useCollectorControls,
} from "../hooks/useMonitoring";
import { MetricCard } from "../components/MetricCard";
import { MonitoringCharts } from "../components/MonitoringCharts";
function formatBytes(bytes: number): string {
if (!bytes || bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unitIdx = 0;
while (value >= 1000 && unitIdx < units.length - 1) {
value /= 1000;
unitIdx++;
}
return `${value.toFixed(1)} ${units[unitIdx]}`;
}
function formatRate(bytes: number): string {
return `${formatBytes(bytes)}/s`;
}
export function Monitoring() {
const { data: status } = useMonitoringStatus();
const { data: metrics } = useMonitoringMetrics();
const { data: disk } = useDiskSpace();
const { start, stop, restart } = useCollectorControls();
const samples = metrics?.samples ?? [];
const latest = samples.at(-1);
// Compute averages and peaks
const avg = (arr: number[]) =>
arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
const max = (arr: number[]) => (arr.length ? Math.max(...arr) : 0);
const cpuArr = samples.map((s) => s.cpu_pct);
const iowArr = samples.map((s) => s.iowait_pct ?? 0);
const memArr = samples.map((s) => s.mem_pct);
const netDownArr = samples.map((s) => s.net_rx_bytes_per_sec);
const netUpArr = samples.map((s) => s.net_tx_bytes_per_sec);
const diskReadArr = samples.map((s) => s.disk_read_bps);
const diskWriteArr = samples.map((s) => s.disk_write_bps);
return (
<div className="space-y-8">
{/* Controls */}
<section className="flex items-center gap-4">
<span className="text-sm text-gray-600">
Collector:{" "}
<code className="bg-gray-100 px-1 rounded">
{status?.status ?? "unknown"}
</code>
</span>
<button
onClick={() => start.mutate()}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
>
Start
</button>
<button
onClick={() => restart.mutate()}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
>
Restart
</button>
<button
onClick={() => stop.mutate()}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
>
Stop
</button>
</section>
{/* Metrics summary */}
<section>
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3">
<MetricCard
label="CPU now"
value={latest ? `${latest.cpu_pct.toFixed(1)}%` : "-"}
subtext={`avg ${avg(cpuArr).toFixed(1)}%\npeak ${max(cpuArr).toFixed(1)}%`}
/>
<MetricCard
label="IO Wait"
value={latest ? `${(latest.iowait_pct ?? 0).toFixed(1)}%` : "-"}
subtext={`avg ${avg(iowArr).toFixed(1)}%\npeak ${max(iowArr).toFixed(1)}%`}
/>
<MetricCard
label="RAM now"
value={latest ? `${latest.mem_pct.toFixed(1)}%` : "-"}
subtext={`avg ${avg(memArr).toFixed(1)}%\npeak ${max(memArr).toFixed(1)}%`}
/>
<MetricCard
label="Net down"
value={latest ? formatRate(latest.net_rx_bytes_per_sec) : "-"}
subtext={`avg ${formatRate(avg(netDownArr))}\npeak ${formatRate(max(netDownArr))}`}
/>
<MetricCard
label="Net up"
value={latest ? formatRate(latest.net_tx_bytes_per_sec) : "-"}
subtext={`avg ${formatRate(avg(netUpArr))}\npeak ${formatRate(max(netUpArr))}`}
/>
<MetricCard
label="Disk read"
value={latest ? formatRate(latest.disk_read_bps) : "-"}
subtext={`avg ${formatRate(avg(diskReadArr))}\npeak ${formatRate(max(diskReadArr))}`}
/>
<MetricCard
label="Disk write"
value={latest ? formatRate(latest.disk_write_bps) : "-"}
subtext={`avg ${formatRate(avg(diskWriteArr))}\npeak ${formatRate(max(diskWriteArr))}`}
/>
</div>
</section>
{/* Disk space */}
{disk && (
<section>
<div className="grid grid-cols-4 gap-3">
<MetricCard label="Disk used" value={formatBytes(disk.used)} />
<MetricCard
label="Disk available"
value={formatBytes(disk.available)}
/>
<MetricCard label="Disk total" value={formatBytes(disk.size)} />
<MetricCard label="Used %" value={disk.used_pct} />
</div>
</section>
)}
{/* Charts */}
<section>
<MonitoringCharts samples={samples} />
</section>
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
/**
* Shared TypeScript interfaces matching backend API responses.
*/
export interface MediaCounts {
movies: number;
series: number;
episodes: number;
}
export interface LibraryCount {
library: string;
type: string;
movies: number;
series: number;
episodes: number;
total: number;
}
export interface NowPlayingSession {
user: string;
title: string;
type: string;
state: string;
transcoding: string;
transcoding_type: string;
device: string;
session_id: string;
}
export interface MonitoringStatus {
status: string;
}
export interface MonitoringSample {
ts: number;
cpu_pct: number;
iowait_pct?: number;
mem_pct: number;
net_rx_bytes_per_sec: number;
net_tx_bytes_per_sec: number;
disk_read_bps: number;
disk_write_bps: number;
}
export interface MonitoringMetrics {
samples: MonitoringSample[];
total_samples: number;
filtered_samples: number;
cutoff_ts: number;
}
export interface DiskSpace {
filesystem: string;
size: number;
used: number;
available: number;
used_pct: string;
mount: string;
}
export interface MediaIndexStatus {
exists: boolean;
item_count: number;
updated_at: number | null;
updated_at_label: string;
build_duration_seconds: number | null;
}
export interface MediaItem {
id: string;
title: string;
series: string;
season: string;
episode: number | null;
type: string;
year: number | null;
runtime_min: number | null;
size: string;
bitrate: string;
hdr: string;
video: string;
resolution: string;
date_added: string;
library: string;
path: string;
}
export interface MediaQueryResponse {
items: MediaItem[];
total: number;
limit: number;
offset: number;
}
export interface FileEntry {
type: string;
size: number;
mtime: number;
name: string;
}
export interface DirectoryListing {
path: string;
entries: FileEntry[];
count: number;
}
export interface JobTemplate {
key: string;
name: string;
description: string;
}
export interface JobResult {
job_key: string;
path: string;
exit_status: number;
stdout: string;
stderr: string;
}
export interface ResolvedPath {
original: string;
resolved: string;
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
"/api": "http://localhost:8000",
},
},
});
+18 -80
View File
@@ -15,7 +15,6 @@ from __future__ import annotations
import json
import posixpath
from pathlib import PurePosixPath
from typing import Any
import streamlit as st
@@ -25,12 +24,9 @@ from media_library_viewer.clients.ssh import RemoteSSHClient
from media_library_viewer.config import load_config
from media_library_viewer.ui.dashboard import render_media_overview, render_now_playing, render_resource_dashboard
from media_library_viewer.ui.file_browser import render_file_browser, set_file_browser_path
from media_library_viewer.ui.library import show_item_card, show_item_detail
from media_library_viewer.ui.media import render_media_tab
from media_library_viewer.ui.preview import render_ssh_tools
st.set_page_config(page_title="Media Library Viewer", layout="wide")
@st.cache_resource(show_spinner=False)
def get_jellyfin_client(base_url: str, api_key: str) -> JellyfinClient:
@@ -64,44 +60,20 @@ def cached_libraries(base_url: str, api_key: str, user_id: str):
return get_jellyfin_client(base_url, api_key).libraries(user_id)
@st.cache_data(ttl=60, show_spinner=False)
def cached_items(
base_url: str,
api_key: str,
user_id: str,
parent_id: str | None,
search: str,
media_types: str,
start: int,
limit: int,
sort_by: str = "SortName",
sort_order: str = "Ascending",
):
"""Cache paginated Jellyfin item list requests."""
return get_jellyfin_client(base_url, api_key).items(
user_id=user_id,
parent_id=parent_id,
search=search,
include_item_types=media_types,
start_index=start,
limit=limit,
sort_by=sort_by,
sort_order=sort_order,
)
@st.cache_data(ttl=60, show_spinner=False)
def cached_item(base_url: str, api_key: str, user_id: str, item_id: str):
"""Cache a single Jellyfin item detail payload."""
return get_jellyfin_client(base_url, api_key).item(user_id, item_id)
@st.cache_data(ttl=300, show_spinner=False)
def cached_media_counts(base_url: str, api_key: str, user_id: str):
"""Cache dashboard-level media counts for movies/series/episodes."""
return get_jellyfin_client(base_url, api_key).media_counts(user_id)
@st.cache_data(ttl=300, show_spinner=False)
def cached_library_counts(base_url: str, api_key: str, user_id: str):
"""Cache per-library item counts for dashboard breakdown."""
client = get_jellyfin_client(base_url, api_key)
libraries = client.libraries(user_id)
return client.library_item_counts(user_id, libraries)
@st.cache_data(ttl=15, show_spinner=False)
def cached_active_sessions(base_url: str, api_key: str):
"""Cache active Jellyfin sessions briefly for dashboard now-playing status."""
@@ -226,6 +198,10 @@ def credentials_panel():
def main():
"""Application entrypoint used by the root ``app.py`` wrapper."""
# Set page config per Streamlit session. Doing this at module import time is
# unreliable here because the root wrapper imports ``main`` from this module,
# and Python may reuse the already-imported module on browser reloads.
st.set_page_config(page_title="Media Library Viewer", layout="wide")
st.title("Media Library Viewer")
st.caption("Phase 1: Jellyfin browser + SSH filesystem inspection + safe remote job templates")
@@ -253,22 +229,22 @@ def main():
selected_user = st.selectbox("Jellyfin user", list(user_options.keys()))
user_id = user_options[selected_user]
tab_dashboard, tab_resources, tab_media, tab_library, tab_files = st.tabs(
["Dashboard", "Resources", "Media", "Jellyfin library", "File browser"]
tab_dashboard, tab_monitoring, tab_media, tab_files = st.tabs(
["Dashboard", "Monitoring", "Media", "File browser"]
)
with tab_dashboard:
render_media_overview(cached_media_counts, jellyfin_url, jellyfin_api_key, user_id)
st.divider()
render_now_playing(cached_active_sessions, jellyfin_url, jellyfin_api_key)
st.divider()
if not ssh_host or not ssh_username:
st.info("Enter SSH connection details in the sidebar for server resource overview.")
st.info("Enter SSH connection details in the sidebar for server monitoring overview.")
else:
ssh_args = (ssh_host, ssh_username, ssh_port, ssh_key, ssh_password)
render_resource_dashboard(get_ssh_client, ssh_args, media_root or "/", detailed=False)
st.divider()
render_media_overview(cached_media_counts, cached_library_counts, jellyfin_url, jellyfin_api_key, user_id)
with tab_resources:
with tab_monitoring:
if not ssh_host or not ssh_username:
st.info("Enter SSH connection details in the sidebar.")
else:
@@ -287,44 +263,6 @@ def main():
with tab_media:
render_media_tab(client, user_id, libraries, set_prefixed_file_browser_path)
with tab_library:
if not libraries:
st.warning("No libraries found.")
return
lib_by_name = {lib["Name"]: lib for lib in libraries}
with st.sidebar:
st.header("Library filters")
lib_name = st.selectbox("Library", list(lib_by_name.keys()))
media_types = st.multiselect("Media types", ["Movie", "Series", "Episode", "Video", "Audio"], default=[])
search = st.text_input("Search")
limit = st.slider("Items per page", 10, 200, 50, step=10)
page = st.number_input("Page", min_value=1, value=1)
response = cached_items(
jellyfin_url,
jellyfin_api_key,
user_id,
lib_by_name[lib_name]["Id"],
search,
",".join(media_types),
(page - 1) * limit,
limit,
)
items = response.get("Items", [])
st.subheader(f"{lib_name} ({response.get('TotalRecordCount', len(items))} items)")
cols = st.columns(5)
for idx, item in enumerate(items):
with cols[idx % 5]:
show_item_card(client, item)
if st.session_state.get("selected_item_id"):
st.divider()
detail = cached_item(jellyfin_url, jellyfin_api_key, user_id, st.session_state["selected_item_id"])
show_item_detail(client, detail)
if detail.get("Path") and st.button("Open item path in file browser", key="library_open_item_path_in_file_browser"):
resolved_path = resolve_remote_media_path(detail["Path"], media_root, remote_path_prefix)
set_file_browser_path(str(PurePosixPath(resolved_path).parent), resolved_path)
with tab_files:
if not ssh_host or not ssh_username:
st.info("Enter SSH connection details in the sidebar.")
+22 -2
View File
@@ -133,8 +133,28 @@ class JellyfinClient:
"episodes": self.item_count(user_id, "Episode"),
}
def item(self, user_id: str, item_id: str) -> dict[str, Any]:
return self.get(f"/Users/{user_id}/Items/{item_id}", Fields=DEFAULT_FIELDS)
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."""
@@ -29,7 +29,7 @@ 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}' /proc/stat
awk '/^cpu / {print $2+$3+$4+$5+$6+$7+$8+$9+$10, $5+$6, $6}' /proc/stat
}
read_mem_pct() {
@@ -84,6 +84,7 @@ read_disk_bytes() {
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}"
@@ -115,6 +116,7 @@ while true; do
set -- $(read_cpu)
total="${1:-0}"
idle="${2:-0}"
iowait="${3:-0}"
set -- $(read_net_bytes)
rx="${1:-0}"
tx="${2:-0}"
@@ -125,12 +127,14 @@ while true; do
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"}')"
@@ -138,8 +142,8 @@ while true; do
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,"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" "$mem_pct" "$rx_bytes_per_sec" "$tx_bytes_per_sec" "$rx_bps" "$tx_bps" "$disk_read_bps" "$disk_write_bps" >> "$OUT"
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
@@ -148,6 +152,7 @@ while true; do
prev_total="$total"
prev_idle="$idle"
prev_iowait="$iowait"
prev_rx="$rx"
prev_tx="$tx"
prev_disk_read="$disk_read"
+96 -29
View File
@@ -58,8 +58,9 @@ def format_elapsed(seconds: float | int | None) -> str:
return f"{hours}h {minutes}m"
def render_media_overview(cached_media_counts, base_url: str, api_key: str, user_id: str) -> None:
"""Render dashboard counts for movies/series/episodes."""
def render_media_overview(cached_media_counts, cached_library_counts, base_url: str, api_key: str,
user_id: str) -> None:
"""Render dashboard counts for movies/series/episodes and per-library breakdown."""
st.subheader("Media library overview")
try:
counts = cached_media_counts(base_url, api_key, user_id)
@@ -67,10 +68,58 @@ def render_media_overview(cached_media_counts, base_url: str, api_key: str, user
st.warning(f"Could not load Jellyfin media counts: {exc}")
return
cols = st.columns(3)
cols[0].metric("Movies", f"{counts.get('movies', 0):,}")
cols[1].metric("Series", f"{counts.get('series', 0):,}")
cols[2].metric("Episodes", f"{counts.get('episodes', 0):,}")
# Top-level totals
total_items = counts.get("movies", 0) + counts.get("series", 0) + counts.get("episodes", 0)
top_cols = st.columns(4)
top_cols[0].metric("Total items", f"{total_items:,}")
top_cols[1].metric("Movies", f"{counts.get('movies', 0):,}")
top_cols[2].metric("Series", f"{counts.get('series', 0):,}")
top_cols[3].metric("Episodes", f"{counts.get('episodes', 0):,}")
# Per-library breakdown
try:
lib_counts = cached_library_counts(base_url, api_key, user_id)
except Exception as exc:
st.caption(f"Could not load per-library counts: {exc}")
return
if not lib_counts:
return
st.markdown("**Libraries**")
movie_libs = [e for e in lib_counts if e.get("type") == "movies"]
tv_libs = [e for e in lib_counts if e.get("type") == "tvshows"]
if movie_libs and tv_libs:
left_col, right_col = st.columns(2)
elif movie_libs:
left_col = st.container()
right_col = None
elif tv_libs:
left_col = None
right_col = st.container()
else:
return
if movie_libs and left_col:
with left_col:
st.caption("Movie libraries")
for entry in movie_libs:
with st.container(border=True):
st.markdown(f"**{entry['library']}**")
m_cols = st.columns(2)
m_cols[0].metric("Movies", f"{entry['movies']:,}")
if tv_libs and right_col:
with right_col:
st.caption("TV libraries")
for entry in tv_libs:
with st.container(border=True):
st.markdown(f"**{entry['library']}**")
m_cols = st.columns(2)
m_cols[0].metric("Series", f"{entry['series']:,}")
m_cols[1].metric("Episodes", f"{entry['total']:,}")
def render_now_playing(cached_active_sessions, base_url: str, api_key: str) -> None:
@@ -130,8 +179,8 @@ def render_now_playing(cached_active_sessions, base_url: str, api_key: str) -> N
def render_resource_dashboard(get_ssh_client, ssh_args: tuple, media_root: str, detailed: bool = False) -> None:
"""Render server resource summary or detailed resource charts."""
st.subheader("Server resource details" if detailed else "Server overview")
"""Render server monitoring summary or detailed charts."""
st.subheader("Server monitoring details" if detailed else "Server overview")
host, username, port, key_filename, password = ssh_args
ssh = get_ssh_client(host, username, port, key_filename, password)
@@ -144,25 +193,25 @@ def render_resource_dashboard(get_ssh_client, ssh_args: tuple, media_root: str,
if detailed:
control_col, start_col, restart_col, stop_col, refresh_col = st.columns([2.3, 1, 1, 1, 1])
control_col.caption(f"Collector: `{status}` | sample interval: 10s | retention: 7 days / 70k samples")
if start_col.button("Start metrics", key="resource_start_metrics", use_container_width=True):
if start_col.button("Start metrics", key="monitoring_start_metrics", use_container_width=True):
try:
st.success(start_resource_collector(ssh))
except Exception as exc:
st.error(str(exc))
if restart_col.button("Restart", key="resource_restart_metrics", use_container_width=True):
if restart_col.button("Restart", key="monitoring_restart_metrics", use_container_width=True):
try:
st.success(restart_resource_collector(ssh))
except Exception as exc:
st.error(str(exc))
if stop_col.button("Stop metrics", key="resource_stop_metrics", use_container_width=True):
if stop_col.button("Stop metrics", key="monitoring_stop_metrics", use_container_width=True):
try:
st.info(stop_resource_collector(ssh))
except Exception as exc:
st.error(str(exc))
if refresh_col.button("Refresh", key="resource_refresh", use_container_width=True):
if refresh_col.button("Refresh", key="monitoring_refresh", use_container_width=True):
st.rerun()
else:
st.caption(f"Collector: `{status}`. Open the Resources tab for controls, diagnostics, and detailed charts.")
st.caption(f"Collector: `{status}`. Open the Monitoring tab for controls, diagnostics, and detailed charts.")
try:
rows = read_resource_metrics(ssh, max_lines=1000)
@@ -179,25 +228,28 @@ def render_resource_dashboard(get_ssh_client, ssh_args: tuple, media_root: str,
disk_cols[1].metric("Disk available", human_size(space.get("available")))
disk_cols[2].metric("Disk total", human_size(space.get("size")))
disk_cols[3].metric("Used percent", f"{used_pct_value:.0f}%")
st.progress(min(max(used_pct_value / 100, 0), 1), text=f"{space.get('mount', disk_path)} on {space.get('filesystem', '')}")
st.progress(min(max(used_pct_value / 100, 0), 1),
text=f"{space.get('mount', disk_path)} on {space.get('filesystem', '')}")
except Exception as exc:
st.warning(f"Could not read disk space for {disk_path}: {exc}")
if not rows:
if detailed:
st.info("No resource history yet. Click 'Start metrics' and wait at least 10 seconds for the first sample. If this stays empty, use Restart to install the latest collector script.")
st.info(
"No monitoring history yet. Click 'Start metrics' and wait at least 10 seconds for the first sample. If this stays empty, use Restart to install the latest collector script.")
with st.expander("Collector diagnostics"):
try:
st.code(resource_collector_debug_info(ssh))
except Exception as exc:
st.error(f"Could not read collector diagnostics: {exc}")
else:
st.info("No resource history yet. Open the Resources tab to start the collector.")
st.info("No monitoring history yet. Open the Monitoring tab to start the collector.")
return
df = pd.DataFrame(rows)
numeric_columns = [
"ts", "cpu_pct", "mem_pct", "net_rx_bytes_per_sec", "net_tx_bytes_per_sec", "disk_read_bps", "disk_write_bps"
"ts", "cpu_pct", "iowait_pct", "mem_pct", "net_rx_bytes_per_sec", "net_tx_bytes_per_sec", "disk_read_bps",
"disk_write_bps"
]
for column in numeric_columns:
if column in df.columns:
@@ -215,7 +267,8 @@ def render_resource_dashboard(get_ssh_client, ssh_args: tuple, media_root: str,
if all_sample_count:
raw_df = pd.DataFrame(rows)
st.write(f"Parsed samples: {all_sample_count}")
st.write(f"Newest remote sample age: {time.time() - float(raw_df['ts'].astype(float).max()):.0f} seconds")
st.write(
f"Newest remote sample age: {time.time() - float(raw_df['ts'].astype(float).max()):.0f} seconds")
st.dataframe(raw_df.tail(10), use_container_width=True, hide_index=True)
else:
st.write("No parseable samples found.")
@@ -225,6 +278,8 @@ def render_resource_dashboard(get_ssh_client, ssh_args: tuple, media_root: str,
latest = df.iloc[-1]
avg_cpu = df["cpu_pct"].mean()
max_cpu = df["cpu_pct"].max()
avg_iowait = df["iowait_pct"].mean() if "iowait_pct" in df.columns else 0
max_iowait = df["iowait_pct"].max() if "iowait_pct" in df.columns else 0
avg_mem = df["mem_pct"].mean()
max_mem = df["mem_pct"].max()
avg_net_down = df["net_rx_bytes_per_sec"].mean()
@@ -236,20 +291,32 @@ def render_resource_dashboard(get_ssh_client, ssh_args: tuple, media_root: str,
avg_disk_write = df["disk_write_bps"].mean()
max_disk_write = df["disk_write_bps"].max()
metric_cols = st.columns(6)
metric_cols[0].metric("CPU now", f"{latest['cpu_pct']:.1f}%", f"avg {avg_cpu:.1f}% / peak {max_cpu:.1f}%")
metric_cols[1].metric("RAM now", f"{latest['mem_pct']:.1f}%", f"avg {avg_mem:.1f}% / peak {max_mem:.1f}%")
metric_cols[2].metric("Network down", format_rate_bytes(latest["net_rx_bytes_per_sec"]), f"avg {format_rate_bytes(avg_net_down)} / peak {format_rate_bytes(max_net_down)}")
metric_cols[3].metric("Network up", format_rate_bytes(latest["net_tx_bytes_per_sec"]), f"avg {format_rate_bytes(avg_net_up)} / peak {format_rate_bytes(max_net_up)}")
metric_cols[4].metric("Disk read", format_rate_bytes(latest["disk_read_bps"]), f"avg {format_rate_bytes(avg_disk_read)} / peak {format_rate_bytes(max_disk_read)}")
metric_cols[5].metric("Disk write", format_rate_bytes(latest["disk_write_bps"]), f"avg {format_rate_bytes(avg_disk_write)} / peak {format_rate_bytes(max_disk_write)}")
metric_cols = st.columns(7)
metric_cols[0].metric("CPU now", f"{latest['cpu_pct']:.1f}%")
metric_cols[0].caption(f"avg {avg_cpu:.1f}% \npeak {max_cpu:.1f}%")
metric_cols[1].metric("IO wait", f"{latest.get('iowait_pct', 0):.1f}%")
metric_cols[1].caption(f"avg {avg_iowait:.1f}% \npeak {max_iowait:.1f}%")
metric_cols[2].metric("RAM now", f"{latest['mem_pct']:.1f}%")
metric_cols[2].caption(f"avg {avg_mem:.1f}% \npeak {max_mem:.1f}%")
metric_cols[3].metric("Network down", format_rate_bytes(latest["net_rx_bytes_per_sec"]))
metric_cols[3].caption(f"avg {format_rate_bytes(avg_net_down)} \npeak {format_rate_bytes(max_net_down)}")
metric_cols[4].metric("Network up", format_rate_bytes(latest["net_tx_bytes_per_sec"]))
metric_cols[4].caption(f"avg {format_rate_bytes(avg_net_up)} \npeak {format_rate_bytes(max_net_up)}")
metric_cols[5].metric("Disk read", format_rate_bytes(latest["disk_read_bps"]))
metric_cols[5].caption(f"avg {format_rate_bytes(avg_disk_read)} \npeak {format_rate_bytes(max_disk_read)}")
metric_cols[6].metric("Disk write", format_rate_bytes(latest["disk_write_bps"]))
metric_cols[6].caption(f"avg {format_rate_bytes(avg_disk_write)} \npeak {format_rate_bytes(max_disk_write)}")
chart_df = df.set_index("time")
if detailed:
st.markdown("**CPU and RAM - last hour**")
st.line_chart(chart_df[["cpu_pct", "mem_pct"]], use_container_width=True)
st.markdown("**CPU, IO wait, and RAM - last hour**")
chart_cols = ["cpu_pct", "mem_pct"]
if "iowait_pct" in chart_df.columns:
chart_cols = ["cpu_pct", "iowait_pct", "mem_pct"]
st.line_chart(chart_df[chart_cols], use_container_width=True)
else:
st.caption("Detailed CPU/RAM charts, network, disk I/O, raw samples, and collector controls are available in the Resources tab.")
st.caption(
"Detailed CPU/IO wait/RAM charts, network, disk I/O, raw samples, and collector controls are available in the Monitoring tab.")
return
net_down_df, net_down_suffix = scaled_rate_chart_df(chart_df, ["net_rx_bytes_per_sec"], ["download"])
@@ -272,5 +339,5 @@ def render_resource_dashboard(get_ssh_client, ssh_args: tuple, media_root: str,
st.markdown(f"**Disk write - last hour ({disk_write_suffix})**")
st.line_chart(disk_write_df, use_container_width=True)
with st.expander("Raw resource samples"):
with st.expander("Raw monitoring samples"):
st.dataframe(df.sort_values("time", ascending=False), use_container_width=True, hide_index=True)
+3 -13
View File
@@ -69,25 +69,15 @@ def render_file_browser(cached_dir_listing: Callable[..., list[dict]], ssh_args:
status_col.caption(f"Current folder: `{current_dir}`")
selected_col.caption(f"Selected path: `{selected}`")
path_col, up_col, go_col, select_col, refresh_col = st.columns([5, 1, 1, 1.5, 1.5])
path_col, refresh_col = st.columns([6, 1])
path_input = path_col.text_input("Remote path", key="file_browser_path_input", label_visibility="collapsed")
requested_path = path_input or "/"
if up_col.button("Up", key="file_browser_up", use_container_width=True):
set_file_browser_path(str(PurePosixPath(current_dir).parent))
st.rerun()
if go_col.button("Go", key="file_browser_go", use_container_width=True):
# Navigate when the user edits the path and presses Enter
if requested_path != current_dir:
set_file_browser_path(requested_path)
st.rerun()
if select_col.button("Select folder", key="file_browser_select_current_folder", use_container_width=True):
if requested_path != current_dir:
set_file_browser_path(requested_path, requested_path)
else:
st.session_state["file_browser_selected_path"] = current_dir
st.rerun()
if refresh_col.button("Refresh", key="file_browser_refresh", use_container_width=True):
cached_dir_listing.clear()
if requested_path != current_dir:
set_file_browser_path(requested_path)
st.rerun()
try:
-51
View File
@@ -1,51 +0,0 @@
"""Jellyfin library browser UI pieces."""
from __future__ import annotations
from typing import Any
import pandas as pd
import streamlit as st
from media_library_viewer.domain.media import media_streams
from media_library_viewer.utils import ticks_to_minutes
def show_item_card(client, item: dict[str, Any]) -> None:
"""Render a poster card in the Jellyfin library grid."""
try:
st.image(client.image_url(item["Id"]), use_container_width=True)
except Exception:
st.caption("No image")
st.markdown(f"**{item.get('Name', 'Untitled')}**")
meta = [item.get("Type", "")]
if item.get("ProductionYear"):
meta.append(str(item["ProductionYear"]))
minutes = ticks_to_minutes(item.get("RunTimeTicks"))
if minutes:
meta.append(f"{minutes} min")
st.caption(" - ".join([m for m in meta if m]))
if st.button("Open", key=f"open-{item['Id']}"):
st.session_state["selected_item_id"] = item["Id"]
def show_item_detail(client, item: dict[str, Any]) -> None:
"""Render detailed Jellyfin item metadata for the selected poster card."""
st.header(item.get("Name", "Untitled"))
left, right = st.columns([1, 2])
with left:
st.image(client.image_url(item["Id"]), use_container_width=True)
with right:
st.write(item.get("Overview") or "No overview.")
st.write("**Path:**", item.get("Path") or "Not exposed by Jellyfin")
st.write("**Genres:**", ", ".join(item.get("Genres", [])) or "-")
st.write("**Rating:**", item.get("CommunityRating") or "-")
st.write("**Official rating:**", item.get("OfficialRating") or "-")
streams = media_streams(item)
if streams:
st.subheader("Jellyfin media streams")
st.dataframe(pd.DataFrame(streams), use_container_width=True)
with st.expander("Raw Jellyfin JSON"):
st.json(item)
+17 -16
View File
@@ -143,25 +143,26 @@ def render_media_tab(
selected_media_path = st.session_state.get("media_inventory_selected_path")
grid_builder = GridOptionsBuilder.from_dataframe(table_df)
grid_builder.configure_default_column(editable=False, resizable=True, sortable=False, filter=False)
grid_builder.configure_column("title", header_name="Title", flex=2)
grid_builder.configure_column("series", header_name="Series", flex=1.5)
grid_builder.configure_column("season", header_name="Season", width=95)
grid_builder.configure_column("episode", header_name="Episode", width=105)
grid_builder.configure_column("type", header_name="Type", width=100)
grid_builder.configure_column("year", header_name="Year", width=90)
grid_builder.configure_column("runtime_min", header_name="Runtime (min)", width=125)
grid_builder.configure_column("size", header_name="Size", width=120)
grid_builder.configure_column("bitrate", header_name="Bitrate", width=125)
grid_builder.configure_column("hdr", header_name="HDR", width=80)
grid_builder.configure_column("video", header_name="Video codec", width=120)
grid_builder.configure_column("resolution", header_name="Resolution", width=120)
grid_builder.configure_column("date_added", header_name="Date added", width=120)
grid_builder.configure_column("library", header_name="Library", width=140)
grid_builder.configure_column("path", header_name="Path", flex=2)
grid_builder.configure_default_column(editable=False, resizable=True, sortable=False, filter=False, autoSize=True)
grid_builder.configure_column("title", header_name="Title", minWidth=150)
grid_builder.configure_column("series", header_name="Series", minWidth=120)
grid_builder.configure_column("season", header_name="Season", maxWidth=95)
grid_builder.configure_column("episode", header_name="Episode", maxWidth=105)
grid_builder.configure_column("type", header_name="Type", maxWidth=100)
grid_builder.configure_column("year", header_name="Year", maxWidth=90)
grid_builder.configure_column("runtime_min", header_name="Runtime (min)", maxWidth=125)
grid_builder.configure_column("size", header_name="Size", maxWidth=120)
grid_builder.configure_column("bitrate", header_name="Bitrate", maxWidth=125)
grid_builder.configure_column("hdr", header_name="HDR", maxWidth=80)
grid_builder.configure_column("video", header_name="Video codec", maxWidth=120)
grid_builder.configure_column("resolution", header_name="Resolution", maxWidth=120)
grid_builder.configure_column("date_added", header_name="Date added", maxWidth=120)
grid_builder.configure_column("library", header_name="Library", maxWidth=140)
grid_builder.configure_column("path", header_name="Path", minWidth=200)
grid_builder.configure_column("id", hide=True)
grid_builder.configure_selection(selection_mode="single", use_checkbox=False)
grid_options = grid_builder.build()
grid_options["autoSizeStrategy"] = {"type": "fitCellContents"}
grid_options["rowSelection"] = {
"mode": "singleRow",
"checkboxes": False,