Restructure into backend/ and frontend/ subprojects

- backend/ uses proper Python src layout (src/media_library_viewer_api/)
  with pyproject.toml, hatchling build, and PYTHONPATH=src convention
- frontend/ is a Vite + React + TypeScript SPA
- archive/ preserves the original Streamlit prototype for reference
- Cleaned up root to only contain docs, license, and subproject dirs
- Updated README for the new dual-subproject architecture
This commit is contained in:
2026-04-30 21:48:46 +02:00
parent 3c432473e5
commit 51b10438a9
47 changed files with 127 additions and 130 deletions
@@ -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 media_library_viewer_api.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 media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.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 media_library_viewer_api.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 media_library_viewer_api.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 media_library_viewer_api.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 @@
"""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 media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id
from media_library_viewer_api.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 media_library_viewer_api.dependencies import get_ssh_client
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.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 media_library_viewer_api.dependencies import get_ssh_client
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.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 media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.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 media_library_viewer_api.dependencies import get_ssh_client
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.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 media_library_viewer_api.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 media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.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