From 3c432473e5d80c4cab8b33eb2cf9889bdbea3104 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Thu, 30 Apr 2026 21:40:18 +0200 Subject: [PATCH] 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) --- .gitignore | 8 + README.md | 4 +- backend/README.md | 65 + backend/__init__.py | 0 backend/clients/__init__.py | 0 backend/clients/jellyfin.py | 167 + backend/clients/resources.py | 316 ++ backend/clients/ssh.py | 146 + backend/config.py | 63 + backend/dependencies.py | 47 + backend/domain/__init__.py | 0 backend/domain/media.py | 161 + backend/jobs.py | 59 + backend/main.py | 51 + backend/path_utils.py | 68 + backend/pyproject.toml | 24 + backend/routers/__init__.py | 1 + backend/routers/dashboard.py | 69 + backend/routers/files.py | 67 + backend/routers/jobs.py | 51 + backend/routers/media.py | 84 + backend/routers/monitoring.py | 82 + backend/services/__init__.py | 0 backend/services/media_index.py | 278 ++ backend/utils.py | 227 ++ docs/MIGRATION_PLAN.md | 241 ++ docs/REQUIREMENTS.md | 14 +- frontend/.gitignore | 25 + frontend/README.md | 61 + frontend/eslint.config.js | 22 + frontend/index.html | 13 + frontend/package-lock.json | 3608 +++++++++++++++++ frontend/package.json | 37 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.tsx | 62 + frontend/src/api/client.ts | 116 + frontend/src/components/LibraryOverview.tsx | 55 + frontend/src/components/MetricCard.tsx | 19 + frontend/src/components/MonitoringCharts.tsx | 179 + frontend/src/components/NowPlaying.tsx | 46 + frontend/src/hooks/useDashboard.ts | 26 + frontend/src/hooks/useFiles.ts | 49 + frontend/src/hooks/useMedia.ts | 40 + frontend/src/hooks/useMonitoring.ts | 54 + frontend/src/index.css | 1 + frontend/src/main.tsx | 10 + frontend/src/pages/Dashboard.tsx | 115 + frontend/src/pages/FileBrowser.tsx | 222 + frontend/src/pages/Media.tsx | 210 + frontend/src/pages/Monitoring.tsx | 140 + frontend/src/types/index.ts | 126 + frontend/tsconfig.app.json | 25 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 24 + frontend/vite.config.ts | 12 + src/media_library_viewer/app.py | 98 +- src/media_library_viewer/clients/jellyfin.py | 24 +- src/media_library_viewer/clients/resources.py | 11 +- src/media_library_viewer/ui/dashboard.py | 125 +- src/media_library_viewer/ui/file_browser.py | 16 +- src/media_library_viewer/ui/library.py | 51 - src/media_library_viewer/ui/media.py | 33 +- 63 files changed, 7778 insertions(+), 202 deletions(-) create mode 100644 backend/README.md create mode 100644 backend/__init__.py create mode 100644 backend/clients/__init__.py create mode 100644 backend/clients/jellyfin.py create mode 100644 backend/clients/resources.py create mode 100644 backend/clients/ssh.py create mode 100644 backend/config.py create mode 100644 backend/dependencies.py create mode 100644 backend/domain/__init__.py create mode 100644 backend/domain/media.py create mode 100644 backend/jobs.py create mode 100644 backend/main.py create mode 100644 backend/path_utils.py create mode 100644 backend/pyproject.toml create mode 100644 backend/routers/__init__.py create mode 100644 backend/routers/dashboard.py create mode 100644 backend/routers/files.py create mode 100644 backend/routers/jobs.py create mode 100644 backend/routers/media.py create mode 100644 backend/routers/monitoring.py create mode 100644 backend/services/__init__.py create mode 100644 backend/services/media_index.py create mode 100644 backend/utils.py create mode 100644 docs/MIGRATION_PLAN.md create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/components/LibraryOverview.tsx create mode 100644 frontend/src/components/MetricCard.tsx create mode 100644 frontend/src/components/MonitoringCharts.tsx create mode 100644 frontend/src/components/NowPlaying.tsx create mode 100644 frontend/src/hooks/useDashboard.ts create mode 100644 frontend/src/hooks/useFiles.ts create mode 100644 frontend/src/hooks/useMedia.ts create mode 100644 frontend/src/hooks/useMonitoring.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/FileBrowser.tsx create mode 100644 frontend/src/pages/Media.tsx create mode 100644 frontend/src/pages/Monitoring.tsx create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts delete mode 100644 src/media_library_viewer/ui/library.py diff --git a/.gitignore b/.gitignore index af947c9..d000eae 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,11 @@ env/ # Logs/temp *.log tmp/ + +# Frontend +frontend/node_modules/ +frontend/dist/ + +# Pi internal +.pi-lens/ +.pi/ diff --git a/README.md b/README.md index 36b72e4..f20e36a 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,8 @@ Project policy/docs: - Dashboard media counts for movies, series, and episodes, plus now-playing sessions (user, title, playback state, transcoding) - SQLite-indexed Media tab with full-library sort/filter for runtime, size, bitrate, explicit HDR yes/no flag, date added, codec, resolution, series, season, episode, path, and row-based selection that automatically syncs File browser to the selected item's folder -- SSH resource overview dashboard plus detailed Resources tab for CPU, RAM, network, disk I/O, and disk space +- Server monitoring dashboard plus detailed Monitoring tab for CPU, IO wait, RAM, network, disk I/O, and disk space - Jellyfin API-key connection using `GET /Users` plus user-scoped library endpoints -- Library selection, search, pagination, poster grid -- Item details with Jellyfin metadata and raw JSON - Compact SSH remote directory browser with clickable rows, search, filters, sorting, pagination, and `[UP] ..` navigation - Blocking selected-file `ffprobe` preview for known video files - Separate container, video, audio, and subtitle metadata sections diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..ef81a6e --- /dev/null +++ b/backend/README.md @@ -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 diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/clients/__init__.py b/backend/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/clients/jellyfin.py b/backend/clients/jellyfin.py new file mode 100644 index 0000000..d339389 --- /dev/null +++ b/backend/clients/jellyfin.py @@ -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}" diff --git a/backend/clients/resources.py b/backend/clients/resources.py new file mode 100644 index 0000000..ecf8c6b --- /dev/null +++ b/backend/clients/resources.py @@ -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//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) diff --git a/backend/clients/ssh.py b/backend/clients/ssh.py new file mode 100644 index 0000000..0443b14 --- /dev/null +++ b/backend/clients/ssh.py @@ -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)) diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..0076740 --- /dev/null +++ b/backend/config.py @@ -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() diff --git a/backend/dependencies.py b/backend/dependencies.py new file mode 100644 index 0000000..469ada3 --- /dev/null +++ b/backend/dependencies.py @@ -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"] diff --git a/backend/domain/__init__.py b/backend/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/domain/media.py b/backend/domain/media.py new file mode 100644 index 0000000..42e3f01 --- /dev/null +++ b/backend/domain/media.py @@ -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", ""), + } diff --git a/backend/jobs.py b/backend/jobs.py new file mode 100644 index 0000000..702194a --- /dev/null +++ b/backend/jobs.py @@ -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) diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..eb358bf --- /dev/null +++ b/backend/main.py @@ -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"} diff --git a/backend/path_utils.py b/backend/path_utils.py new file mode 100644 index 0000000..3986f3b --- /dev/null +++ b/backend/path_utils.py @@ -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) diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..3a91f28 --- /dev/null +++ b/backend/pyproject.toml @@ -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"] diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py new file mode 100644 index 0000000..44a55f5 --- /dev/null +++ b/backend/routers/__init__.py @@ -0,0 +1 @@ +"""Routers package.""" diff --git a/backend/routers/dashboard.py b/backend/routers/dashboard.py new file mode 100644 index 0000000..ba4600b --- /dev/null +++ b/backend/routers/dashboard.py @@ -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 diff --git a/backend/routers/files.py b/backend/routers/files.py new file mode 100644 index 0000000..2da03ff --- /dev/null +++ b/backend/routers/files.py @@ -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} diff --git a/backend/routers/jobs.py b/backend/routers/jobs.py new file mode 100644 index 0000000..abe0a15 --- /dev/null +++ b/backend/routers/jobs.py @@ -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, + } diff --git a/backend/routers/media.py b/backend/routers/media.py new file mode 100644 index 0000000..61a74dc --- /dev/null +++ b/backend/routers/media.py @@ -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, + } diff --git a/backend/routers/monitoring.py b/backend/routers/monitoring.py new file mode 100644 index 0000000..a64bf34 --- /dev/null +++ b/backend/routers/monitoring.py @@ -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)} diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/media_index.py b/backend/services/media_index.py new file mode 100644 index 0000000..b238e02 --- /dev/null +++ b/backend/services/media_index.py @@ -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 diff --git a/backend/utils.py b/backend/utils.py new file mode 100644 index 0000000..a7fd412 --- /dev/null +++ b/backend/utils.py @@ -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 diff --git a/docs/MIGRATION_PLAN.md b/docs/MIGRATION_PLAN.md new file mode 100644 index 0000000..a505b9e --- /dev/null +++ b/docs/MIGRATION_PLAN.md @@ -0,0 +1,241 @@ +# Migration Plan: Streamlit → FastAPI + React + +## Overview + +Split the current Streamlit monolith into: + +- **Backend:** FastAPI (Python) serving a REST API +- **Frontend:** React SPA (TypeScript) consuming that API + +The existing `clients/`, `domain/`, `services/`, `jobs.py`, `utils.py`, and `config.py` are already UI-independent and transfer directly to the FastAPI backend with minimal changes. + +--- + +## Current Architecture + +``` +src/media_library_viewer/ +├── app.py # Streamlit orchestration + caching (DELETE) +├── config.py # Env/dotenv config +├── jobs.py # SSH job templates +├── utils.py # Formatting helpers +├── clients/ +│ ├── jellyfin.py # Jellyfin HTTP client +│ ├── resources.py # Remote resource collector +│ └── ssh.py # SSH command execution +├── domain/ +│ └── media.py # Media normalization +├── services/ +│ └── media_index.py # SQLite media index +└── ui/ # Streamlit rendering (DELETE) + ├── dashboard.py + ├── file_browser.py + ├── media.py + └── preview.py +``` + +--- + +## Target Architecture + +``` +repo/ +├── backend/ +│ ├── main.py # FastAPI app + CORS + lifespan +│ ├── config.py # pydantic-settings based config +│ ├── dependencies.py # DI for SSH/Jellyfin clients +│ ├── routers/ +│ │ ├── dashboard.py # /api/dashboard/* +│ │ ├── monitoring.py # /api/monitoring/* +│ │ ├── media.py # /api/media/* +│ │ ├── files.py # /api/files/* +│ │ └── jobs.py # /api/jobs/* +│ ├── clients/ # Copied unchanged +│ │ ├── jellyfin.py +│ │ ├── resources.py +│ │ └── ssh.py +│ ├── domain/ # Copied unchanged +│ │ └── media.py +│ ├── services/ # Copied unchanged +│ │ └── media_index.py +│ ├── jobs.py # Copied unchanged +│ ├── utils.py # Copied unchanged +│ └── pyproject.toml +│ +├── frontend/ +│ ├── package.json +│ ├── vite.config.ts +│ ├── tsconfig.json +│ └── src/ +│ ├── main.tsx +│ ├── App.tsx +│ ├── api/ +│ │ └── client.ts # Typed fetch wrappers +│ ├── hooks/ +│ │ ├── useDashboard.ts +│ │ ├── useMonitoring.ts +│ │ ├── useMedia.ts +│ │ └── useFiles.ts +│ ├── pages/ +│ │ ├── Dashboard.tsx +│ │ ├── Monitoring.tsx +│ │ ├── Media.tsx +│ │ └── FileBrowser.tsx +│ ├── components/ +│ │ ├── NowPlaying.tsx +│ │ ├── MetricCard.tsx +│ │ ├── LibraryOverview.tsx +│ │ ├── MonitoringCharts.tsx +│ │ ├── MediaTable.tsx +│ │ ├── FileListing.tsx +│ │ └── FfprobePreview.tsx +│ └── types/ +│ └── index.ts +│ +├── docker-compose.yml # Optional unified deployment +└── README.md +``` + +--- + +## FastAPI Endpoints + +| Endpoint | Method | Source | Description | +| ------------------------------- | ------ | ---------------------------------------- | -------------------------------- | +| `/api/dashboard/counts` | GET | `jellyfin.media_counts()` | Movie/series/episode totals | +| `/api/dashboard/libraries` | GET | `jellyfin.library_item_counts()` | Per-library breakdown | +| `/api/dashboard/now-playing` | GET | `jellyfin.active_sessions()` | Active sessions + transcode info | +| `/api/monitoring/status` | GET | `resources.resource_collector_status()` | Collector running? | +| `/api/monitoring/metrics` | GET | `resources.read_resource_metrics()` | Last-hour JSONL samples | +| `/api/monitoring/disk` | GET | `resources.disk_space()` | df for media root | +| `/api/monitoring/start` | POST | `resources.start_resource_collector()` | Start collector | +| `/api/monitoring/stop` | POST | `resources.stop_resource_collector()` | Stop collector | +| `/api/monitoring/restart` | POST | `resources.restart_resource_collector()` | Restart collector | +| `/api/media/status` | GET | `MediaIndex.status()` | Index exists/count/age | +| `/api/media/build` | POST | `build_media_index()` | Rebuild index | +| `/api/media/query` | GET | `MediaIndex.query()` | Filtered/sorted/paged query | +| `/api/files/list?path=` | GET | `ssh.list_dir()` | Directory listing | +| `/api/files/ffprobe?path=` | GET | `ssh.ffprobe_json()` | ffprobe JSON | +| `/api/files/stat?path=` | GET | `ssh.stat_path()` | stat output | +| `/api/files/resolve-path?path=` | GET | `resolve_remote_media_path()` | Jellyfin→SSH path mapping | +| `/api/jobs/templates` | GET | `JOB_TEMPLATES` | Available job list | +| `/api/jobs/run` | POST | `run_job()` | Execute a job | + +--- + +## React Frontend + +### Tech Stack + +- **Vite** + React 18+ + TypeScript +- **@tanstack/react-query** — data fetching with polling +- **ag-grid-react** — media table and file browser (same grid lib) +- **recharts** — monitoring line charts +- **react-router** — page navigation +- **tailwindcss** + **shadcn/ui** — styling + +### Page → Component Mapping + +| Page | Components | Polling | +| ------------ | --------------------------------------------------------- | -------------------------------- | +| Dashboard | NowPlaying, MetricCard (server overview), LibraryOverview | 15s (now-playing), 30s (metrics) | +| Monitoring | MonitoringCharts, MetricCard, CollectorControls | 15s | +| Media | MediaTable (AG Grid), filter/sort/page controls | on-demand | +| File Browser | FileListing (AG Grid), FfprobePreview, JobRunner | on-demand | + +### Key Interactions + +- Media row click → updates client-side file browser path state (no API call) +- File browser directory click → fetches `/api/files/list?path=...` +- File browser file click → fetches `/api/files/ffprobe?path=...` +- Path input Enter → navigates directory +- Monitoring charts → recharts line chart from `/api/monitoring/metrics` + +--- + +## What Transfers Unchanged (~1,350 lines) + +| File | Lines | Notes | +| ------------------------- | ----- | ---------------------------- | +| `clients/jellyfin.py` | 167 | Remove unused methods if any | +| `clients/ssh.py` | 146 | No changes | +| `clients/resources.py` | 316 | No changes | +| `domain/media.py` | 161 | No changes | +| `services/media_index.py` | 278 | No changes | +| `jobs.py` | 59 | No changes | +| `utils.py` | 227 | No changes | + +--- + +## What Gets Deleted + +- `src/media_library_viewer/ui/` (all Streamlit rendering) +- `src/media_library_viewer/app.py` (Streamlit orchestration) +- Root `app.py` (Streamlit launcher) +- `streamlit` and `streamlit-aggrid` dependencies + +--- + +## Migration Steps + +### Step 1: Backend scaffold + +- Create `backend/` with FastAPI, pydantic-settings config, DI +- Copy `clients/`, `domain/`, `services/`, `jobs.py`, `utils.py` +- Implement routers wrapping existing functions +- Test with curl + +### Step 2: Frontend scaffold + +- Create `frontend/` with Vite + React + TypeScript +- API client with typed wrappers +- React Query provider + hooks +- Router with 4 pages + +### Step 3: Build pages (one at a time) + +1. Dashboard (simplest — just fetches and displays) +2. Monitoring (charts + controls) +3. Media (AG Grid + filters) +4. File Browser (AG Grid + ffprobe + jobs) + +### Step 4: Validate feature parity + +- Compare behavior side-by-side with Streamlit +- Remove Streamlit code + +--- + +## Effort Estimate + +| Component | Effort | +| ------------------------------------- | ------------- | +| FastAPI backend | 1-2 days | +| React scaffold + routing + API client | 0.5 day | +| Dashboard page | 0.5 day | +| Monitoring page + charts | 1 day | +| Media page + AG Grid | 1 day | +| File browser page | 1 day | +| Polish + testing + deployment | 1-2 days | +| **Total** | **~6-9 days** | + +--- + +## Key Decisions + +- **Polling over WebSocket initially** — simpler, matches current behavior; upgrade path exists for later. +- **Auth deferred** — rely on network access control for now; add API key or JWT later. +- **CORS enabled** — for Vite dev server and production origin. +- **Path resolution stays server-side** — frontend sends Jellyfin paths, backend resolves to SSH paths. +- **Both can coexist** — Streamlit and FastAPI can run simultaneously during transition since they share the same clients/services. + +--- + +## Optional Post-Migration Enhancements + +- WebSocket push for real-time monitoring/now-playing +- JWT authentication +- Background index build with SSE progress +- Dark/light theme +- Persistent user preferences (localStorage + optional backend sync) +- Docker Compose (backend + frontend + nginx reverse proxy) diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 5200601..a88aa03 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -8,7 +8,7 @@ Build a compact Streamlit application for browsing a remote Jellyfin media libra ## Current Phase -Phase 1: Jellyfin library browser plus SSH-based remote filesystem inspection and safe job templates. +Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server monitoring, and safe job templates. ## Core Requirements @@ -91,14 +91,15 @@ Phase 1: Jellyfin library browser plus SSH-based remote filesystem inspection an - Keep a manual `ffprobe` action available for selected paths. - Support `stat` on selected paths. -### Dashboard / Server Resources +### Dashboard / Server Monitoring - Provide a dashboard tab with a compact Jellyfin media library overview and server resource overview. - Show Jellyfin media counts for movies, series, and series episodes on the dashboard. - Show currently playing Jellyfin sessions on the dashboard, including user, media title, playback state, and whether transcoding is active. - Provide a dashboard tab with a compact server resource overview over SSH. -- Provide a separate Resources tab for detailed resource charts, collector controls, diagnostics, and raw samples. +- Provide a separate Monitoring tab for detailed resource charts, collector controls, diagnostics, and raw samples. - Show CPU and RAM usage for the last hour. +- Show IO wait percentage for the last hour. - Show average and spike/peak values for network throughput and disk I/O. - Show used, available, and total disk space for the configured media root, falling back to `/`. - Use a lightweight remote collector that reads Linux `/proc`, `/sys/block`, and `df` data into a JSONL file under `/tmp`. @@ -274,8 +275,8 @@ Local app dependencies are declared in `pyproject.toml`; `requirements.txt` inst - Changed network display units from bits per second to bytes per second to avoid Kbps/KB/s ambiguity; the collector still stores bit-rate compatibility fields for old/debug consumers. - Scaled network and disk throughput charts into readable units such as KB/s, MB/s, and GB/s instead of plotting raw base units. - Updated collector startup to remove old temporary metrics/log files when a new collector process is started after a schema/display change. -- Moved detailed resource charts, raw samples, diagnostics, and collector controls into a dedicated Resources tab; the Dashboard now keeps a compact overview. -- Removed the CPU/RAM chart from the Dashboard and kept detailed charts in the Resources tab. +- Moved detailed resource charts, raw samples, diagnostics, and collector controls into a dedicated Monitoring tab; the Dashboard now keeps a compact overview. +- Removed the CPU/RAM chart from the Dashboard and kept detailed charts in the Monitoring tab. - Renamed the remote files tab to File browser. - Added Jellyfin media counts for movies, series, and episodes to the Dashboard using lightweight count queries. - Added a Dashboard now-playing section sourced from Jellyfin sessions, showing who is currently playing what and whether each session is transcoding. @@ -297,6 +298,9 @@ Local app dependencies are declared in `pyproject.toml`; `requirements.txt` inst - Restored File browser table row selection with AG Grid (single-select), using a table interaction style consistent with the Media tab. - Reintroduced open-on-select behavior in File browser: selecting a directory row (including `[UP] ..`) opens it immediately, while file rows update selected target path. - Refined Media table column presentation with explicit user-friendly headers and null-safe display formatting to keep the grid readable and consistent. +- Renamed Resources tab to Monitoring; added IO wait (iowait) percentage to the collector script, metrics, dashboard summary, and detailed charts. +- Removed the Jellyfin library poster-grid tab and its associated cached API calls and UI module; the Media index tab now covers library browsing needs. +- Simplified File browser navigation: removed Up/Go/Select folder buttons; pressing Enter in the path text input navigates directly. - Added broad inline/module documentation across clients, domain, services, and Streamlit adapter modules to make debugging and future frontend extraction easier. - Simplified the File browser by removing its interactive AG Grid and using a read-only listing with explicit Open/Select controls, reducing cross-tab state interactions with the Media grid. - Removed optional/compatibility code paths around the Media table grid and old file-browser state aliases to keep the interaction model easier to reason about during debugging. diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..752bb7e --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +frontend/.pi-lens/ diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..babb551 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,61 @@ +# Frontend - Media Library Viewer + +React + TypeScript SPA for the Media Library Viewer, consuming the FastAPI backend. + +## Tech Stack + +- **Vite** — Build tool +- **React 18+** — UI framework +- **TypeScript** — Type safety +- **@tanstack/react-query** — Data fetching/caching +- **ag-grid-react** — Data tables (media, file browser) +- **recharts** — Monitoring charts +- **react-router-dom** — Client-side routing +- **Tailwind CSS** — Styling + +## Setup + +```bash +cd frontend +npm install +``` + +## Development + +```bash +npm run dev +``` + +Runs on http://localhost:5173 with API requests proxied to http://localhost:8000. + +Make sure the backend is running: + +```bash +cd ../backend +uvicorn main:app --reload --port 8000 +``` + +## Build + +```bash +npm run build +``` + +Output goes to `frontend/dist/`. + +## Pages + +- **Dashboard** (`/`) — Now playing, server overview, library stats +- **Monitoring** (`/monitoring`) — CPU/IO wait/RAM/network/disk charts, collector controls +- **Media** (`/media`) — Full-library table with sort/filter/search +- **File Browser** (`/files`) — Remote directory browsing, ffprobe preview, jobs + +## Environment Variables + +Create a `.env` file in `frontend/` if the API is not at `http://localhost:8000`: + +```bash +VITE_API_URL=http://your-backend-host:8000 +``` + +In development, the Vite proxy handles `/api` requests automatically. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..ef614d2 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0fca6f0 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..4aea484 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3608 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@tailwindcss/vite": "^4.2.4", + "@tanstack/react-query": "^5.100.6", + "ag-grid-community": "^35.2.1", + "ag-grid-react": "^35.2.1", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "react-router-dom": "^7.14.2", + "recharts": "^3.8.1", + "tailwindcss": "^4.2.4" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.2.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.5.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.2", + "vite": "^8.0.10" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-x64": "4.2.4", + "@tailwindcss/oxide-freebsd-x64": "4.2.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-x64-musl": "4.2.4", + "@tailwindcss/oxide-wasm32-wasi": "4.2.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", + "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", + "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", + "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", + "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", + "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", + "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", + "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", + "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", + "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", + "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", + "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "tailwindcss": "4.2.4" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.100.6", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.6.tgz", + "integrity": "sha512-Os2CPUr98to98RYm+D4qGqGkiffn7MGSyl2547a4MljVkHE30AMJRqTiyCqBfMwzAx/I91vCkAxp5tHSla6Twg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.6", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.6.tgz", + "integrity": "sha512-uVSrps0PV16Cxmcn2rvL+dUhwTpTUtiRW347AEeYxMZXO2pZe9ja7E24PAMGoQ5u2g89DD8u4QhOviBk+RN8RA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", + "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/type-utils": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz", + "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", + "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.1", + "@typescript-eslint/types": "^8.59.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", + "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", + "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", + "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", + "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", + "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.1", + "@typescript-eslint/tsconfig-utils": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", + "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", + "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ag-charts-types": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-13.2.1.tgz", + "integrity": "sha512-r7veb3QqJtIKlXmeUsLR4/oDPwmHxFI2tmbZra/203mdaz3uwQUrrgYNg628nrK+7L2YxXnwGc6L05tWjLLjNQ==", + "license": "MIT" + }, + "node_modules/ag-grid-community": { + "version": "35.2.1", + "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-35.2.1.tgz", + "integrity": "sha512-ycmGI+1EbUT7i3eg/Kgi1owwnkdHXRufo10Xm6cfSsVPM3TMpvlbLgi28KIPt9DGHZWHq9fOBn7nxMNdv1Yaow==", + "license": "MIT", + "dependencies": { + "ag-charts-types": "13.2.1" + } + }, + "node_modules/ag-grid-react": { + "version": "35.2.1", + "resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-35.2.1.tgz", + "integrity": "sha512-UzdU15R6fyGJB+lBKEC458xacGoZged3Ra6Plqa7LvrJ/Mg0tWn1NH01UnuKyGEKPWMEAGvdXruOtOUywsPElA==", + "license": "MIT", + "dependencies": { + "ag-grid-community": "35.2.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.24", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.24.tgz", + "integrity": "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.345", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.345.tgz", + "integrity": "sha512-F9JXQGiMrz6yVNPI2qOVPvB9HzjH5cGzhs8oJ6A28V5L/YnzN/0KsuiibqF+F1Fd9qxFzD1BUnYSd8JfULxTwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz", + "integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", + "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-is": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", + "integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz", + "integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.1.tgz", + "integrity": "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.1", + "@typescript-eslint/parser": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.1.tgz", + "integrity": "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..37a2967 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,37 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@tailwindcss/vite": "^4.2.4", + "@tanstack/react-query": "^5.100.6", + "ag-grid-community": "^35.2.1", + "ag-grid-react": "^35.2.1", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "react-router-dom": "^7.14.2", + "recharts": "^3.8.1", + "tailwindcss": "^4.2.4" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.2.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.5.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.2", + "vite": "^8.0.10" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..8340802 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,62 @@ +import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Dashboard } from "./pages/Dashboard"; +import { Monitoring } from "./pages/Monitoring"; +import { Media } from "./pages/Media"; +import { FileBrowser } from "./pages/FileBrowser"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); + +const navLinks = [ + { to: "/", label: "Dashboard" }, + { to: "/monitoring", label: "Monitoring" }, + { to: "/media", label: "Media" }, + { to: "/files", label: "File Browser" }, +]; + +function NavBar() { + return ( + + ); +} + +export default function App() { + return ( + + +
+ +
+ + } /> + } /> + } /> + } /> + +
+
+
+
+ ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..6077716 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,116 @@ +/** + * Typed API client for the FastAPI backend. + */ + +import type { + MediaCounts, + LibraryCount, + NowPlayingSession, + MonitoringStatus, + MonitoringMetrics, + DiskSpace, + MediaIndexStatus, + MediaQueryResponse, + DirectoryListing, + JobTemplate, + JobResult, + ResolvedPath, +} from "../types"; + +const BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8000"; + +async function get( + path: string, + params?: Record, +): Promise { + const url = new URL(path, BASE_URL); + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== "") url.searchParams.set(key, value); + }); + } + const response = await fetch(url.toString()); + if (!response.ok) { + const detail = await response.text(); + throw new Error(`${response.status}: ${detail}`); + } + return response.json(); +} + +async function post(path: string, body?: unknown): Promise { + const url = new URL(path, BASE_URL); + const response = await fetch(url.toString(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!response.ok) { + const detail = await response.text(); + throw new Error(`${response.status}: ${detail}`); + } + return response.json(); +} + +// Dashboard +export const fetchCounts = () => get("/api/dashboard/counts"); +export const fetchLibraries = () => + get("/api/dashboard/libraries"); +export const fetchNowPlaying = () => + get("/api/dashboard/now-playing"); + +// Monitoring +export const fetchMonitoringStatus = () => + get("/api/monitoring/status"); +export const fetchMonitoringMetrics = (lastSeconds = 3600) => + get("/api/monitoring/metrics", { + last_seconds: String(lastSeconds), + }); +export const fetchDiskSpace = () => get("/api/monitoring/disk"); +export const startCollector = () => + post<{ message: string }>("/api/monitoring/start"); +export const stopCollector = () => + post<{ message: string }>("/api/monitoring/stop"); +export const restartCollector = () => + post<{ message: string }>("/api/monitoring/restart"); + +// Media +export const fetchMediaStatus = () => + get("/api/media/status"); +export const buildMediaIndex = () => + post<{ indexed_items: number }>("/api/media/build"); +export const queryMedia = (params: { + libraries?: string; + types?: string; + search?: string; + hdr_filter?: string; + sort_key?: string; + sort_order?: string; + limit?: number; + offset?: number; +}) => + get("/api/media/query", { + libraries: params.libraries || "", + types: params.types || "Movie,Episode", + search: params.search || "", + hdr_filter: params.hdr_filter || "All", + sort_key: params.sort_key || "title", + sort_order: params.sort_order || "Ascending", + limit: String(params.limit || 100), + offset: String(params.offset || 0), + }); + +// Files +export const fetchDirectoryListing = (path: string) => + get("/api/files/list", { path }); +export const fetchFfprobe = (path: string) => + get>("/api/files/ffprobe", { path }); +export const fetchStat = (path: string) => + get<{ path: string; output: string }>("/api/files/stat", { path }); +export const resolvePath = (path: string) => + get("/api/files/resolve-path", { path }); + +// Jobs +export const fetchJobTemplates = () => + get("/api/jobs/templates"); +export const runJob = (jobKey: string, path: string) => + post("/api/jobs/run", { job_key: jobKey, path }); diff --git a/frontend/src/components/LibraryOverview.tsx b/frontend/src/components/LibraryOverview.tsx new file mode 100644 index 0000000..3873590 --- /dev/null +++ b/frontend/src/components/LibraryOverview.tsx @@ -0,0 +1,55 @@ +import type { LibraryCount } from "../types"; + +interface Props { + libraries: LibraryCount[]; +} + +export function LibraryOverview({ libraries }: Props) { + const movieLibs = libraries.filter((l) => l.type === "movies"); + const tvLibs = libraries.filter((l) => l.type === "tvshows"); + + return ( +
+ {movieLibs.length > 0 && ( +
+

+ Movie libraries +

+ {movieLibs.map((lib) => ( +
+

{lib.library}

+
+ + Total: {lib.total.toLocaleString()} + + + Movies: {lib.movies.toLocaleString()} + +
+
+ ))} +
+ )} + {tvLibs.length > 0 && ( +
+

+ TV libraries +

+ {tvLibs.map((lib) => ( +
+

{lib.library}

+
+ + Total: {lib.total.toLocaleString()} + + + Series: {lib.series.toLocaleString()} + +
+
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/MetricCard.tsx b/frontend/src/components/MetricCard.tsx new file mode 100644 index 0000000..6c80e02 --- /dev/null +++ b/frontend/src/components/MetricCard.tsx @@ -0,0 +1,19 @@ +interface Props { + label: string; + value: string; + subtext?: string; +} + +export function MetricCard({ label, value, subtext }: Props) { + return ( +
+

{label}

+

{value}

+ {subtext && ( +

+ {subtext} +

+ )} +
+ ); +} diff --git a/frontend/src/components/MonitoringCharts.tsx b/frontend/src/components/MonitoringCharts.tsx new file mode 100644 index 0000000..e4951c7 --- /dev/null +++ b/frontend/src/components/MonitoringCharts.tsx @@ -0,0 +1,179 @@ +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; +import type { MonitoringSample } from "../types"; + +interface Props { + samples: MonitoringSample[]; +} + +function formatTime(ts: number) { + return new Date(ts * 1000).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B/s"; + const units = ["B/s", "KB/s", "MB/s", "GB/s"]; + let value = bytes; + let unitIdx = 0; + while (value >= 1000 && unitIdx < units.length - 1) { + value /= 1000; + unitIdx++; + } + return `${value.toFixed(1)} ${units[unitIdx]}`; +} + +export function MonitoringCharts({ samples }: Props) { + if (samples.length === 0) { + return ( +

No monitoring samples available.

+ ); + } + + const data = samples.map((s) => ({ + time: formatTime(s.ts), + ts: s.ts, + cpu: s.cpu_pct, + iowait: s.iowait_pct ?? 0, + mem: s.mem_pct, + net_down: s.net_rx_bytes_per_sec, + net_up: s.net_tx_bytes_per_sec, + disk_read: s.disk_read_bps, + disk_write: s.disk_write_bps, + })); + + return ( +
+
+

+ CPU, IO Wait, and RAM - last hour +

+ + + + + + + + + + + + +
+ +
+
+

Network download

+ + + + + formatBytes(v)} /> + formatBytes(Number(v))} /> + + + +
+
+

Network upload

+ + + + + formatBytes(v)} /> + formatBytes(Number(v))} /> + + + +
+
+ +
+
+

Disk read

+ + + + + formatBytes(v)} /> + formatBytes(Number(v))} /> + + + +
+
+

