Add FastAPI backend and React frontend subprojects
Backend: - FastAPI app with 17 REST endpoints covering dashboard, monitoring, media index, file browser, and jobs - Reuses existing clients/domain/services unchanged - pydantic-settings config, dependency injection, CORS setup - Auto-generated OpenAPI docs at /docs Frontend: - Vite + React + TypeScript SPA - @tanstack/react-query for data fetching with polling - ag-grid-react for media table and file browser - recharts for monitoring charts - Tailwind CSS styling - 4 pages: Dashboard, Monitoring, Media, File Browser - Typed API client matching all backend endpoints Also: - docs/MIGRATION_PLAN.md with full architecture plan - Updated .gitignore for both subprojects - Streamlit app preserved for now (can coexist)
This commit is contained in:
@@ -0,0 +1,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
|
||||
Reference in New Issue
Block a user