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:
@@ -39,3 +39,11 @@ env/
|
|||||||
# Logs/temp
|
# Logs/temp
|
||||||
*.log
|
*.log
|
||||||
tmp/
|
tmp/
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# Pi internal
|
||||||
|
.pi-lens/
|
||||||
|
.pi/
|
||||||
|
|||||||
@@ -13,10 +13,8 @@ Project policy/docs:
|
|||||||
|
|
||||||
- Dashboard media counts for movies, series, and episodes, plus now-playing sessions (user, title, playback state, transcoding)
|
- 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
|
- 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
|
- 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
|
- Compact SSH remote directory browser with clickable rows, search, filters, sorting, pagination, and `[UP] ..` navigation
|
||||||
- Blocking selected-file `ffprobe` preview for known video files
|
- Blocking selected-file `ffprobe` preview for known video files
|
||||||
- Separate container, video, audio, and subtitle metadata sections
|
- Separate container, video, audio, and subtitle metadata sections
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""Jellyfin HTTP API client.
|
||||||
|
|
||||||
|
This module is deliberately independent from Streamlit. It wraps only the API
|
||||||
|
calls the app currently needs and returns plain Python dictionaries/lists so a
|
||||||
|
future FastAPI/React frontend can reuse the same client.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
# Jellyfin validates Fields against its ItemFields enum. Keep this list to
|
||||||
|
# documented/commonly supported optional fields; invalid names cause 400s.
|
||||||
|
DEFAULT_FIELDS = ",".join(
|
||||||
|
[
|
||||||
|
"DateCreated",
|
||||||
|
"Genres",
|
||||||
|
"MediaSources",
|
||||||
|
"Overview",
|
||||||
|
"Path",
|
||||||
|
"People",
|
||||||
|
"PremiereDate",
|
||||||
|
"ProviderIds",
|
||||||
|
"Tags",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinClient:
|
||||||
|
"""Small wrapper around the Jellyfin/Emby-compatible HTTP API."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, api_key: str, timeout: int = 30):
|
||||||
|
if not base_url:
|
||||||
|
raise ValueError("Jellyfin URL is required")
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError("Jellyfin API key is required")
|
||||||
|
|
||||||
|
# Use the server root, not the web UI path. Users often paste
|
||||||
|
# https://host/web; API endpoints live at https://host/...
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
if self.base_url.endswith("/web"):
|
||||||
|
self.base_url = self.base_url[:-4]
|
||||||
|
self.api_key = api_key
|
||||||
|
self.timeout = timeout
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update(
|
||||||
|
{
|
||||||
|
"X-Emby-Token": api_key,
|
||||||
|
"Accept": "application/json",
|
||||||
|
"X-Emby-Authorization": 'MediaBrowser Client="MediaLibraryViewer", Device="Streamlit", DeviceId="streamlit", Version="0.1"',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def get(self, path: str, **params: Any) -> dict[str, Any]:
|
||||||
|
"""GET a Jellyfin endpoint and include useful response text on errors."""
|
||||||
|
clean_params = {k: v for k, v in params.items() if v is not None and v != ""}
|
||||||
|
response = self.session.get(
|
||||||
|
f"{self.base_url}{path}", params=clean_params, timeout=self.timeout
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response.raise_for_status()
|
||||||
|
except requests.HTTPError as exc:
|
||||||
|
detail = response.text[:500]
|
||||||
|
raise requests.HTTPError(
|
||||||
|
f"{response.status_code} for {response.url}: {detail}",
|
||||||
|
response=response,
|
||||||
|
) from exc
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def users(self) -> list[dict[str, Any]]:
|
||||||
|
"""List users visible to this API key.
|
||||||
|
|
||||||
|
Jellyfin API keys are server-level tokens, not user session tokens, so
|
||||||
|
/Users/Me often fails with API-key auth. The user id selected here is
|
||||||
|
then used for user-scoped library endpoints.
|
||||||
|
"""
|
||||||
|
return self.get("/Users")
|
||||||
|
|
||||||
|
def libraries(self, user_id: str) -> list[dict[str, Any]]:
|
||||||
|
"""Return top-level library views visible to the selected Jellyfin user."""
|
||||||
|
return self.get(f"/Users/{user_id}/Views").get("Items", [])
|
||||||
|
|
||||||
|
def items(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
parent_id: str | None = None,
|
||||||
|
start_index: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
search: str | None = None,
|
||||||
|
include_item_types: str | None = None,
|
||||||
|
recursive: bool = True,
|
||||||
|
sort_by: str = "SortName",
|
||||||
|
sort_order: str = "Ascending",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return a paginated item list for a user/library.
|
||||||
|
|
||||||
|
This is used by both the visual library browser and the media-index
|
||||||
|
builder. Keep arguments close to Jellyfin's own query parameters so the
|
||||||
|
service layer can request server-side pagination and basic sorting.
|
||||||
|
"""
|
||||||
|
return self.get(
|
||||||
|
f"/Users/{user_id}/Items",
|
||||||
|
ParentId=parent_id,
|
||||||
|
StartIndex=start_index,
|
||||||
|
Limit=limit,
|
||||||
|
SearchTerm=search,
|
||||||
|
IncludeItemTypes=include_item_types,
|
||||||
|
Recursive=str(recursive).lower(),
|
||||||
|
Fields=DEFAULT_FIELDS,
|
||||||
|
SortBy=sort_by,
|
||||||
|
SortOrder=sort_order,
|
||||||
|
)
|
||||||
|
|
||||||
|
def item_count(self, user_id: str, include_item_types: str, parent_id: str | None = None) -> int:
|
||||||
|
"""Return a count using Jellyfin's TotalRecordCount without fetching rows."""
|
||||||
|
response = self.get(
|
||||||
|
f"/Users/{user_id}/Items",
|
||||||
|
ParentId=parent_id,
|
||||||
|
Recursive="true",
|
||||||
|
IncludeItemTypes=include_item_types,
|
||||||
|
Limit=0,
|
||||||
|
)
|
||||||
|
return int(response.get("TotalRecordCount", 0))
|
||||||
|
|
||||||
|
def media_counts(self, user_id: str) -> dict[str, int]:
|
||||||
|
"""Return dashboard-level counts for the main media types."""
|
||||||
|
return {
|
||||||
|
"movies": self.item_count(user_id, "Movie"),
|
||||||
|
"series": self.item_count(user_id, "Series"),
|
||||||
|
"episodes": self.item_count(user_id, "Episode"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def library_item_counts(self, user_id: str, libraries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Return per-library item counts broken down by type for the dashboard."""
|
||||||
|
results = []
|
||||||
|
for lib in libraries:
|
||||||
|
lib_id = lib.get("Id")
|
||||||
|
lib_name = lib.get("Name", "Unknown")
|
||||||
|
lib_type = lib.get("CollectionType", "")
|
||||||
|
if not lib_id:
|
||||||
|
continue
|
||||||
|
movies = self.item_count(user_id, "Movie", parent_id=lib_id)
|
||||||
|
series = self.item_count(user_id, "Series", parent_id=lib_id)
|
||||||
|
episodes = self.item_count(user_id, "Episode", parent_id=lib_id)
|
||||||
|
total = self.item_count(user_id, "Movie,Episode,Video,Audio,Series", parent_id=lib_id)
|
||||||
|
results.append({
|
||||||
|
"library": lib_name,
|
||||||
|
"type": lib_type,
|
||||||
|
"movies": movies,
|
||||||
|
"series": series,
|
||||||
|
"episodes": episodes,
|
||||||
|
"total": total,
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
def active_sessions(self, active_within_seconds: int = 300) -> list[dict[str, Any]]:
|
||||||
|
"""Return currently active sessions that have a now-playing item."""
|
||||||
|
payload = self.get("/Sessions", ActiveWithinSeconds=active_within_seconds)
|
||||||
|
sessions = payload if isinstance(payload, list) else []
|
||||||
|
return [session for session in sessions if session.get("NowPlayingItem")]
|
||||||
|
|
||||||
|
def image_url(self, item_id: str, image_type: str = "Primary") -> str:
|
||||||
|
"""Build an authenticated image URL suitable for st.image/browser use."""
|
||||||
|
return f"{self.base_url}/Items/{item_id}/Images/{image_type}?api_key={self.api_key}"
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
"""Remote resource collection helpers.
|
||||||
|
|
||||||
|
The app does not require Prometheus, Netdata, or sysstat. Instead it can install
|
||||||
|
and manage a tiny POSIX-sh collector under /tmp on the remote server. The
|
||||||
|
collector samples Linux /proc and /sys counters every 10 seconds and appends JSON
|
||||||
|
Lines. This module starts/stops the collector and reads those JSONL samples.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shlex
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from clients.ssh import RemoteSSHClient
|
||||||
|
|
||||||
|
# POSIX shell script copied to the remote server by start_resource_collector().
|
||||||
|
# Keep this script bash-free because many NAS/media servers have minimal shells.
|
||||||
|
COLLECTOR_SCRIPT = r'''#!/bin/sh
|
||||||
|
set -u
|
||||||
|
OUT="${1:-/tmp/media_library_viewer_metrics.jsonl}"
|
||||||
|
INTERVAL="${2:-10}"
|
||||||
|
RETENTION_SECONDS="${3:-604800}"
|
||||||
|
MAX_LINES="${4:-70000}"
|
||||||
|
PRUNE_EVERY_SAMPLES="${5:-60}"
|
||||||
|
mkdir -p "$(dirname "$OUT")"
|
||||||
|
|
||||||
|
echo "collector starting at $(date -Is 2>/dev/null || date), interval=${INTERVAL}s, retention=${RETENTION_SECONDS}s, max_lines=${MAX_LINES}, out=${OUT}"
|
||||||
|
|
||||||
|
read_cpu() {
|
||||||
|
awk '/^cpu / {print $2+$3+$4+$5+$6+$7+$8+$9+$10, $5+$6, $6}' /proc/stat
|
||||||
|
}
|
||||||
|
|
||||||
|
read_mem_pct() {
|
||||||
|
awk '
|
||||||
|
/^MemTotal:/ {total=$2}
|
||||||
|
/^MemAvailable:/ {avail=$2}
|
||||||
|
END {if (total > 0) printf "%.2f", (total-avail)*100/total; else printf "0"}
|
||||||
|
' /proc/meminfo
|
||||||
|
}
|
||||||
|
|
||||||
|
read_net_bytes() {
|
||||||
|
awk '
|
||||||
|
NR > 2 {
|
||||||
|
split($0, parts, ":")
|
||||||
|
iface = parts[1]
|
||||||
|
stats = parts[2]
|
||||||
|
gsub(/^[ \t]+|[ \t]+$/, "", iface)
|
||||||
|
gsub(/^[ \t]+|[ \t]+$/, "", stats)
|
||||||
|
if (iface == "lo" || iface == "" || stats == "") next
|
||||||
|
split(stats, values, /[ \t]+/)
|
||||||
|
# /proc/net/dev after the colon:
|
||||||
|
# receive bytes are field 1, transmit bytes are field 9.
|
||||||
|
# Trim the stats block before split; otherwise leading whitespace can make
|
||||||
|
# values[1] empty in some awk implementations, resulting in zero rates.
|
||||||
|
rx += values[1] + 0
|
||||||
|
tx += values[9] + 0
|
||||||
|
}
|
||||||
|
END {printf "%.0f %.0f", rx, tx}
|
||||||
|
' /proc/net/dev
|
||||||
|
}
|
||||||
|
|
||||||
|
read_disk_bytes() {
|
||||||
|
read_sectors=0
|
||||||
|
written_sectors=0
|
||||||
|
for dev in /sys/block/*; do
|
||||||
|
[ -r "$dev/stat" ] || continue
|
||||||
|
name="$(basename "$dev")"
|
||||||
|
case "$name" in
|
||||||
|
loop*|ram*|fd*|sr*) continue ;;
|
||||||
|
esac
|
||||||
|
# Linux /sys/block/<dev>/stat fields: 3=sectors read, 7=sectors written.
|
||||||
|
# Use POSIX sh parsing instead of bash arrays so this works on minimal systems.
|
||||||
|
set -- $(cat "$dev/stat")
|
||||||
|
sectors_read="${3:-0}"
|
||||||
|
sectors_written="${7:-0}"
|
||||||
|
read_sectors=$((read_sectors + sectors_read))
|
||||||
|
written_sectors=$((written_sectors + sectors_written))
|
||||||
|
done
|
||||||
|
printf "%s %s" "$((read_sectors * 512))" "$((written_sectors * 512))"
|
||||||
|
}
|
||||||
|
|
||||||
|
set -- $(read_cpu)
|
||||||
|
prev_total="${1:-0}"
|
||||||
|
prev_idle="${2:-0}"
|
||||||
|
prev_iowait="${3:-0}"
|
||||||
|
set -- $(read_net_bytes)
|
||||||
|
prev_rx="${1:-0}"
|
||||||
|
prev_tx="${2:-0}"
|
||||||
|
set -- $(read_disk_bytes)
|
||||||
|
prev_disk_read="${1:-0}"
|
||||||
|
prev_disk_write="${2:-0}"
|
||||||
|
prev_ts="$(date +%s)"
|
||||||
|
sample_count=0
|
||||||
|
|
||||||
|
prune_metrics_file() {
|
||||||
|
[ -f "$OUT" ] || return 0
|
||||||
|
cutoff="$1"
|
||||||
|
tmp="${OUT}.$$.tmp"
|
||||||
|
awk -v cutoff="$cutoff" '
|
||||||
|
match($0, /"ts":[0-9]+/) {
|
||||||
|
ts = substr($0, RSTART + 5, RLENGTH - 5)
|
||||||
|
if (ts >= cutoff) print $0
|
||||||
|
}
|
||||||
|
' "$OUT" | tail -n "$MAX_LINES" > "$tmp" && mv "$tmp" "$OUT"
|
||||||
|
rm -f "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
sleep "$INTERVAL"
|
||||||
|
now_ts="$(date +%s)"
|
||||||
|
dt=$((now_ts - prev_ts))
|
||||||
|
if [ "$dt" -le 0 ]; then dt=1; fi
|
||||||
|
|
||||||
|
set -- $(read_cpu)
|
||||||
|
total="${1:-0}"
|
||||||
|
idle="${2:-0}"
|
||||||
|
iowait="${3:-0}"
|
||||||
|
set -- $(read_net_bytes)
|
||||||
|
rx="${1:-0}"
|
||||||
|
tx="${2:-0}"
|
||||||
|
set -- $(read_disk_bytes)
|
||||||
|
disk_read="${1:-0}"
|
||||||
|
disk_write="${2:-0}"
|
||||||
|
mem_pct="$(read_mem_pct)"
|
||||||
|
|
||||||
|
total_delta=$((total - prev_total))
|
||||||
|
idle_delta=$((idle - prev_idle))
|
||||||
|
iowait_delta=$((iowait - prev_iowait))
|
||||||
|
rx_delta=$((rx - prev_rx))
|
||||||
|
tx_delta=$((tx - prev_tx))
|
||||||
|
disk_read_delta=$((disk_read - prev_disk_read))
|
||||||
|
disk_write_delta=$((disk_write - prev_disk_write))
|
||||||
|
|
||||||
|
cpu_pct="$(awk -v total="$total_delta" -v idle="$idle_delta" 'BEGIN {if (total > 0) printf "%.2f", (total-idle)*100/total; else printf "0"}')"
|
||||||
|
iowait_pct="$(awk -v total="$total_delta" -v iow="$iowait_delta" 'BEGIN {if (total > 0) printf "%.2f", iow*100/total; else printf "0"}')"
|
||||||
|
rx_bytes_per_sec="$(awk -v bytes="$rx_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes/dt; else printf "0"}')"
|
||||||
|
tx_bytes_per_sec="$(awk -v bytes="$tx_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes/dt; else printf "0"}')"
|
||||||
|
rx_bps="$(awk -v bytes="$rx_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes*8/dt; else printf "0"}')"
|
||||||
|
tx_bps="$(awk -v bytes="$tx_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes*8/dt; else printf "0"}')"
|
||||||
|
disk_read_bps="$(awk -v bytes="$disk_read_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes/dt; else printf "0"}')"
|
||||||
|
disk_write_bps="$(awk -v bytes="$disk_write_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes/dt; else printf "0"}')"
|
||||||
|
|
||||||
|
printf '{"ts":%s,"cpu_pct":%s,"iowait_pct":%s,"mem_pct":%s,"net_rx_bytes_per_sec":%s,"net_tx_bytes_per_sec":%s,"net_rx_bps":%s,"net_tx_bps":%s,"disk_read_bps":%s,"disk_write_bps":%s}\n' \
|
||||||
|
"$now_ts" "$cpu_pct" "$iowait_pct" "$mem_pct" "$rx_bytes_per_sec" "$tx_bytes_per_sec" "$rx_bps" "$tx_bps" "$disk_read_bps" "$disk_write_bps" >> "$OUT"
|
||||||
|
|
||||||
|
sample_count=$((sample_count + 1))
|
||||||
|
if [ $((sample_count % PRUNE_EVERY_SAMPLES)) -eq 0 ]; then
|
||||||
|
prune_metrics_file "$((now_ts - RETENTION_SECONDS))"
|
||||||
|
fi
|
||||||
|
|
||||||
|
prev_total="$total"
|
||||||
|
prev_idle="$idle"
|
||||||
|
prev_iowait="$iowait"
|
||||||
|
prev_rx="$rx"
|
||||||
|
prev_tx="$tx"
|
||||||
|
prev_disk_read="$disk_read"
|
||||||
|
prev_disk_write="$disk_write"
|
||||||
|
prev_ts="$now_ts"
|
||||||
|
done
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ResourceMonitorPaths:
|
||||||
|
"""Remote file locations used by the lightweight resource collector."""
|
||||||
|
|
||||||
|
metrics_file: str = "/tmp/media_library_viewer_metrics.jsonl"
|
||||||
|
pid_file: str = "/tmp/media_library_viewer_metrics.pid"
|
||||||
|
script_file: str = "/tmp/media_library_viewer_metrics_collector.sh"
|
||||||
|
log_file: str = "/tmp/media_library_viewer_metrics.log"
|
||||||
|
|
||||||
|
|
||||||
|
def start_resource_collector(
|
||||||
|
ssh: RemoteSSHClient,
|
||||||
|
interval_seconds: int = 10,
|
||||||
|
retention_seconds: int = 7 * 24 * 60 * 60,
|
||||||
|
max_lines: int = 70_000,
|
||||||
|
paths: ResourceMonitorPaths = ResourceMonitorPaths(),
|
||||||
|
) -> str:
|
||||||
|
"""Install and start the remote metrics collector if it is not running.
|
||||||
|
|
||||||
|
Starting a fresh collector removes old metrics/log files because schema
|
||||||
|
changes during development can otherwise leave mixed JSONL records behind.
|
||||||
|
The collector prunes its own metrics file to 7 days / max_lines.
|
||||||
|
"""
|
||||||
|
command = f"""
|
||||||
|
cat > {shlex.quote(paths.script_file)} <<'MLV_RESOURCE_COLLECTOR'
|
||||||
|
{COLLECTOR_SCRIPT}
|
||||||
|
MLV_RESOURCE_COLLECTOR
|
||||||
|
chmod +x {shlex.quote(paths.script_file)}
|
||||||
|
if [ -f {shlex.quote(paths.pid_file)} ] && kill -0 "$(cat {shlex.quote(paths.pid_file)})" 2>/dev/null; then
|
||||||
|
echo "already running pid=$(cat {shlex.quote(paths.pid_file)})"
|
||||||
|
else
|
||||||
|
rm -f {shlex.quote(paths.metrics_file)} {shlex.quote(paths.log_file)}
|
||||||
|
nohup {shlex.quote(paths.script_file)} {shlex.quote(paths.metrics_file)} {int(interval_seconds)} {int(retention_seconds)} {int(max_lines)} >> {shlex.quote(paths.log_file)} 2>&1 &
|
||||||
|
echo $! > {shlex.quote(paths.pid_file)}
|
||||||
|
echo "started pid=$(cat {shlex.quote(paths.pid_file)})"
|
||||||
|
fi
|
||||||
|
"""
|
||||||
|
result = ssh.run(command, timeout=20)
|
||||||
|
if result.exit_status != 0:
|
||||||
|
raise RuntimeError(result.stderr or result.stdout or "failed to start resource collector")
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def stop_resource_collector(ssh: RemoteSSHClient, paths: ResourceMonitorPaths = ResourceMonitorPaths()) -> str:
|
||||||
|
"""Stop the remote collector process if the pid file points to one."""
|
||||||
|
command = f"""
|
||||||
|
if [ -f {shlex.quote(paths.pid_file)} ]; then
|
||||||
|
pid="$(cat {shlex.quote(paths.pid_file)})"
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
kill "$pid"
|
||||||
|
echo "stopped pid=$pid"
|
||||||
|
else
|
||||||
|
echo "not running"
|
||||||
|
fi
|
||||||
|
rm -f {shlex.quote(paths.pid_file)}
|
||||||
|
else
|
||||||
|
echo "not running"
|
||||||
|
fi
|
||||||
|
"""
|
||||||
|
result = ssh.run(command, timeout=20)
|
||||||
|
if result.exit_status != 0:
|
||||||
|
raise RuntimeError(result.stderr or result.stdout or "failed to stop resource collector")
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def restart_resource_collector(
|
||||||
|
ssh: RemoteSSHClient,
|
||||||
|
interval_seconds: int = 10,
|
||||||
|
retention_seconds: int = 7 * 24 * 60 * 60,
|
||||||
|
max_lines: int = 70_000,
|
||||||
|
paths: ResourceMonitorPaths = ResourceMonitorPaths(),
|
||||||
|
) -> str:
|
||||||
|
stop_message = stop_resource_collector(ssh, paths)
|
||||||
|
start_message = start_resource_collector(ssh, interval_seconds, retention_seconds, max_lines, paths)
|
||||||
|
return f"{stop_message}\n{start_message}"
|
||||||
|
|
||||||
|
|
||||||
|
def resource_collector_status(ssh: RemoteSSHClient, paths: ResourceMonitorPaths = ResourceMonitorPaths()) -> str:
|
||||||
|
"""Return a short human-readable status string for the dashboard."""
|
||||||
|
command = f"""
|
||||||
|
if [ -f {shlex.quote(paths.pid_file)} ] && kill -0 "$(cat {shlex.quote(paths.pid_file)})" 2>/dev/null; then
|
||||||
|
echo "running pid=$(cat {shlex.quote(paths.pid_file)})"
|
||||||
|
else
|
||||||
|
echo "not running"
|
||||||
|
fi
|
||||||
|
"""
|
||||||
|
result = ssh.run(command, timeout=10)
|
||||||
|
if result.exit_status != 0:
|
||||||
|
raise RuntimeError(result.stderr or result.stdout or "failed to check collector status")
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def resource_collector_debug_info(ssh: RemoteSSHClient, paths: ResourceMonitorPaths = ResourceMonitorPaths()) -> str:
|
||||||
|
"""Collect remote diagnostics for troubleshooting missing metrics."""
|
||||||
|
command = f"""
|
||||||
|
echo "status:"
|
||||||
|
if [ -f {shlex.quote(paths.pid_file)} ]; then
|
||||||
|
pid="$(cat {shlex.quote(paths.pid_file)})"
|
||||||
|
echo "pid_file=$pid"
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then echo "process=running"; else echo "process=not-running"; fi
|
||||||
|
else
|
||||||
|
echo "pid_file=missing"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "files:"
|
||||||
|
ls -l {shlex.quote(paths.script_file)} {shlex.quote(paths.metrics_file)} {shlex.quote(paths.log_file)} 2>&1 || true
|
||||||
|
|
||||||
|
echo "sample_count:"
|
||||||
|
if [ -f {shlex.quote(paths.metrics_file)} ]; then wc -l < {shlex.quote(paths.metrics_file)}; else echo 0; fi
|
||||||
|
|
||||||
|
echo "last_samples:"
|
||||||
|
if [ -f {shlex.quote(paths.metrics_file)} ]; then tail -n 5 {shlex.quote(paths.metrics_file)}; fi
|
||||||
|
|
||||||
|
echo "log_tail:"
|
||||||
|
if [ -f {shlex.quote(paths.log_file)} ]; then tail -n 40 {shlex.quote(paths.log_file)}; fi
|
||||||
|
|
||||||
|
echo "netdev_snapshot:"
|
||||||
|
cat /proc/net/dev 2>&1 || true
|
||||||
|
"""
|
||||||
|
result = ssh.run(command, timeout=20)
|
||||||
|
return (result.stdout or "") + (result.stderr or "")
|
||||||
|
|
||||||
|
|
||||||
|
def read_resource_metrics(ssh: RemoteSSHClient, max_lines: int = 1000, paths: ResourceMonitorPaths = ResourceMonitorPaths()) -> list[dict[str, Any]]:
|
||||||
|
"""Read recent JSONL metric samples from the remote collector file."""
|
||||||
|
command = f"test -f {shlex.quote(paths.metrics_file)} && tail -n {int(max_lines)} {shlex.quote(paths.metrics_file)} || true"
|
||||||
|
result = ssh.run(command, timeout=20)
|
||||||
|
if result.exit_status != 0:
|
||||||
|
raise RuntimeError(result.stderr or result.stdout or "failed to read resource metrics")
|
||||||
|
rows = []
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rows.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def disk_space(ssh: RemoteSSHClient, path: str = "/") -> dict[str, Any]:
|
||||||
|
"""Return df information for the filesystem containing ``path``."""
|
||||||
|
command = (
|
||||||
|
"df -P -B1 -- "
|
||||||
|
+ shlex.quote(path or "/")
|
||||||
|
+ " | awk 'NR==2 {printf \"{\\\"filesystem\\\":\\\"%s\\\",\\\"size\\\":%s,\\\"used\\\":%s,\\\"available\\\":%s,\\\"used_pct\\\":\\\"%s\\\",\\\"mount\\\":\\\"%s\\\"}\", $1,$2,$3,$4,$5,$6}'"
|
||||||
|
)
|
||||||
|
result = ssh.run(command, timeout=20)
|
||||||
|
if result.exit_status != 0 or not result.stdout.strip():
|
||||||
|
raise RuntimeError(result.stderr or result.stdout or "failed to read disk space")
|
||||||
|
return json.loads(result.stdout)
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""SSH client helpers for remote filesystem and media inspection.
|
||||||
|
|
||||||
|
All command execution goes through ``/bin/sh -c`` and all paths inserted into
|
||||||
|
commands are shell-quoted by callers. This is important for two reasons:
|
||||||
|
|
||||||
|
1. The remote login shell may be fish/csh/etc.; internal commands are POSIX sh.
|
||||||
|
2. Media paths frequently contain spaces and punctuation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import posixpath
|
||||||
|
import shlex
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CommandResult:
|
||||||
|
"""Plain result object returned by remote command execution."""
|
||||||
|
|
||||||
|
command: str
|
||||||
|
exit_status: int
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteSSHClient:
|
||||||
|
"""SSH helper for read-only inspection plus explicit job execution."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
username: str,
|
||||||
|
port: int = 22,
|
||||||
|
key_filename: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
timeout: int = 20,
|
||||||
|
):
|
||||||
|
if not host or not username:
|
||||||
|
raise ValueError("SSH host and username are required")
|
||||||
|
self.host = host
|
||||||
|
self.username = username
|
||||||
|
self.port = port
|
||||||
|
self.key_filename = key_filename or None
|
||||||
|
self.password = password or None
|
||||||
|
self.timeout = timeout
|
||||||
|
self._client: paramiko.SSHClient | None = None
|
||||||
|
|
||||||
|
def connect(self) -> paramiko.SSHClient:
|
||||||
|
"""Create or reuse the Paramiko connection.
|
||||||
|
|
||||||
|
Unknown host keys are rejected. Users should connect once manually with
|
||||||
|
ssh so the server is present in known_hosts.
|
||||||
|
"""
|
||||||
|
if self._client:
|
||||||
|
return self._client
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.load_system_host_keys()
|
||||||
|
client.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||||
|
client.connect(
|
||||||
|
self.host,
|
||||||
|
port=self.port,
|
||||||
|
username=self.username,
|
||||||
|
key_filename=self.key_filename,
|
||||||
|
password=self.password,
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
self._client = client
|
||||||
|
return client
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self._client:
|
||||||
|
self._client.close()
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
def run(self, command: str, timeout: int | None = None) -> CommandResult:
|
||||||
|
"""Run a command through POSIX sh, independent of the user's login shell.
|
||||||
|
|
||||||
|
Paramiko asks the SSH server to execute a command using the account's
|
||||||
|
default shell. If that shell is fish/csh/etc., POSIX snippets containing
|
||||||
|
`if ...; then`, pipes, redirects, or heredocs can fail. All internal app
|
||||||
|
commands and job templates are written for POSIX shell, so explicitly
|
||||||
|
dispatch through `/bin/sh -c`.
|
||||||
|
"""
|
||||||
|
client = self.connect()
|
||||||
|
shell_command = f"/bin/sh -c {shlex.quote(command)}"
|
||||||
|
stdin, stdout, stderr = client.exec_command(shell_command, timeout=timeout or self.timeout)
|
||||||
|
exit_status = stdout.channel.recv_exit_status()
|
||||||
|
return CommandResult(
|
||||||
|
command=command,
|
||||||
|
exit_status=exit_status,
|
||||||
|
stdout=stdout.read().decode(errors="replace"),
|
||||||
|
stderr=stderr.read().decode(errors="replace"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_dir(self, path: str) -> CommandResult:
|
||||||
|
"""List one remote directory as JSON.
|
||||||
|
|
||||||
|
The command first verifies that ``path`` is a directory. Without that
|
||||||
|
guard, running ``find`` on a file can look like an empty directory, which
|
||||||
|
was a source of file-browser confusion. Output is NUL-delimited before
|
||||||
|
Python serializes it, making spaces in filenames safe.
|
||||||
|
"""
|
||||||
|
# JSON-ish output: type, size, mtime epoch, filename. Handles spaces/newlines reasonably via NUL boundaries.
|
||||||
|
quoted = shlex.quote(path)
|
||||||
|
not_dir_message = shlex.quote(f"Not a directory: {path}")
|
||||||
|
command = (
|
||||||
|
f"test -d {quoted} || "
|
||||||
|
f"{{ echo {not_dir_message} >&2; exit 20; }}; "
|
||||||
|
f"find {quoted} -maxdepth 1 -mindepth 1 -printf "
|
||||||
|
"'%y\\t%s\\t%T@\\t%f\\0' | python3 -c "
|
||||||
|
+ shlex.quote(
|
||||||
|
"import sys,json; data=sys.stdin.buffer.read().split(b'\\0'); "
|
||||||
|
"rows=[]\n"
|
||||||
|
"for row in data:\n"
|
||||||
|
" if not row: continue\n"
|
||||||
|
" t,s,m,n=row.decode('utf-8','replace').split('\\t',3)\n"
|
||||||
|
" rows.append({'type':t,'size':int(s),'mtime':float(m),'name':n})\n"
|
||||||
|
"print(json.dumps(rows))"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return self.run(command)
|
||||||
|
|
||||||
|
def stat_path(self, path: str) -> CommandResult:
|
||||||
|
"""Run stat for a remote file or directory path."""
|
||||||
|
quoted = shlex.quote(path)
|
||||||
|
return self.run(f"stat --printf='%F\\n%s bytes\\n%y\\n%n\\n' {quoted}")
|
||||||
|
|
||||||
|
def ffprobe_json(self, path: str) -> dict[str, Any]:
|
||||||
|
"""Run ffprobe and parse JSON output for a remote media file."""
|
||||||
|
quoted = shlex.quote(path)
|
||||||
|
result = self.run(
|
||||||
|
"ffprobe -v error -show_format -show_streams -print_format json " + quoted,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if result.exit_status != 0:
|
||||||
|
raise RuntimeError(result.stderr or result.stdout or "ffprobe failed")
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def join(parent: str, child: str) -> str:
|
||||||
|
return posixpath.normpath(posixpath.join(parent, child))
|
||||||
@@ -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()
|
||||||
@@ -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"]
|
||||||
@@ -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", ""),
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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"}
|
||||||
@@ -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)
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Routers package."""
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Dashboard router — media counts, per-library breakdown, now-playing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from dependencies import get_jellyfin_client, get_user_id
|
||||||
|
from clients.jellyfin import JellyfinClient
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/counts")
|
||||||
|
def get_counts(
|
||||||
|
client: JellyfinClient = Depends(get_jellyfin_client),
|
||||||
|
user_id: str = Depends(get_user_id),
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Return total movie/series/episode counts."""
|
||||||
|
return client.media_counts(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/libraries")
|
||||||
|
def get_library_counts(
|
||||||
|
client: JellyfinClient = Depends(get_jellyfin_client),
|
||||||
|
user_id: str = Depends(get_user_id),
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Return per-library item counts broken down by type."""
|
||||||
|
libraries = client.libraries(user_id)
|
||||||
|
return client.library_item_counts(user_id, libraries)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/now-playing")
|
||||||
|
def get_now_playing(
|
||||||
|
client: JellyfinClient = Depends(get_jellyfin_client),
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Return currently active playback sessions with transcode info."""
|
||||||
|
sessions = client.active_sessions()
|
||||||
|
results = []
|
||||||
|
for session in sessions:
|
||||||
|
item = session.get("NowPlayingItem") or {}
|
||||||
|
play_state = session.get("PlayState") or {}
|
||||||
|
transcoding = session.get("TranscodingInfo") or {}
|
||||||
|
|
||||||
|
series = item.get("SeriesName") or ""
|
||||||
|
title = f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown")
|
||||||
|
|
||||||
|
is_transcoding = bool(transcoding)
|
||||||
|
transcode_type = []
|
||||||
|
if is_transcoding:
|
||||||
|
if transcoding.get("IsVideoDirect") is False:
|
||||||
|
transcode_type.append("video")
|
||||||
|
if transcoding.get("IsAudioDirect") is False:
|
||||||
|
transcode_type.append("audio")
|
||||||
|
if not transcode_type:
|
||||||
|
transcode_type.append("active")
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"user": session.get("UserName") or "Unknown",
|
||||||
|
"title": title,
|
||||||
|
"type": item.get("Type", ""),
|
||||||
|
"state": "paused" if play_state.get("IsPaused") else "playing",
|
||||||
|
"transcoding": "yes" if is_transcoding else "no",
|
||||||
|
"transcoding_type": ", ".join(transcode_type),
|
||||||
|
"device": session.get("DeviceName") or session.get("Client") or "",
|
||||||
|
"session_id": session.get("Id") or "",
|
||||||
|
})
|
||||||
|
return results
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Files router — directory listing, ffprobe, stat, path resolution."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||||
|
|
||||||
|
from dependencies import get_ssh_client
|
||||||
|
from clients.ssh import RemoteSSHClient
|
||||||
|
from config import get_settings
|
||||||
|
from path_utils import resolve_remote_media_path
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/files", tags=["files"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/list")
|
||||||
|
def list_directory(
|
||||||
|
path: str = Query(..., description="Remote directory path to list"),
|
||||||
|
ssh: RemoteSSHClient = Depends(get_ssh_client),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""List a remote directory."""
|
||||||
|
result = ssh.list_dir(path)
|
||||||
|
if result.exit_status != 0:
|
||||||
|
raise HTTPException(status_code=400, detail=result.stderr or result.stdout or "Failed to list directory")
|
||||||
|
entries = json.loads(result.stdout)
|
||||||
|
return {
|
||||||
|
"path": path,
|
||||||
|
"entries": entries,
|
||||||
|
"count": len(entries),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ffprobe")
|
||||||
|
def get_ffprobe(
|
||||||
|
path: str = Query(..., description="Remote file path to probe"),
|
||||||
|
ssh: RemoteSSHClient = Depends(get_ssh_client),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Run ffprobe on a remote file and return parsed JSON."""
|
||||||
|
try:
|
||||||
|
data = ssh.ffprobe_json(path)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stat")
|
||||||
|
def get_stat(
|
||||||
|
path: str = Query(..., description="Remote path to stat"),
|
||||||
|
ssh: RemoteSSHClient = Depends(get_ssh_client),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Run stat on a remote path."""
|
||||||
|
result = ssh.stat_path(path)
|
||||||
|
if result.exit_status != 0:
|
||||||
|
raise HTTPException(status_code=400, detail=result.stderr or result.stdout or "stat failed")
|
||||||
|
return {"path": path, "output": result.stdout}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/resolve-path")
|
||||||
|
def resolve_path(
|
||||||
|
path: str = Query(..., description="Jellyfin path to resolve to SSH path"),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Resolve a Jellyfin path to its SSH-visible equivalent."""
|
||||||
|
settings = get_settings()
|
||||||
|
resolved = resolve_remote_media_path(path, settings.media_root, settings.path_prefix)
|
||||||
|
return {"original": path, "resolved": resolved}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Jobs router — list templates and run jobs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from dependencies import get_ssh_client
|
||||||
|
from clients.ssh import RemoteSSHClient
|
||||||
|
from jobs import JOB_TEMPLATES, run_job
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
|
||||||
|
|
||||||
|
|
||||||
|
class RunJobRequest(BaseModel):
|
||||||
|
job_key: str
|
||||||
|
path: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/templates")
|
||||||
|
def get_templates() -> list[dict[str, str]]:
|
||||||
|
"""Return available job templates."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"name": template.name,
|
||||||
|
"description": template.description,
|
||||||
|
}
|
||||||
|
for key, template in JOB_TEMPLATES.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/run")
|
||||||
|
def post_run_job(
|
||||||
|
request: RunJobRequest,
|
||||||
|
ssh: RemoteSSHClient = Depends(get_ssh_client),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Run a job template on a remote path."""
|
||||||
|
if request.job_key not in JOB_TEMPLATES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unknown job key: {request.job_key}")
|
||||||
|
|
||||||
|
result = run_job(ssh, request.job_key, request.path)
|
||||||
|
return {
|
||||||
|
"job_key": request.job_key,
|
||||||
|
"path": request.path,
|
||||||
|
"exit_status": result.exit_status,
|
||||||
|
"stdout": result.stdout,
|
||||||
|
"stderr": result.stderr,
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Media router — index status, build, and query."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from dependencies import get_jellyfin_client, get_user_id
|
||||||
|
from clients.jellyfin import JellyfinClient
|
||||||
|
from services.media_index import MediaIndex, build_media_index
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/media", tags=["media"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_media_index() -> MediaIndex:
|
||||||
|
return MediaIndex()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
def get_index_status(index: MediaIndex = Depends(get_media_index)) -> dict[str, Any]:
|
||||||
|
"""Return media index status (exists, count, last updated, build duration)."""
|
||||||
|
status = index.status()
|
||||||
|
return {
|
||||||
|
"exists": status.exists,
|
||||||
|
"item_count": status.item_count,
|
||||||
|
"updated_at": status.updated_at,
|
||||||
|
"updated_at_label": status.updated_at_label,
|
||||||
|
"build_duration_seconds": status.build_duration_seconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/build")
|
||||||
|
def post_build_index(
|
||||||
|
client: JellyfinClient = Depends(get_jellyfin_client),
|
||||||
|
user_id: str = Depends(get_user_id),
|
||||||
|
index: MediaIndex = Depends(get_media_index),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Rebuild the media index from Jellyfin."""
|
||||||
|
libraries = client.libraries(user_id)
|
||||||
|
count = build_media_index(client, user_id, libraries, index)
|
||||||
|
return {"indexed_items": count}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/query")
|
||||||
|
def query_media(
|
||||||
|
libraries: str = Query("", description="Comma-separated library IDs"),
|
||||||
|
types: str = Query("Movie,Episode", description="Comma-separated media types"),
|
||||||
|
search: str = Query("", description="Search term"),
|
||||||
|
hdr_filter: str = Query("All", description="All, HDR only, SDR/unknown only"),
|
||||||
|
sort_key: str = Query("title", description="Sort field"),
|
||||||
|
sort_order: str = Query("Ascending", description="Ascending or Descending"),
|
||||||
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
client: JellyfinClient = Depends(get_jellyfin_client),
|
||||||
|
user_id: str = Depends(get_user_id),
|
||||||
|
index: MediaIndex = Depends(get_media_index),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Query the media index with filters, sorting, and pagination."""
|
||||||
|
# If no library IDs provided, use all libraries
|
||||||
|
library_ids = [lid.strip() for lid in libraries.split(",") if lid.strip()] if libraries else None
|
||||||
|
if not library_ids:
|
||||||
|
all_libs = client.libraries(user_id)
|
||||||
|
library_ids = [lib["Id"] for lib in all_libs]
|
||||||
|
|
||||||
|
media_types = [t.strip() for t in types.split(",") if t.strip()]
|
||||||
|
|
||||||
|
rows, total = index.query(
|
||||||
|
library_ids=library_ids,
|
||||||
|
media_types=media_types,
|
||||||
|
search=search,
|
||||||
|
hdr_filter=hdr_filter,
|
||||||
|
sort_key=sort_key,
|
||||||
|
sort_order=sort_order,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": rows,
|
||||||
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Monitoring router — metrics, collector controls, disk space."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from dependencies import get_ssh_client
|
||||||
|
from clients.ssh import RemoteSSHClient
|
||||||
|
from clients.resources import (
|
||||||
|
disk_space,
|
||||||
|
read_resource_metrics,
|
||||||
|
resource_collector_debug_info,
|
||||||
|
resource_collector_status,
|
||||||
|
restart_resource_collector,
|
||||||
|
start_resource_collector,
|
||||||
|
stop_resource_collector,
|
||||||
|
)
|
||||||
|
from config import get_settings
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/monitoring", tags=["monitoring"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
def get_status(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
|
||||||
|
"""Return collector running status."""
|
||||||
|
return {"status": resource_collector_status(ssh)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/metrics")
|
||||||
|
def get_metrics(
|
||||||
|
max_lines: int = 1000,
|
||||||
|
last_seconds: int = 3600,
|
||||||
|
ssh: RemoteSSHClient = Depends(get_ssh_client),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return resource metric samples from the remote collector."""
|
||||||
|
rows = read_resource_metrics(ssh, max_lines=max_lines)
|
||||||
|
cutoff_ts = time.time() - last_seconds
|
||||||
|
filtered = [row for row in rows if float(row.get("ts", 0)) >= cutoff_ts]
|
||||||
|
return {
|
||||||
|
"samples": filtered,
|
||||||
|
"total_samples": len(rows),
|
||||||
|
"filtered_samples": len(filtered),
|
||||||
|
"cutoff_ts": cutoff_ts,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/disk")
|
||||||
|
def get_disk_space(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, Any]:
|
||||||
|
"""Return disk space for the configured media root."""
|
||||||
|
settings = get_settings()
|
||||||
|
path = settings.media_root or "/"
|
||||||
|
return disk_space(ssh, path)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/start")
|
||||||
|
def post_start(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
|
||||||
|
"""Start the remote resource collector."""
|
||||||
|
message = start_resource_collector(ssh)
|
||||||
|
return {"message": message}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/stop")
|
||||||
|
def post_stop(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
|
||||||
|
"""Stop the remote resource collector."""
|
||||||
|
message = stop_resource_collector(ssh)
|
||||||
|
return {"message": message}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/restart")
|
||||||
|
def post_restart(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
|
||||||
|
"""Restart the remote resource collector."""
|
||||||
|
message = restart_resource_collector(ssh)
|
||||||
|
return {"message": message}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/diagnostics")
|
||||||
|
def get_diagnostics(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
|
||||||
|
"""Return collector debug info for troubleshooting."""
|
||||||
|
return {"diagnostics": resource_collector_debug_info(ssh)}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -8,7 +8,7 @@ Build a compact Streamlit application for browsing a remote Jellyfin media libra
|
|||||||
|
|
||||||
## Current Phase
|
## 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
|
## 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.
|
- Keep a manual `ffprobe` action available for selected paths.
|
||||||
- Support `stat` on 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.
|
- 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 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.
|
- 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 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 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 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 `/`.
|
- 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`.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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 Resources tab.
|
- Removed the CPU/RAM chart from the Dashboard and kept detailed charts in the Monitoring tab.
|
||||||
- Renamed the remote files tab to File browser.
|
- 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 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -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/
|
||||||
@@ -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.
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
@@ -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>
|
||||||
Generated
+3608
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -15,7 +15,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import posixpath
|
import posixpath
|
||||||
from pathlib import PurePosixPath
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import streamlit as st
|
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.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.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.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.media import render_media_tab
|
||||||
from media_library_viewer.ui.preview import render_ssh_tools
|
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)
|
@st.cache_resource(show_spinner=False)
|
||||||
def get_jellyfin_client(base_url: str, api_key: str) -> JellyfinClient:
|
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)
|
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)
|
@st.cache_data(ttl=300, show_spinner=False)
|
||||||
def cached_media_counts(base_url: str, api_key: str, user_id: str):
|
def cached_media_counts(base_url: str, api_key: str, user_id: str):
|
||||||
"""Cache dashboard-level media counts for movies/series/episodes."""
|
"""Cache dashboard-level media counts for movies/series/episodes."""
|
||||||
return get_jellyfin_client(base_url, api_key).media_counts(user_id)
|
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)
|
@st.cache_data(ttl=15, show_spinner=False)
|
||||||
def cached_active_sessions(base_url: str, api_key: str):
|
def cached_active_sessions(base_url: str, api_key: str):
|
||||||
"""Cache active Jellyfin sessions briefly for dashboard now-playing status."""
|
"""Cache active Jellyfin sessions briefly for dashboard now-playing status."""
|
||||||
@@ -226,6 +198,10 @@ def credentials_panel():
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Application entrypoint used by the root ``app.py`` wrapper."""
|
"""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.title("Media Library Viewer")
|
||||||
st.caption("Phase 1: Jellyfin browser + SSH filesystem inspection + safe remote job templates")
|
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()))
|
selected_user = st.selectbox("Jellyfin user", list(user_options.keys()))
|
||||||
user_id = user_options[selected_user]
|
user_id = user_options[selected_user]
|
||||||
|
|
||||||
tab_dashboard, tab_resources, tab_media, tab_library, tab_files = st.tabs(
|
tab_dashboard, tab_monitoring, tab_media, tab_files = st.tabs(
|
||||||
["Dashboard", "Resources", "Media", "Jellyfin library", "File browser"]
|
["Dashboard", "Monitoring", "Media", "File browser"]
|
||||||
)
|
)
|
||||||
|
|
||||||
with tab_dashboard:
|
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)
|
render_now_playing(cached_active_sessions, jellyfin_url, jellyfin_api_key)
|
||||||
st.divider()
|
st.divider()
|
||||||
if not ssh_host or not ssh_username:
|
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:
|
else:
|
||||||
ssh_args = (ssh_host, ssh_username, ssh_port, ssh_key, ssh_password)
|
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)
|
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:
|
if not ssh_host or not ssh_username:
|
||||||
st.info("Enter SSH connection details in the sidebar.")
|
st.info("Enter SSH connection details in the sidebar.")
|
||||||
else:
|
else:
|
||||||
@@ -287,44 +263,6 @@ def main():
|
|||||||
with tab_media:
|
with tab_media:
|
||||||
render_media_tab(client, user_id, libraries, set_prefixed_file_browser_path)
|
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:
|
with tab_files:
|
||||||
if not ssh_host or not ssh_username:
|
if not ssh_host or not ssh_username:
|
||||||
st.info("Enter SSH connection details in the sidebar.")
|
st.info("Enter SSH connection details in the sidebar.")
|
||||||
|
|||||||
@@ -133,8 +133,28 @@ class JellyfinClient:
|
|||||||
"episodes": self.item_count(user_id, "Episode"),
|
"episodes": self.item_count(user_id, "Episode"),
|
||||||
}
|
}
|
||||||
|
|
||||||
def item(self, user_id: str, item_id: str) -> dict[str, Any]:
|
def library_item_counts(self, user_id: str, libraries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
return self.get(f"/Users/{user_id}/Items/{item_id}", Fields=DEFAULT_FIELDS)
|
"""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]]:
|
def active_sessions(self, active_within_seconds: int = 300) -> list[dict[str, Any]]:
|
||||||
"""Return currently active sessions that have a now-playing item."""
|
"""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}"
|
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() {
|
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() {
|
read_mem_pct() {
|
||||||
@@ -84,6 +84,7 @@ read_disk_bytes() {
|
|||||||
set -- $(read_cpu)
|
set -- $(read_cpu)
|
||||||
prev_total="${1:-0}"
|
prev_total="${1:-0}"
|
||||||
prev_idle="${2:-0}"
|
prev_idle="${2:-0}"
|
||||||
|
prev_iowait="${3:-0}"
|
||||||
set -- $(read_net_bytes)
|
set -- $(read_net_bytes)
|
||||||
prev_rx="${1:-0}"
|
prev_rx="${1:-0}"
|
||||||
prev_tx="${2:-0}"
|
prev_tx="${2:-0}"
|
||||||
@@ -115,6 +116,7 @@ while true; do
|
|||||||
set -- $(read_cpu)
|
set -- $(read_cpu)
|
||||||
total="${1:-0}"
|
total="${1:-0}"
|
||||||
idle="${2:-0}"
|
idle="${2:-0}"
|
||||||
|
iowait="${3:-0}"
|
||||||
set -- $(read_net_bytes)
|
set -- $(read_net_bytes)
|
||||||
rx="${1:-0}"
|
rx="${1:-0}"
|
||||||
tx="${2:-0}"
|
tx="${2:-0}"
|
||||||
@@ -125,12 +127,14 @@ while true; do
|
|||||||
|
|
||||||
total_delta=$((total - prev_total))
|
total_delta=$((total - prev_total))
|
||||||
idle_delta=$((idle - prev_idle))
|
idle_delta=$((idle - prev_idle))
|
||||||
|
iowait_delta=$((iowait - prev_iowait))
|
||||||
rx_delta=$((rx - prev_rx))
|
rx_delta=$((rx - prev_rx))
|
||||||
tx_delta=$((tx - prev_tx))
|
tx_delta=$((tx - prev_tx))
|
||||||
disk_read_delta=$((disk_read - prev_disk_read))
|
disk_read_delta=$((disk_read - prev_disk_read))
|
||||||
disk_write_delta=$((disk_write - prev_disk_write))
|
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"}')"
|
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"}')"
|
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"}')"
|
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"}')"
|
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_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"}')"
|
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' \
|
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" "$mem_pct" "$rx_bytes_per_sec" "$tx_bytes_per_sec" "$rx_bps" "$tx_bps" "$disk_read_bps" "$disk_write_bps" >> "$OUT"
|
"$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))
|
sample_count=$((sample_count + 1))
|
||||||
if [ $((sample_count % PRUNE_EVERY_SAMPLES)) -eq 0 ]; then
|
if [ $((sample_count % PRUNE_EVERY_SAMPLES)) -eq 0 ]; then
|
||||||
@@ -148,6 +152,7 @@ while true; do
|
|||||||
|
|
||||||
prev_total="$total"
|
prev_total="$total"
|
||||||
prev_idle="$idle"
|
prev_idle="$idle"
|
||||||
|
prev_iowait="$iowait"
|
||||||
prev_rx="$rx"
|
prev_rx="$rx"
|
||||||
prev_tx="$tx"
|
prev_tx="$tx"
|
||||||
prev_disk_read="$disk_read"
|
prev_disk_read="$disk_read"
|
||||||
|
|||||||
@@ -58,8 +58,9 @@ def format_elapsed(seconds: float | int | None) -> str:
|
|||||||
return f"{hours}h {minutes}m"
|
return f"{hours}h {minutes}m"
|
||||||
|
|
||||||
|
|
||||||
def render_media_overview(cached_media_counts, base_url: str, api_key: str, user_id: str) -> None:
|
def render_media_overview(cached_media_counts, cached_library_counts, base_url: str, api_key: str,
|
||||||
"""Render dashboard counts for movies/series/episodes."""
|
user_id: str) -> None:
|
||||||
|
"""Render dashboard counts for movies/series/episodes and per-library breakdown."""
|
||||||
st.subheader("Media library overview")
|
st.subheader("Media library overview")
|
||||||
try:
|
try:
|
||||||
counts = cached_media_counts(base_url, api_key, user_id)
|
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}")
|
st.warning(f"Could not load Jellyfin media counts: {exc}")
|
||||||
return
|
return
|
||||||
|
|
||||||
cols = st.columns(3)
|
# Top-level totals
|
||||||
cols[0].metric("Movies", f"{counts.get('movies', 0):,}")
|
total_items = counts.get("movies", 0) + counts.get("series", 0) + counts.get("episodes", 0)
|
||||||
cols[1].metric("Series", f"{counts.get('series', 0):,}")
|
top_cols = st.columns(4)
|
||||||
cols[2].metric("Episodes", f"{counts.get('episodes', 0):,}")
|
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:
|
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:
|
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."""
|
"""Render server monitoring summary or detailed charts."""
|
||||||
st.subheader("Server resource details" if detailed else "Server overview")
|
st.subheader("Server monitoring details" if detailed else "Server overview")
|
||||||
host, username, port, key_filename, password = ssh_args
|
host, username, port, key_filename, password = ssh_args
|
||||||
ssh = get_ssh_client(host, username, port, key_filename, password)
|
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:
|
if detailed:
|
||||||
control_col, start_col, restart_col, stop_col, refresh_col = st.columns([2.3, 1, 1, 1, 1])
|
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")
|
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:
|
try:
|
||||||
st.success(start_resource_collector(ssh))
|
st.success(start_resource_collector(ssh))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
st.error(str(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:
|
try:
|
||||||
st.success(restart_resource_collector(ssh))
|
st.success(restart_resource_collector(ssh))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
st.error(str(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:
|
try:
|
||||||
st.info(stop_resource_collector(ssh))
|
st.info(stop_resource_collector(ssh))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
st.error(str(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()
|
st.rerun()
|
||||||
else:
|
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:
|
try:
|
||||||
rows = read_resource_metrics(ssh, max_lines=1000)
|
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[1].metric("Disk available", human_size(space.get("available")))
|
||||||
disk_cols[2].metric("Disk total", human_size(space.get("size")))
|
disk_cols[2].metric("Disk total", human_size(space.get("size")))
|
||||||
disk_cols[3].metric("Used percent", f"{used_pct_value:.0f}%")
|
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:
|
except Exception as exc:
|
||||||
st.warning(f"Could not read disk space for {disk_path}: {exc}")
|
st.warning(f"Could not read disk space for {disk_path}: {exc}")
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
if detailed:
|
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"):
|
with st.expander("Collector diagnostics"):
|
||||||
try:
|
try:
|
||||||
st.code(resource_collector_debug_info(ssh))
|
st.code(resource_collector_debug_info(ssh))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
st.error(f"Could not read collector diagnostics: {exc}")
|
st.error(f"Could not read collector diagnostics: {exc}")
|
||||||
else:
|
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
|
return
|
||||||
|
|
||||||
df = pd.DataFrame(rows)
|
df = pd.DataFrame(rows)
|
||||||
numeric_columns = [
|
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:
|
for column in numeric_columns:
|
||||||
if column in df.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:
|
if all_sample_count:
|
||||||
raw_df = pd.DataFrame(rows)
|
raw_df = pd.DataFrame(rows)
|
||||||
st.write(f"Parsed samples: {all_sample_count}")
|
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)
|
st.dataframe(raw_df.tail(10), use_container_width=True, hide_index=True)
|
||||||
else:
|
else:
|
||||||
st.write("No parseable samples found.")
|
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]
|
latest = df.iloc[-1]
|
||||||
avg_cpu = df["cpu_pct"].mean()
|
avg_cpu = df["cpu_pct"].mean()
|
||||||
max_cpu = df["cpu_pct"].max()
|
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()
|
avg_mem = df["mem_pct"].mean()
|
||||||
max_mem = df["mem_pct"].max()
|
max_mem = df["mem_pct"].max()
|
||||||
avg_net_down = df["net_rx_bytes_per_sec"].mean()
|
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()
|
avg_disk_write = df["disk_write_bps"].mean()
|
||||||
max_disk_write = df["disk_write_bps"].max()
|
max_disk_write = df["disk_write_bps"].max()
|
||||||
|
|
||||||
metric_cols = st.columns(6)
|
metric_cols = st.columns(7)
|
||||||
metric_cols[0].metric("CPU now", f"{latest['cpu_pct']:.1f}%", f"avg {avg_cpu:.1f}% / peak {max_cpu:.1f}%")
|
metric_cols[0].metric("CPU now", f"{latest['cpu_pct']:.1f}%")
|
||||||
metric_cols[1].metric("RAM now", f"{latest['mem_pct']:.1f}%", f"avg {avg_mem:.1f}% / peak {max_mem:.1f}%")
|
metric_cols[0].caption(f"avg {avg_cpu:.1f}% \npeak {max_cpu:.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[1].metric("IO wait", f"{latest.get('iowait_pct', 0):.1f}%")
|
||||||
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[1].caption(f"avg {avg_iowait:.1f}% \npeak {max_iowait:.1f}%")
|
||||||
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[2].metric("RAM now", f"{latest['mem_pct']:.1f}%")
|
||||||
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[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")
|
chart_df = df.set_index("time")
|
||||||
if detailed:
|
if detailed:
|
||||||
st.markdown("**CPU and RAM - last hour**")
|
st.markdown("**CPU, IO wait, and RAM - last hour**")
|
||||||
st.line_chart(chart_df[["cpu_pct", "mem_pct"]], use_container_width=True)
|
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:
|
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
|
return
|
||||||
|
|
||||||
net_down_df, net_down_suffix = scaled_rate_chart_df(chart_df, ["net_rx_bytes_per_sec"], ["download"])
|
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.markdown(f"**Disk write - last hour ({disk_write_suffix})**")
|
||||||
st.line_chart(disk_write_df, use_container_width=True)
|
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)
|
st.dataframe(df.sort_values("time", ascending=False), use_container_width=True, hide_index=True)
|
||||||
|
|||||||
@@ -69,25 +69,15 @@ def render_file_browser(cached_dir_listing: Callable[..., list[dict]], ssh_args:
|
|||||||
status_col.caption(f"Current folder: `{current_dir}`")
|
status_col.caption(f"Current folder: `{current_dir}`")
|
||||||
selected_col.caption(f"Selected path: `{selected}`")
|
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")
|
path_input = path_col.text_input("Remote path", key="file_browser_path_input", label_visibility="collapsed")
|
||||||
requested_path = path_input or "/"
|
requested_path = path_input or "/"
|
||||||
if up_col.button("Up", key="file_browser_up", use_container_width=True):
|
# Navigate when the user edits the path and presses Enter
|
||||||
set_file_browser_path(str(PurePosixPath(current_dir).parent))
|
if requested_path != current_dir:
|
||||||
st.rerun()
|
|
||||||
if go_col.button("Go", key="file_browser_go", use_container_width=True):
|
|
||||||
set_file_browser_path(requested_path)
|
set_file_browser_path(requested_path)
|
||||||
st.rerun()
|
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):
|
if refresh_col.button("Refresh", key="file_browser_refresh", use_container_width=True):
|
||||||
cached_dir_listing.clear()
|
cached_dir_listing.clear()
|
||||||
if requested_path != current_dir:
|
|
||||||
set_file_browser_path(requested_path)
|
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -143,25 +143,26 @@ def render_media_tab(
|
|||||||
selected_media_path = st.session_state.get("media_inventory_selected_path")
|
selected_media_path = st.session_state.get("media_inventory_selected_path")
|
||||||
|
|
||||||
grid_builder = GridOptionsBuilder.from_dataframe(table_df)
|
grid_builder = GridOptionsBuilder.from_dataframe(table_df)
|
||||||
grid_builder.configure_default_column(editable=False, resizable=True, sortable=False, filter=False)
|
grid_builder.configure_default_column(editable=False, resizable=True, sortable=False, filter=False, autoSize=True)
|
||||||
grid_builder.configure_column("title", header_name="Title", flex=2)
|
grid_builder.configure_column("title", header_name="Title", minWidth=150)
|
||||||
grid_builder.configure_column("series", header_name="Series", flex=1.5)
|
grid_builder.configure_column("series", header_name="Series", minWidth=120)
|
||||||
grid_builder.configure_column("season", header_name="Season", width=95)
|
grid_builder.configure_column("season", header_name="Season", maxWidth=95)
|
||||||
grid_builder.configure_column("episode", header_name="Episode", width=105)
|
grid_builder.configure_column("episode", header_name="Episode", maxWidth=105)
|
||||||
grid_builder.configure_column("type", header_name="Type", width=100)
|
grid_builder.configure_column("type", header_name="Type", maxWidth=100)
|
||||||
grid_builder.configure_column("year", header_name="Year", width=90)
|
grid_builder.configure_column("year", header_name="Year", maxWidth=90)
|
||||||
grid_builder.configure_column("runtime_min", header_name="Runtime (min)", width=125)
|
grid_builder.configure_column("runtime_min", header_name="Runtime (min)", maxWidth=125)
|
||||||
grid_builder.configure_column("size", header_name="Size", width=120)
|
grid_builder.configure_column("size", header_name="Size", maxWidth=120)
|
||||||
grid_builder.configure_column("bitrate", header_name="Bitrate", width=125)
|
grid_builder.configure_column("bitrate", header_name="Bitrate", maxWidth=125)
|
||||||
grid_builder.configure_column("hdr", header_name="HDR", width=80)
|
grid_builder.configure_column("hdr", header_name="HDR", maxWidth=80)
|
||||||
grid_builder.configure_column("video", header_name="Video codec", width=120)
|
grid_builder.configure_column("video", header_name="Video codec", maxWidth=120)
|
||||||
grid_builder.configure_column("resolution", header_name="Resolution", width=120)
|
grid_builder.configure_column("resolution", header_name="Resolution", maxWidth=120)
|
||||||
grid_builder.configure_column("date_added", header_name="Date added", width=120)
|
grid_builder.configure_column("date_added", header_name="Date added", maxWidth=120)
|
||||||
grid_builder.configure_column("library", header_name="Library", width=140)
|
grid_builder.configure_column("library", header_name="Library", maxWidth=140)
|
||||||
grid_builder.configure_column("path", header_name="Path", flex=2)
|
grid_builder.configure_column("path", header_name="Path", minWidth=200)
|
||||||
grid_builder.configure_column("id", hide=True)
|
grid_builder.configure_column("id", hide=True)
|
||||||
grid_builder.configure_selection(selection_mode="single", use_checkbox=False)
|
grid_builder.configure_selection(selection_mode="single", use_checkbox=False)
|
||||||
grid_options = grid_builder.build()
|
grid_options = grid_builder.build()
|
||||||
|
grid_options["autoSizeStrategy"] = {"type": "fitCellContents"}
|
||||||
grid_options["rowSelection"] = {
|
grid_options["rowSelection"] = {
|
||||||
"mode": "singleRow",
|
"mode": "singleRow",
|
||||||
"checkboxes": False,
|
"checkboxes": False,
|
||||||
|
|||||||
Reference in New Issue
Block a user