Disk write

+ + + + + formatBytes(v)} /> + formatBytes(Number(v))} /> + + + +
+
+
+ ); +} diff --git a/frontend/src/components/NowPlaying.tsx b/frontend/src/components/NowPlaying.tsx new file mode 100644 index 0000000..f3fa952 --- /dev/null +++ b/frontend/src/components/NowPlaying.tsx @@ -0,0 +1,46 @@ +import type { NowPlayingSession } from "../types"; + +interface Props { + sessions: NowPlayingSession[]; +} + +export function NowPlaying({ sessions }: Props) { + if (sessions.length === 0) { + return ( +

+ No active playback sessions right now. +

+ ); + } + + return ( +
+ + + + + + + + + + + + + + {sessions.map((s) => ( + + + + + + + + + + ))} + +
UserTitleTypeStateTranscodingTranscode typeDevice
{s.user}{s.title}{s.type}{s.state}{s.transcoding}{s.transcoding_type}{s.device}
+
+ ); +} diff --git a/frontend/src/hooks/useDashboard.ts b/frontend/src/hooks/useDashboard.ts new file mode 100644 index 0000000..d2bf21c --- /dev/null +++ b/frontend/src/hooks/useDashboard.ts @@ -0,0 +1,26 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchCounts, fetchLibraries, fetchNowPlaying } from "../api/client"; + +export function useCounts() { + return useQuery({ + queryKey: ["dashboard", "counts"], + queryFn: fetchCounts, + staleTime: 5 * 60 * 1000, + }); +} + +export function useLibraries() { + return useQuery({ + queryKey: ["dashboard", "libraries"], + queryFn: fetchLibraries, + staleTime: 5 * 60 * 1000, + }); +} + +export function useNowPlaying() { + return useQuery({ + queryKey: ["dashboard", "now-playing"], + queryFn: fetchNowPlaying, + refetchInterval: 15_000, + }); +} diff --git a/frontend/src/hooks/useFiles.ts b/frontend/src/hooks/useFiles.ts new file mode 100644 index 0000000..8f7764e --- /dev/null +++ b/frontend/src/hooks/useFiles.ts @@ -0,0 +1,49 @@ +import { useQuery, useMutation } from "@tanstack/react-query"; +import { + fetchDirectoryListing, + fetchFfprobe, + fetchStat, + fetchJobTemplates, + runJob, +} from "../api/client"; + +export function useDirectoryListing(path: string) { + return useQuery({ + queryKey: ["files", "list", path], + queryFn: () => fetchDirectoryListing(path), + enabled: !!path, + staleTime: 30_000, + }); +} + +export function useFfprobe(path: string, enabled = false) { + return useQuery({ + queryKey: ["files", "ffprobe", path], + queryFn: () => fetchFfprobe(path), + enabled: enabled && !!path, + staleTime: 5 * 60_000, + }); +} + +export function useStat(path: string, enabled = false) { + return useQuery({ + queryKey: ["files", "stat", path], + queryFn: () => fetchStat(path), + enabled: enabled && !!path, + }); +} + +export function useJobTemplates() { + return useQuery({ + queryKey: ["jobs", "templates"], + queryFn: fetchJobTemplates, + staleTime: 60_000, + }); +} + +export function useRunJob() { + return useMutation({ + mutationFn: ({ jobKey, path }: { jobKey: string; path: string }) => + runJob(jobKey, path), + }); +} diff --git a/frontend/src/hooks/useMedia.ts b/frontend/src/hooks/useMedia.ts new file mode 100644 index 0000000..5157c14 --- /dev/null +++ b/frontend/src/hooks/useMedia.ts @@ -0,0 +1,40 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { fetchMediaStatus, buildMediaIndex, queryMedia } from "../api/client"; + +export function useMediaStatus() { + return useQuery({ + queryKey: ["media", "status"], + queryFn: fetchMediaStatus, + staleTime: 60_000, + }); +} + +export function useMediaQuery(params: { + libraries?: string; + types?: string; + search?: string; + hdr_filter?: string; + sort_key?: string; + sort_order?: string; + limit?: number; + offset?: number; + enabled?: boolean; +}) { + const { enabled = true, ...queryParams } = params; + return useQuery({ + queryKey: ["media", "query", queryParams], + queryFn: () => queryMedia(queryParams), + enabled, + staleTime: 30_000, + }); +} + +export function useBuildIndex() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: buildMediaIndex, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["media"] }); + }, + }); +} diff --git a/frontend/src/hooks/useMonitoring.ts b/frontend/src/hooks/useMonitoring.ts new file mode 100644 index 0000000..19ee1c9 --- /dev/null +++ b/frontend/src/hooks/useMonitoring.ts @@ -0,0 +1,54 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + fetchMonitoringStatus, + fetchMonitoringMetrics, + fetchDiskSpace, + startCollector, + stopCollector, + restartCollector, +} from "../api/client"; + +export function useMonitoringStatus() { + return useQuery({ + queryKey: ["monitoring", "status"], + queryFn: fetchMonitoringStatus, + refetchInterval: 30_000, + }); +} + +export function useMonitoringMetrics(lastSeconds = 3600) { + return useQuery({ + queryKey: ["monitoring", "metrics", lastSeconds], + queryFn: () => fetchMonitoringMetrics(lastSeconds), + refetchInterval: 15_000, + }); +} + +export function useDiskSpace() { + return useQuery({ + queryKey: ["monitoring", "disk"], + queryFn: fetchDiskSpace, + staleTime: 60_000, + }); +} + +export function useCollectorControls() { + const queryClient = useQueryClient(); + const invalidate = () => + queryClient.invalidateQueries({ queryKey: ["monitoring"] }); + + const start = useMutation({ + mutationFn: startCollector, + onSuccess: invalidate, + }); + const stop = useMutation({ + mutationFn: stopCollector, + onSuccess: invalidate, + }); + const restart = useMutation({ + mutationFn: restartCollector, + onSuccess: invalidate, + }); + + return { start, stop, restart }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..0ae8e52 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./index.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..645ce43 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,115 @@ +import { useCounts, useLibraries, useNowPlaying } from "../hooks/useDashboard"; +import { useMonitoringMetrics, useDiskSpace } from "../hooks/useMonitoring"; +import { NowPlaying } from "../components/NowPlaying"; +import { MetricCard } from "../components/MetricCard"; +import { LibraryOverview } from "../components/LibraryOverview"; + +function formatBytes(bytes: number): string { + if (!bytes || bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let value = bytes; + let unitIdx = 0; + while (value >= 1000 && unitIdx < units.length - 1) { + value /= 1000; + unitIdx++; + } + return `${value.toFixed(1)} ${units[unitIdx]}`; +} + +function formatRate(bytes: number): string { + return `${formatBytes(bytes)}/s`; +} + +export function Dashboard() { + const { data: counts } = useCounts(); + const { data: libraries } = useLibraries(); + const { data: nowPlaying } = useNowPlaying(); + const { data: metrics } = useMonitoringMetrics(); + const { data: disk } = useDiskSpace(); + + const latest = metrics?.samples?.at(-1); + + return ( +
+ {/* Now Playing */} +
+

Now playing

+ {nowPlaying && } +
+ +
+ + {/* Server Overview */} +
+

Server overview

+
+ + + + + + + +
+ {disk && ( +
+ + + + +
+ )} +
+ +
+ + {/* Media Library Overview */} +
+

Media library overview

+ {counts && ( +
+ + + + +
+ )} + {libraries && } +
+
+ ); +} diff --git a/frontend/src/pages/FileBrowser.tsx b/frontend/src/pages/FileBrowser.tsx new file mode 100644 index 0000000..665abf4 --- /dev/null +++ b/frontend/src/pages/FileBrowser.tsx @@ -0,0 +1,222 @@ +import { useState, useCallback, useRef } from "react"; +import { AgGridReact } from "ag-grid-react"; +import { + useDirectoryListing, + useFfprobe, + useJobTemplates, + useRunJob, +} from "../hooks/useFiles"; + +interface DisplayRow { + type: string; + name: string; + ext: string; + size: string; + modified: string; + path: string; +} + +function formatSize(bytes: number): string { + if (bytes === 0) return "-"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let value = bytes; + let unitIdx = 0; + while (value >= 1000 && unitIdx < units.length - 1) { + value /= 1000; + unitIdx++; + } + return `${value.toFixed(1)} ${units[unitIdx]}`; +} + +function formatTime(epoch: number): string { + if (!epoch) return ""; + return new Date(epoch * 1000).toLocaleString(); +} + +function isVideoFile(name: string): boolean { + const exts = [ + ".mkv", + ".mp4", + ".avi", + ".m4v", + ".ts", + ".wmv", + ".mov", + ".flv", + ".webm", + ]; + return exts.some((ext) => name.toLowerCase().endsWith(ext)); +} + +export function FileBrowser() { + const [currentDir, setCurrentDir] = useState("/"); + const [pathInput, setPathInput] = useState("/"); + const [selectedPath, setSelectedPath] = useState(null); + + const { data: listing, isLoading, error } = useDirectoryListing(currentDir); + const { data: ffprobeData } = useFfprobe( + selectedPath ?? "", + !!selectedPath && isVideoFile(selectedPath), + ); + const { data: templates } = useJobTemplates(); + const runJob = useRunJob(); + + const gridRef = useRef>(null); + + const navigate = useCallback((path: string) => { + setCurrentDir(path); + setPathInput(path); + setSelectedPath(null); + }, []); + + const handlePathSubmit = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + navigate(pathInput || "/"); + } + }; + + // Build display rows + const rows: DisplayRow[] = []; + if (currentDir !== "/") { + const parent = currentDir.replace(/\/[^/]+\/?$/, "") || "/"; + rows.push({ + type: "up", + name: "..", + ext: "", + size: "-", + modified: "", + path: parent, + }); + } + if (listing) { + for (const entry of listing.entries) { + const kind = entry.type === "d" ? "dir" : "file"; + const ext = kind === "file" ? (entry.name.split(".").pop() ?? "") : ""; + rows.push({ + type: kind, + name: entry.name, + ext, + size: kind === "dir" ? "-" : formatSize(entry.size), + modified: formatTime(entry.mtime), + path: `${currentDir === "/" ? "" : currentDir}/${entry.name}`, + }); + } + } + + const columnDefs = [ + { field: "type" as const, headerName: "Type", width: 80 }, + { field: "name" as const, headerName: "Name", flex: 2 }, + { field: "ext" as const, headerName: "Ext", width: 80 }, + { field: "size" as const, headerName: "Size", width: 110 }, + { field: "modified" as const, headerName: "Modified", width: 180 }, + ]; + + const onRowClicked = useCallback( + (event: { data?: DisplayRow }) => { + const row = event.data; + if (!row) return; + if (row.type === "dir" || row.type === "up") { + navigate(row.path); + } else { + setSelectedPath(row.path); + } + }, + [navigate], + ); + + return ( +
+ {/* Path input */} +
+ setPathInput(e.target.value)} + onKeyDown={handlePathSubmit} + className="border rounded px-3 py-1 text-sm flex-1" + placeholder="Remote path (press Enter to navigate)" + /> + +
+ + {/* Status */} +
+ + Current: {currentDir} + + {selectedPath && ( + + Selected: {selectedPath} + + )} + {listing && Entries: {listing.count}} +
+ + {error &&

Error: {String(error)}

} + + {/* File listing grid */} +
+ + ref={gridRef} + rowData={rows} + columnDefs={columnDefs} + rowSelection="single" + onRowClicked={onRowClicked} + loading={isLoading} + suppressCellFocus + animateRows={false} + /> +
+ + {/* ffprobe preview */} + {selectedPath && isVideoFile(selectedPath) && ( +
+

+ ffprobe preview: {selectedPath} +

+ {ffprobeData ? ( +
+							{JSON.stringify(ffprobeData, null, 2)}
+						
+ ) : ( +

Loading ffprobe data...

+ )} +
+ )} + + {/* Jobs */} + {selectedPath && templates && templates.length > 0 && ( +
+

Jobs

+
+ {templates.map((tpl) => ( + + ))} +
+ {runJob.data && ( +
+							Exit: {runJob.data.exit_status}
+							{"\n"}
+							{runJob.data.stdout}
+							{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
+						
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/Media.tsx b/frontend/src/pages/Media.tsx new file mode 100644 index 0000000..06a10cf --- /dev/null +++ b/frontend/src/pages/Media.tsx @@ -0,0 +1,210 @@ +import { useState, useCallback, useRef } from "react"; +import { AgGridReact } from "ag-grid-react"; +import { + useMediaStatus, + useMediaQuery, + useBuildIndex, +} from "../hooks/useMedia"; +import type { MediaItem } from "../types"; + +export function Media() { + const { data: status } = useMediaStatus(); + const buildIndex = useBuildIndex(); + + const [search, setSearch] = useState(""); + const [types, setTypes] = useState("Movie,Episode"); + const [hdrFilter, setHdrFilter] = useState("All"); + const [sortKey, setSortKey] = useState("title"); + const [sortOrder, setSortOrder] = useState("Ascending"); + const [limit] = useState(100); + const [offset, setOffset] = useState(0); + + const { data: queryResult, isLoading } = useMediaQuery({ + types, + search, + hdr_filter: hdrFilter, + sort_key: sortKey, + sort_order: sortOrder, + limit, + offset, + enabled: status?.exists ?? false, + }); + + const gridRef = useRef>(null); + + const columnDefs = [ + { field: "title" as const, headerName: "Title", minWidth: 150 }, + { field: "series" as const, headerName: "Series", minWidth: 120 }, + { field: "season" as const, headerName: "Season", maxWidth: 95 }, + { field: "episode" as const, headerName: "Episode", maxWidth: 105 }, + { field: "type" as const, headerName: "Type", maxWidth: 100 }, + { field: "year" as const, headerName: "Year", maxWidth: 90 }, + { + field: "runtime_min" as const, + headerName: "Runtime (min)", + maxWidth: 125, + }, + { field: "size" as const, headerName: "Size", maxWidth: 120 }, + { field: "bitrate" as const, headerName: "Bitrate", maxWidth: 125 }, + { field: "hdr" as const, headerName: "HDR", maxWidth: 80 }, + { field: "video" as const, headerName: "Video codec", maxWidth: 120 }, + { field: "resolution" as const, headerName: "Resolution", maxWidth: 120 }, + { field: "date_added" as const, headerName: "Date added", maxWidth: 120 }, + { field: "library" as const, headerName: "Library", maxWidth: 140 }, + { field: "path" as const, headerName: "Path", minWidth: 200 }, + ]; + + const onGridReady = useCallback(() => { + gridRef.current?.api?.sizeColumnsToFit(); + }, []); + + const page = Math.floor(offset / limit) + 1; + const totalPages = queryResult ? Math.ceil(queryResult.total / limit) : 1; + + return ( +
+ {/* Status and controls */} +
+ {status?.exists ? ( + + Index: {status.item_count.toLocaleString()} items + {status.updated_at_label && ` | updated ${status.updated_at_label}`} + + ) : ( + No index built yet. + )} + +
+ + {/* Filters */} +
+
+ + { + setSearch(e.target.value); + setOffset(0); + }} + className="border rounded px-2 py-1 text-sm w-48" + placeholder="Search title, series, path..." + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + {/* Results info */} + {queryResult && ( +

+ Showing {queryResult.items.length} of{" "} + {queryResult.total.toLocaleString()} items | Page {page} of{" "} + {totalPages} +

+ )} + + {/* AG Grid table */} + {status?.exists && ( +
+ + ref={gridRef} + rowData={queryResult?.items ?? []} + columnDefs={columnDefs} + rowSelection="single" + onGridReady={onGridReady} + loading={isLoading} + suppressCellFocus + animateRows={false} + /> +
+ )} + + {/* Pagination */} + {queryResult && totalPages > 1 && ( +
+ + + Page {page} / {totalPages} + + +
+ )} +
+ ); +} diff --git a/frontend/src/pages/Monitoring.tsx b/frontend/src/pages/Monitoring.tsx new file mode 100644 index 0000000..e8eff18 --- /dev/null +++ b/frontend/src/pages/Monitoring.tsx @@ -0,0 +1,140 @@ +import { + useMonitoringStatus, + useMonitoringMetrics, + useDiskSpace, + useCollectorControls, +} from "../hooks/useMonitoring"; +import { MetricCard } from "../components/MetricCard"; +import { MonitoringCharts } from "../components/MonitoringCharts"; + +function formatBytes(bytes: number): string { + if (!bytes || bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let value = bytes; + let unitIdx = 0; + while (value >= 1000 && unitIdx < units.length - 1) { + value /= 1000; + unitIdx++; + } + return `${value.toFixed(1)} ${units[unitIdx]}`; +} + +function formatRate(bytes: number): string { + return `${formatBytes(bytes)}/s`; +} + +export function Monitoring() { + const { data: status } = useMonitoringStatus(); + const { data: metrics } = useMonitoringMetrics(); + const { data: disk } = useDiskSpace(); + const { start, stop, restart } = useCollectorControls(); + + const samples = metrics?.samples ?? []; + const latest = samples.at(-1); + + // Compute averages and peaks + const avg = (arr: number[]) => + arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0; + const max = (arr: number[]) => (arr.length ? Math.max(...arr) : 0); + + const cpuArr = samples.map((s) => s.cpu_pct); + const iowArr = samples.map((s) => s.iowait_pct ?? 0); + const memArr = samples.map((s) => s.mem_pct); + const netDownArr = samples.map((s) => s.net_rx_bytes_per_sec); + const netUpArr = samples.map((s) => s.net_tx_bytes_per_sec); + const diskReadArr = samples.map((s) => s.disk_read_bps); + const diskWriteArr = samples.map((s) => s.disk_write_bps); + + return ( +
+ {/* Controls */} +
+ + Collector:{" "} + + {status?.status ?? "unknown"} + + + + + +
+ + {/* Metrics summary */} +
+
+ + + + + + + +
+
+ + {/* Disk space */} + {disk && ( +
+
+ + + + +
+
+ )} + + {/* Charts */} +
+ +
+
+ ); +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..cf4ceee --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,126 @@ +/** + * Shared TypeScript interfaces matching backend API responses. + */ + +export interface MediaCounts { + movies: number; + series: number; + episodes: number; +} + +export interface LibraryCount { + library: string; + type: string; + movies: number; + series: number; + episodes: number; + total: number; +} + +export interface NowPlayingSession { + user: string; + title: string; + type: string; + state: string; + transcoding: string; + transcoding_type: string; + device: string; + session_id: string; +} + +export interface MonitoringStatus { + status: string; +} + +export interface MonitoringSample { + ts: number; + cpu_pct: number; + iowait_pct?: number; + mem_pct: number; + net_rx_bytes_per_sec: number; + net_tx_bytes_per_sec: number; + disk_read_bps: number; + disk_write_bps: number; +} + +export interface MonitoringMetrics { + samples: MonitoringSample[]; + total_samples: number; + filtered_samples: number; + cutoff_ts: number; +} + +export interface DiskSpace { + filesystem: string; + size: number; + used: number; + available: number; + used_pct: string; + mount: string; +} + +export interface MediaIndexStatus { + exists: boolean; + item_count: number; + updated_at: number | null; + updated_at_label: string; + build_duration_seconds: number | null; +} + +export interface MediaItem { + id: string; + title: string; + series: string; + season: string; + episode: number | null; + type: string; + year: number | null; + runtime_min: number | null; + size: string; + bitrate: string; + hdr: string; + video: string; + resolution: string; + date_added: string; + library: string; + path: string; +} + +export interface MediaQueryResponse { + items: MediaItem[]; + total: number; + limit: number; + offset: number; +} + +export interface FileEntry { + type: string; + size: number; + mtime: number; + name: string; +} + +export interface DirectoryListing { + path: string; + entries: FileEntry[]; + count: number; +} + +export interface JobTemplate { + key: string; + name: string; + description: string; +} + +export interface JobResult { + job_key: string; + path: string; + exit_status: number; + stdout: string; + stderr: string; +} + +export interface ResolvedPath { + original: string; + resolved: string; +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..7f42e5f --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..d3c52ea --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..cf7cbd3 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + proxy: { + "/api": "http://localhost:8000", + }, + }, +}); diff --git a/src/media_library_viewer/app.py b/src/media_library_viewer/app.py index 428ae3a..62af4d4 100644 --- a/src/media_library_viewer/app.py +++ b/src/media_library_viewer/app.py @@ -15,7 +15,6 @@ from __future__ import annotations import json import posixpath -from pathlib import PurePosixPath from typing import Any import streamlit as st @@ -25,12 +24,9 @@ from media_library_viewer.clients.ssh import RemoteSSHClient from media_library_viewer.config import load_config from media_library_viewer.ui.dashboard import render_media_overview, render_now_playing, render_resource_dashboard from media_library_viewer.ui.file_browser import render_file_browser, set_file_browser_path -from media_library_viewer.ui.library import show_item_card, show_item_detail from media_library_viewer.ui.media import render_media_tab from media_library_viewer.ui.preview import render_ssh_tools -st.set_page_config(page_title="Media Library Viewer", layout="wide") - @st.cache_resource(show_spinner=False) def get_jellyfin_client(base_url: str, api_key: str) -> JellyfinClient: @@ -64,44 +60,20 @@ def cached_libraries(base_url: str, api_key: str, user_id: str): return get_jellyfin_client(base_url, api_key).libraries(user_id) -@st.cache_data(ttl=60, show_spinner=False) -def cached_items( - base_url: str, - api_key: str, - user_id: str, - parent_id: str | None, - search: str, - media_types: str, - start: int, - limit: int, - sort_by: str = "SortName", - sort_order: str = "Ascending", -): - """Cache paginated Jellyfin item list requests.""" - return get_jellyfin_client(base_url, api_key).items( - user_id=user_id, - parent_id=parent_id, - search=search, - include_item_types=media_types, - start_index=start, - limit=limit, - sort_by=sort_by, - sort_order=sort_order, - ) - - -@st.cache_data(ttl=60, show_spinner=False) -def cached_item(base_url: str, api_key: str, user_id: str, item_id: str): - """Cache a single Jellyfin item detail payload.""" - return get_jellyfin_client(base_url, api_key).item(user_id, item_id) - - @st.cache_data(ttl=300, show_spinner=False) def cached_media_counts(base_url: str, api_key: str, user_id: str): """Cache dashboard-level media counts for movies/series/episodes.""" return get_jellyfin_client(base_url, api_key).media_counts(user_id) +@st.cache_data(ttl=300, show_spinner=False) +def cached_library_counts(base_url: str, api_key: str, user_id: str): + """Cache per-library item counts for dashboard breakdown.""" + client = get_jellyfin_client(base_url, api_key) + libraries = client.libraries(user_id) + return client.library_item_counts(user_id, libraries) + + @st.cache_data(ttl=15, show_spinner=False) def cached_active_sessions(base_url: str, api_key: str): """Cache active Jellyfin sessions briefly for dashboard now-playing status.""" @@ -226,6 +198,10 @@ def credentials_panel(): def main(): """Application entrypoint used by the root ``app.py`` wrapper.""" + # Set page config per Streamlit session. Doing this at module import time is + # unreliable here because the root wrapper imports ``main`` from this module, + # and Python may reuse the already-imported module on browser reloads. + st.set_page_config(page_title="Media Library Viewer", layout="wide") st.title("Media Library Viewer") st.caption("Phase 1: Jellyfin browser + SSH filesystem inspection + safe remote job templates") @@ -253,22 +229,22 @@ def main(): selected_user = st.selectbox("Jellyfin user", list(user_options.keys())) user_id = user_options[selected_user] - tab_dashboard, tab_resources, tab_media, tab_library, tab_files = st.tabs( - ["Dashboard", "Resources", "Media", "Jellyfin library", "File browser"] + tab_dashboard, tab_monitoring, tab_media, tab_files = st.tabs( + ["Dashboard", "Monitoring", "Media", "File browser"] ) with tab_dashboard: - render_media_overview(cached_media_counts, jellyfin_url, jellyfin_api_key, user_id) - st.divider() render_now_playing(cached_active_sessions, jellyfin_url, jellyfin_api_key) st.divider() if not ssh_host or not ssh_username: - st.info("Enter SSH connection details in the sidebar for server resource overview.") + st.info("Enter SSH connection details in the sidebar for server monitoring overview.") else: ssh_args = (ssh_host, ssh_username, ssh_port, ssh_key, ssh_password) render_resource_dashboard(get_ssh_client, ssh_args, media_root or "/", detailed=False) + st.divider() + render_media_overview(cached_media_counts, cached_library_counts, jellyfin_url, jellyfin_api_key, user_id) - with tab_resources: + with tab_monitoring: if not ssh_host or not ssh_username: st.info("Enter SSH connection details in the sidebar.") else: @@ -287,44 +263,6 @@ def main(): with tab_media: render_media_tab(client, user_id, libraries, set_prefixed_file_browser_path) - with tab_library: - if not libraries: - st.warning("No libraries found.") - return - lib_by_name = {lib["Name"]: lib for lib in libraries} - with st.sidebar: - st.header("Library filters") - lib_name = st.selectbox("Library", list(lib_by_name.keys())) - media_types = st.multiselect("Media types", ["Movie", "Series", "Episode", "Video", "Audio"], default=[]) - search = st.text_input("Search") - limit = st.slider("Items per page", 10, 200, 50, step=10) - page = st.number_input("Page", min_value=1, value=1) - - response = cached_items( - jellyfin_url, - jellyfin_api_key, - user_id, - lib_by_name[lib_name]["Id"], - search, - ",".join(media_types), - (page - 1) * limit, - limit, - ) - items = response.get("Items", []) - st.subheader(f"{lib_name} ({response.get('TotalRecordCount', len(items))} items)") - cols = st.columns(5) - for idx, item in enumerate(items): - with cols[idx % 5]: - show_item_card(client, item) - - if st.session_state.get("selected_item_id"): - st.divider() - detail = cached_item(jellyfin_url, jellyfin_api_key, user_id, st.session_state["selected_item_id"]) - show_item_detail(client, detail) - if detail.get("Path") and st.button("Open item path in file browser", key="library_open_item_path_in_file_browser"): - resolved_path = resolve_remote_media_path(detail["Path"], media_root, remote_path_prefix) - set_file_browser_path(str(PurePosixPath(resolved_path).parent), resolved_path) - with tab_files: if not ssh_host or not ssh_username: st.info("Enter SSH connection details in the sidebar.") diff --git a/src/media_library_viewer/clients/jellyfin.py b/src/media_library_viewer/clients/jellyfin.py index d58b2f2..d339389 100644 --- a/src/media_library_viewer/clients/jellyfin.py +++ b/src/media_library_viewer/clients/jellyfin.py @@ -133,8 +133,28 @@ class JellyfinClient: "episodes": self.item_count(user_id, "Episode"), } - def item(self, user_id: str, item_id: str) -> dict[str, Any]: - return self.get(f"/Users/{user_id}/Items/{item_id}", Fields=DEFAULT_FIELDS) + def library_item_counts(self, user_id: str, libraries: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Return per-library item counts broken down by type for the dashboard.""" + results = [] + for lib in libraries: + lib_id = lib.get("Id") + lib_name = lib.get("Name", "Unknown") + lib_type = lib.get("CollectionType", "") + if not lib_id: + continue + movies = self.item_count(user_id, "Movie", parent_id=lib_id) + series = self.item_count(user_id, "Series", parent_id=lib_id) + episodes = self.item_count(user_id, "Episode", parent_id=lib_id) + total = self.item_count(user_id, "Movie,Episode,Video,Audio,Series", parent_id=lib_id) + results.append({ + "library": lib_name, + "type": lib_type, + "movies": movies, + "series": series, + "episodes": episodes, + "total": total, + }) + return results def active_sessions(self, active_within_seconds: int = 300) -> list[dict[str, Any]]: """Return currently active sessions that have a now-playing item.""" diff --git a/src/media_library_viewer/clients/resources.py b/src/media_library_viewer/clients/resources.py index c8e6d30..21c8939 100644 --- a/src/media_library_viewer/clients/resources.py +++ b/src/media_library_viewer/clients/resources.py @@ -29,7 +29,7 @@ mkdir -p "$(dirname "$OUT")" echo "collector starting at $(date -Is 2>/dev/null || date), interval=${INTERVAL}s, retention=${RETENTION_SECONDS}s, max_lines=${MAX_LINES}, out=${OUT}" read_cpu() { - awk '/^cpu / {print $2+$3+$4+$5+$6+$7+$8+$9+$10, $5+$6}' /proc/stat + awk '/^cpu / {print $2+$3+$4+$5+$6+$7+$8+$9+$10, $5+$6, $6}' /proc/stat } read_mem_pct() { @@ -84,6 +84,7 @@ read_disk_bytes() { set -- $(read_cpu) prev_total="${1:-0}" prev_idle="${2:-0}" +prev_iowait="${3:-0}" set -- $(read_net_bytes) prev_rx="${1:-0}" prev_tx="${2:-0}" @@ -115,6 +116,7 @@ while true; do set -- $(read_cpu) total="${1:-0}" idle="${2:-0}" + iowait="${3:-0}" set -- $(read_net_bytes) rx="${1:-0}" tx="${2:-0}" @@ -125,12 +127,14 @@ while true; do total_delta=$((total - prev_total)) idle_delta=$((idle - prev_idle)) + iowait_delta=$((iowait - prev_iowait)) rx_delta=$((rx - prev_rx)) tx_delta=$((tx - prev_tx)) disk_read_delta=$((disk_read - prev_disk_read)) disk_write_delta=$((disk_write - prev_disk_write)) cpu_pct="$(awk -v total="$total_delta" -v idle="$idle_delta" 'BEGIN {if (total > 0) printf "%.2f", (total-idle)*100/total; else printf "0"}')" + iowait_pct="$(awk -v total="$total_delta" -v iow="$iowait_delta" 'BEGIN {if (total > 0) printf "%.2f", iow*100/total; else printf "0"}')" rx_bytes_per_sec="$(awk -v bytes="$rx_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes/dt; else printf "0"}')" tx_bytes_per_sec="$(awk -v bytes="$tx_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes/dt; else printf "0"}')" rx_bps="$(awk -v bytes="$rx_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes*8/dt; else printf "0"}')" @@ -138,8 +142,8 @@ while true; do disk_read_bps="$(awk -v bytes="$disk_read_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes/dt; else printf "0"}')" disk_write_bps="$(awk -v bytes="$disk_write_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes/dt; else printf "0"}')" - printf '{"ts":%s,"cpu_pct":%s,"mem_pct":%s,"net_rx_bytes_per_sec":%s,"net_tx_bytes_per_sec":%s,"net_rx_bps":%s,"net_tx_bps":%s,"disk_read_bps":%s,"disk_write_bps":%s}\n' \ - "$now_ts" "$cpu_pct" "$mem_pct" "$rx_bytes_per_sec" "$tx_bytes_per_sec" "$rx_bps" "$tx_bps" "$disk_read_bps" "$disk_write_bps" >> "$OUT" + printf '{"ts":%s,"cpu_pct":%s,"iowait_pct":%s,"mem_pct":%s,"net_rx_bytes_per_sec":%s,"net_tx_bytes_per_sec":%s,"net_rx_bps":%s,"net_tx_bps":%s,"disk_read_bps":%s,"disk_write_bps":%s}\n' \ + "$now_ts" "$cpu_pct" "$iowait_pct" "$mem_pct" "$rx_bytes_per_sec" "$tx_bytes_per_sec" "$rx_bps" "$tx_bps" "$disk_read_bps" "$disk_write_bps" >> "$OUT" sample_count=$((sample_count + 1)) if [ $((sample_count % PRUNE_EVERY_SAMPLES)) -eq 0 ]; then @@ -148,6 +152,7 @@ while true; do prev_total="$total" prev_idle="$idle" + prev_iowait="$iowait" prev_rx="$rx" prev_tx="$tx" prev_disk_read="$disk_read" diff --git a/src/media_library_viewer/ui/dashboard.py b/src/media_library_viewer/ui/dashboard.py index 5c4da9d..5e3d555 100644 --- a/src/media_library_viewer/ui/dashboard.py +++ b/src/media_library_viewer/ui/dashboard.py @@ -58,8 +58,9 @@ def format_elapsed(seconds: float | int | None) -> str: return f"{hours}h {minutes}m" -def render_media_overview(cached_media_counts, base_url: str, api_key: str, user_id: str) -> None: - """Render dashboard counts for movies/series/episodes.""" +def render_media_overview(cached_media_counts, cached_library_counts, base_url: str, api_key: str, + user_id: str) -> None: + """Render dashboard counts for movies/series/episodes and per-library breakdown.""" st.subheader("Media library overview") try: counts = cached_media_counts(base_url, api_key, user_id) @@ -67,10 +68,58 @@ def render_media_overview(cached_media_counts, base_url: str, api_key: str, user st.warning(f"Could not load Jellyfin media counts: {exc}") return - cols = st.columns(3) - cols[0].metric("Movies", f"{counts.get('movies', 0):,}") - cols[1].metric("Series", f"{counts.get('series', 0):,}") - cols[2].metric("Episodes", f"{counts.get('episodes', 0):,}") + # Top-level totals + total_items = counts.get("movies", 0) + counts.get("series", 0) + counts.get("episodes", 0) + top_cols = st.columns(4) + top_cols[0].metric("Total items", f"{total_items:,}") + top_cols[1].metric("Movies", f"{counts.get('movies', 0):,}") + top_cols[2].metric("Series", f"{counts.get('series', 0):,}") + top_cols[3].metric("Episodes", f"{counts.get('episodes', 0):,}") + + # Per-library breakdown + try: + lib_counts = cached_library_counts(base_url, api_key, user_id) + except Exception as exc: + st.caption(f"Could not load per-library counts: {exc}") + return + + if not lib_counts: + return + + st.markdown("**Libraries**") + + movie_libs = [e for e in lib_counts if e.get("type") == "movies"] + tv_libs = [e for e in lib_counts if e.get("type") == "tvshows"] + + if movie_libs and tv_libs: + left_col, right_col = st.columns(2) + elif movie_libs: + left_col = st.container() + right_col = None + elif tv_libs: + left_col = None + right_col = st.container() + else: + return + + if movie_libs and left_col: + with left_col: + st.caption("Movie libraries") + for entry in movie_libs: + with st.container(border=True): + st.markdown(f"**{entry['library']}**") + m_cols = st.columns(2) + m_cols[0].metric("Movies", f"{entry['movies']:,}") + + if tv_libs and right_col: + with right_col: + st.caption("TV libraries") + for entry in tv_libs: + with st.container(border=True): + st.markdown(f"**{entry['library']}**") + m_cols = st.columns(2) + m_cols[0].metric("Series", f"{entry['series']:,}") + m_cols[1].metric("Episodes", f"{entry['total']:,}") def render_now_playing(cached_active_sessions, base_url: str, api_key: str) -> None: @@ -130,8 +179,8 @@ def render_now_playing(cached_active_sessions, base_url: str, api_key: str) -> N def render_resource_dashboard(get_ssh_client, ssh_args: tuple, media_root: str, detailed: bool = False) -> None: - """Render server resource summary or detailed resource charts.""" - st.subheader("Server resource details" if detailed else "Server overview") + """Render server monitoring summary or detailed charts.""" + st.subheader("Server monitoring details" if detailed else "Server overview") host, username, port, key_filename, password = ssh_args ssh = get_ssh_client(host, username, port, key_filename, password) @@ -144,25 +193,25 @@ def render_resource_dashboard(get_ssh_client, ssh_args: tuple, media_root: str, if detailed: control_col, start_col, restart_col, stop_col, refresh_col = st.columns([2.3, 1, 1, 1, 1]) control_col.caption(f"Collector: `{status}` | sample interval: 10s | retention: 7 days / 70k samples") - if start_col.button("Start metrics", key="resource_start_metrics", use_container_width=True): + if start_col.button("Start metrics", key="monitoring_start_metrics", use_container_width=True): try: st.success(start_resource_collector(ssh)) except Exception as exc: st.error(str(exc)) - if restart_col.button("Restart", key="resource_restart_metrics", use_container_width=True): + if restart_col.button("Restart", key="monitoring_restart_metrics", use_container_width=True): try: st.success(restart_resource_collector(ssh)) except Exception as exc: st.error(str(exc)) - if stop_col.button("Stop metrics", key="resource_stop_metrics", use_container_width=True): + if stop_col.button("Stop metrics", key="monitoring_stop_metrics", use_container_width=True): try: st.info(stop_resource_collector(ssh)) except Exception as exc: st.error(str(exc)) - if refresh_col.button("Refresh", key="resource_refresh", use_container_width=True): + if refresh_col.button("Refresh", key="monitoring_refresh", use_container_width=True): st.rerun() else: - st.caption(f"Collector: `{status}`. Open the Resources tab for controls, diagnostics, and detailed charts.") + st.caption(f"Collector: `{status}`. Open the Monitoring tab for controls, diagnostics, and detailed charts.") try: rows = read_resource_metrics(ssh, max_lines=1000) @@ -179,25 +228,28 @@ def render_resource_dashboard(get_ssh_client, ssh_args: tuple, media_root: str, disk_cols[1].metric("Disk available", human_size(space.get("available"))) disk_cols[2].metric("Disk total", human_size(space.get("size"))) disk_cols[3].metric("Used percent", f"{used_pct_value:.0f}%") - st.progress(min(max(used_pct_value / 100, 0), 1), text=f"{space.get('mount', disk_path)} on {space.get('filesystem', '')}") + st.progress(min(max(used_pct_value / 100, 0), 1), + text=f"{space.get('mount', disk_path)} on {space.get('filesystem', '')}") except Exception as exc: st.warning(f"Could not read disk space for {disk_path}: {exc}") if not rows: if detailed: - st.info("No resource history yet. Click 'Start metrics' and wait at least 10 seconds for the first sample. If this stays empty, use Restart to install the latest collector script.") + st.info( + "No monitoring history yet. Click 'Start metrics' and wait at least 10 seconds for the first sample. If this stays empty, use Restart to install the latest collector script.") with st.expander("Collector diagnostics"): try: st.code(resource_collector_debug_info(ssh)) except Exception as exc: st.error(f"Could not read collector diagnostics: {exc}") else: - st.info("No resource history yet. Open the Resources tab to start the collector.") + st.info("No monitoring history yet. Open the Monitoring tab to start the collector.") return df = pd.DataFrame(rows) numeric_columns = [ - "ts", "cpu_pct", "mem_pct", "net_rx_bytes_per_sec", "net_tx_bytes_per_sec", "disk_read_bps", "disk_write_bps" + "ts", "cpu_pct", "iowait_pct", "mem_pct", "net_rx_bytes_per_sec", "net_tx_bytes_per_sec", "disk_read_bps", + "disk_write_bps" ] for column in numeric_columns: if column in df.columns: @@ -215,7 +267,8 @@ def render_resource_dashboard(get_ssh_client, ssh_args: tuple, media_root: str, if all_sample_count: raw_df = pd.DataFrame(rows) st.write(f"Parsed samples: {all_sample_count}") - st.write(f"Newest remote sample age: {time.time() - float(raw_df['ts'].astype(float).max()):.0f} seconds") + st.write( + f"Newest remote sample age: {time.time() - float(raw_df['ts'].astype(float).max()):.0f} seconds") st.dataframe(raw_df.tail(10), use_container_width=True, hide_index=True) else: st.write("No parseable samples found.") @@ -225,6 +278,8 @@ def render_resource_dashboard(get_ssh_client, ssh_args: tuple, media_root: str, latest = df.iloc[-1] avg_cpu = df["cpu_pct"].mean() max_cpu = df["cpu_pct"].max() + avg_iowait = df["iowait_pct"].mean() if "iowait_pct" in df.columns else 0 + max_iowait = df["iowait_pct"].max() if "iowait_pct" in df.columns else 0 avg_mem = df["mem_pct"].mean() max_mem = df["mem_pct"].max() avg_net_down = df["net_rx_bytes_per_sec"].mean() @@ -236,20 +291,32 @@ def render_resource_dashboard(get_ssh_client, ssh_args: tuple, media_root: str, avg_disk_write = df["disk_write_bps"].mean() max_disk_write = df["disk_write_bps"].max() - metric_cols = st.columns(6) - metric_cols[0].metric("CPU now", f"{latest['cpu_pct']:.1f}%", f"avg {avg_cpu:.1f}% / peak {max_cpu:.1f}%") - metric_cols[1].metric("RAM now", f"{latest['mem_pct']:.1f}%", f"avg {avg_mem:.1f}% / peak {max_mem:.1f}%") - metric_cols[2].metric("Network down", format_rate_bytes(latest["net_rx_bytes_per_sec"]), f"avg {format_rate_bytes(avg_net_down)} / peak {format_rate_bytes(max_net_down)}") - metric_cols[3].metric("Network up", format_rate_bytes(latest["net_tx_bytes_per_sec"]), f"avg {format_rate_bytes(avg_net_up)} / peak {format_rate_bytes(max_net_up)}") - metric_cols[4].metric("Disk read", format_rate_bytes(latest["disk_read_bps"]), f"avg {format_rate_bytes(avg_disk_read)} / peak {format_rate_bytes(max_disk_read)}") - metric_cols[5].metric("Disk write", format_rate_bytes(latest["disk_write_bps"]), f"avg {format_rate_bytes(avg_disk_write)} / peak {format_rate_bytes(max_disk_write)}") + metric_cols = st.columns(7) + metric_cols[0].metric("CPU now", f"{latest['cpu_pct']:.1f}%") + metric_cols[0].caption(f"avg {avg_cpu:.1f}% \npeak {max_cpu:.1f}%") + metric_cols[1].metric("IO wait", f"{latest.get('iowait_pct', 0):.1f}%") + metric_cols[1].caption(f"avg {avg_iowait:.1f}% \npeak {max_iowait:.1f}%") + metric_cols[2].metric("RAM now", f"{latest['mem_pct']:.1f}%") + metric_cols[2].caption(f"avg {avg_mem:.1f}% \npeak {max_mem:.1f}%") + metric_cols[3].metric("Network down", format_rate_bytes(latest["net_rx_bytes_per_sec"])) + metric_cols[3].caption(f"avg {format_rate_bytes(avg_net_down)} \npeak {format_rate_bytes(max_net_down)}") + metric_cols[4].metric("Network up", format_rate_bytes(latest["net_tx_bytes_per_sec"])) + metric_cols[4].caption(f"avg {format_rate_bytes(avg_net_up)} \npeak {format_rate_bytes(max_net_up)}") + metric_cols[5].metric("Disk read", format_rate_bytes(latest["disk_read_bps"])) + metric_cols[5].caption(f"avg {format_rate_bytes(avg_disk_read)} \npeak {format_rate_bytes(max_disk_read)}") + metric_cols[6].metric("Disk write", format_rate_bytes(latest["disk_write_bps"])) + metric_cols[6].caption(f"avg {format_rate_bytes(avg_disk_write)} \npeak {format_rate_bytes(max_disk_write)}") chart_df = df.set_index("time") if detailed: - st.markdown("**CPU and RAM - last hour**") - st.line_chart(chart_df[["cpu_pct", "mem_pct"]], use_container_width=True) + st.markdown("**CPU, IO wait, and RAM - last hour**") + chart_cols = ["cpu_pct", "mem_pct"] + if "iowait_pct" in chart_df.columns: + chart_cols = ["cpu_pct", "iowait_pct", "mem_pct"] + st.line_chart(chart_df[chart_cols], use_container_width=True) else: - st.caption("Detailed CPU/RAM charts, network, disk I/O, raw samples, and collector controls are available in the Resources tab.") + st.caption( + "Detailed CPU/IO wait/RAM charts, network, disk I/O, raw samples, and collector controls are available in the Monitoring tab.") return net_down_df, net_down_suffix = scaled_rate_chart_df(chart_df, ["net_rx_bytes_per_sec"], ["download"]) @@ -272,5 +339,5 @@ def render_resource_dashboard(get_ssh_client, ssh_args: tuple, media_root: str, st.markdown(f"**Disk write - last hour ({disk_write_suffix})**") st.line_chart(disk_write_df, use_container_width=True) - with st.expander("Raw resource samples"): + with st.expander("Raw monitoring samples"): st.dataframe(df.sort_values("time", ascending=False), use_container_width=True, hide_index=True) diff --git a/src/media_library_viewer/ui/file_browser.py b/src/media_library_viewer/ui/file_browser.py index bb892b7..e0eca42 100644 --- a/src/media_library_viewer/ui/file_browser.py +++ b/src/media_library_viewer/ui/file_browser.py @@ -69,25 +69,15 @@ def render_file_browser(cached_dir_listing: Callable[..., list[dict]], ssh_args: status_col.caption(f"Current folder: `{current_dir}`") selected_col.caption(f"Selected path: `{selected}`") - path_col, up_col, go_col, select_col, refresh_col = st.columns([5, 1, 1, 1.5, 1.5]) + path_col, refresh_col = st.columns([6, 1]) path_input = path_col.text_input("Remote path", key="file_browser_path_input", label_visibility="collapsed") requested_path = path_input or "/" - if up_col.button("Up", key="file_browser_up", use_container_width=True): - set_file_browser_path(str(PurePosixPath(current_dir).parent)) - st.rerun() - if go_col.button("Go", key="file_browser_go", use_container_width=True): + # Navigate when the user edits the path and presses Enter + if requested_path != current_dir: set_file_browser_path(requested_path) st.rerun() - if select_col.button("Select folder", key="file_browser_select_current_folder", use_container_width=True): - if requested_path != current_dir: - set_file_browser_path(requested_path, requested_path) - else: - st.session_state["file_browser_selected_path"] = current_dir - st.rerun() if refresh_col.button("Refresh", key="file_browser_refresh", use_container_width=True): cached_dir_listing.clear() - if requested_path != current_dir: - set_file_browser_path(requested_path) st.rerun() try: diff --git a/src/media_library_viewer/ui/library.py b/src/media_library_viewer/ui/library.py deleted file mode 100644 index a0afcc6..0000000 --- a/src/media_library_viewer/ui/library.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Jellyfin library browser UI pieces.""" - -from __future__ import annotations - -from typing import Any - -import pandas as pd -import streamlit as st - -from media_library_viewer.domain.media import media_streams -from media_library_viewer.utils import ticks_to_minutes - - -def show_item_card(client, item: dict[str, Any]) -> None: - """Render a poster card in the Jellyfin library grid.""" - try: - st.image(client.image_url(item["Id"]), use_container_width=True) - except Exception: - st.caption("No image") - st.markdown(f"**{item.get('Name', 'Untitled')}**") - meta = [item.get("Type", "")] - if item.get("ProductionYear"): - meta.append(str(item["ProductionYear"])) - minutes = ticks_to_minutes(item.get("RunTimeTicks")) - if minutes: - meta.append(f"{minutes} min") - st.caption(" - ".join([m for m in meta if m])) - if st.button("Open", key=f"open-{item['Id']}"): - st.session_state["selected_item_id"] = item["Id"] - - -def show_item_detail(client, item: dict[str, Any]) -> None: - """Render detailed Jellyfin item metadata for the selected poster card.""" - st.header(item.get("Name", "Untitled")) - left, right = st.columns([1, 2]) - with left: - st.image(client.image_url(item["Id"]), use_container_width=True) - with right: - st.write(item.get("Overview") or "No overview.") - st.write("**Path:**", item.get("Path") or "Not exposed by Jellyfin") - st.write("**Genres:**", ", ".join(item.get("Genres", [])) or "-") - st.write("**Rating:**", item.get("CommunityRating") or "-") - st.write("**Official rating:**", item.get("OfficialRating") or "-") - - streams = media_streams(item) - if streams: - st.subheader("Jellyfin media streams") - st.dataframe(pd.DataFrame(streams), use_container_width=True) - - with st.expander("Raw Jellyfin JSON"): - st.json(item) diff --git a/src/media_library_viewer/ui/media.py b/src/media_library_viewer/ui/media.py index 95a7e71..b7bd457 100644 --- a/src/media_library_viewer/ui/media.py +++ b/src/media_library_viewer/ui/media.py @@ -143,25 +143,26 @@ def render_media_tab( selected_media_path = st.session_state.get("media_inventory_selected_path") grid_builder = GridOptionsBuilder.from_dataframe(table_df) - grid_builder.configure_default_column(editable=False, resizable=True, sortable=False, filter=False) - grid_builder.configure_column("title", header_name="Title", flex=2) - grid_builder.configure_column("series", header_name="Series", flex=1.5) - grid_builder.configure_column("season", header_name="Season", width=95) - grid_builder.configure_column("episode", header_name="Episode", width=105) - grid_builder.configure_column("type", header_name="Type", width=100) - grid_builder.configure_column("year", header_name="Year", width=90) - grid_builder.configure_column("runtime_min", header_name="Runtime (min)", width=125) - grid_builder.configure_column("size", header_name="Size", width=120) - grid_builder.configure_column("bitrate", header_name="Bitrate", width=125) - grid_builder.configure_column("hdr", header_name="HDR", width=80) - grid_builder.configure_column("video", header_name="Video codec", width=120) - grid_builder.configure_column("resolution", header_name="Resolution", width=120) - grid_builder.configure_column("date_added", header_name="Date added", width=120) - grid_builder.configure_column("library", header_name="Library", width=140) - grid_builder.configure_column("path", header_name="Path", flex=2) + grid_builder.configure_default_column(editable=False, resizable=True, sortable=False, filter=False, autoSize=True) + grid_builder.configure_column("title", header_name="Title", minWidth=150) + grid_builder.configure_column("series", header_name="Series", minWidth=120) + grid_builder.configure_column("season", header_name="Season", maxWidth=95) + grid_builder.configure_column("episode", header_name="Episode", maxWidth=105) + grid_builder.configure_column("type", header_name="Type", maxWidth=100) + grid_builder.configure_column("year", header_name="Year", maxWidth=90) + grid_builder.configure_column("runtime_min", header_name="Runtime (min)", maxWidth=125) + grid_builder.configure_column("size", header_name="Size", maxWidth=120) + grid_builder.configure_column("bitrate", header_name="Bitrate", maxWidth=125) + grid_builder.configure_column("hdr", header_name="HDR", maxWidth=80) + grid_builder.configure_column("video", header_name="Video codec", maxWidth=120) + grid_builder.configure_column("resolution", header_name="Resolution", maxWidth=120) + grid_builder.configure_column("date_added", header_name="Date added", maxWidth=120) + grid_builder.configure_column("library", header_name="Library", maxWidth=140) + grid_builder.configure_column("path", header_name="Path", minWidth=200) grid_builder.configure_column("id", hide=True) grid_builder.configure_selection(selection_mode="single", use_checkbox=False) grid_options = grid_builder.build() + grid_options["autoSizeStrategy"] = {"type": "fitCellContents"} grid_options["rowSelection"] = { "mode": "singleRow", "checkboxes": False,