Add FastAPI backend and React frontend subprojects
Backend: - FastAPI app with 17 REST endpoints covering dashboard, monitoring, media index, file browser, and jobs - Reuses existing clients/domain/services unchanged - pydantic-settings config, dependency injection, CORS setup - Auto-generated OpenAPI docs at /docs Frontend: - Vite + React + TypeScript SPA - @tanstack/react-query for data fetching with polling - ag-grid-react for media table and file browser - recharts for monitoring charts - Tailwind CSS styling - 4 pages: Dashboard, Monitoring, Media, File Browser - Typed API client matching all backend endpoints Also: - docs/MIGRATION_PLAN.md with full architecture plan - Updated .gitignore for both subprojects - Streamlit app preserved for now (can coexist)
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
"""Formatting and ffprobe summarization helpers.
|
||||
|
||||
These helpers are intentionally UI-framework independent. Streamlit renders the
|
||||
returned dictionaries/dataframes, but another frontend can reuse the same
|
||||
summaries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
|
||||
VIDEO_FILE_EXTENSIONS = {
|
||||
".3g2",
|
||||
".3gp",
|
||||
".avi",
|
||||
".divx",
|
||||
".flv",
|
||||
".m2ts",
|
||||
".m4v",
|
||||
".mkv",
|
||||
".mov",
|
||||
".mp4",
|
||||
".mpeg",
|
||||
".mpg",
|
||||
".mts",
|
||||
".ogm",
|
||||
".ogv",
|
||||
".rmvb",
|
||||
".ts",
|
||||
".vob",
|
||||
".webm",
|
||||
".wmv",
|
||||
}
|
||||
|
||||
|
||||
def ticks_to_minutes(ticks: int | None) -> int | None:
|
||||
"""Convert Jellyfin/Emby 100-nanosecond ticks to rounded minutes."""
|
||||
if not ticks:
|
||||
return None
|
||||
return round(ticks / 10_000_000 / 60)
|
||||
|
||||
|
||||
def human_size(num: int | float | None) -> str:
|
||||
"""Format a byte count as B/KB/MB/GB/etc."""
|
||||
if num is None:
|
||||
return ""
|
||||
value = float(num)
|
||||
for unit in ["B", "KB", "MB", "GB", "TB", "PB"]:
|
||||
if value < 1024 or unit == "PB":
|
||||
return f"{value:.1f} {unit}" if unit != "B" else f"{int(value)} B"
|
||||
value /= 1024
|
||||
return f"{value:.1f} PB"
|
||||
|
||||
|
||||
def timestamp_to_local(ts: float | None) -> str:
|
||||
if ts is None:
|
||||
return ""
|
||||
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def is_known_video_file(path: str | None) -> bool:
|
||||
"""Return True when a path extension is one we should ffprobe automatically."""
|
||||
if not path:
|
||||
return False
|
||||
return PurePosixPath(path).suffix.lower() in VIDEO_FILE_EXTENSIONS
|
||||
|
||||
|
||||
def format_duration(seconds: str | int | float | None) -> str:
|
||||
if seconds in (None, ""):
|
||||
return ""
|
||||
try:
|
||||
total = float(seconds)
|
||||
except (TypeError, ValueError):
|
||||
return str(seconds)
|
||||
hours = int(total // 3600)
|
||||
minutes = int((total % 3600) // 60)
|
||||
secs = int(total % 60)
|
||||
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
||||
|
||||
|
||||
def format_bitrate(bit_rate: str | int | float | None) -> str:
|
||||
if bit_rate in (None, ""):
|
||||
return ""
|
||||
try:
|
||||
value = float(bit_rate)
|
||||
except (TypeError, ValueError):
|
||||
return str(bit_rate)
|
||||
if value >= 1_000_000:
|
||||
return f"{value / 1_000_000:.2f} Mbps"
|
||||
if value >= 1_000:
|
||||
return f"{value / 1_000:.0f} kbps"
|
||||
return f"{value:.0f} bps"
|
||||
|
||||
|
||||
def _tags(stream: dict[str, Any]) -> dict[str, Any]:
|
||||
return stream.get("tags") or {}
|
||||
|
||||
|
||||
def _disposition(stream: dict[str, Any], key: str) -> str:
|
||||
value = (stream.get("disposition") or {}).get(key)
|
||||
return "yes" if value == 1 else ""
|
||||
|
||||
|
||||
def _side_data_types(stream: dict[str, Any]) -> str:
|
||||
values = []
|
||||
for item in stream.get("side_data_list") or []:
|
||||
if item.get("side_data_type"):
|
||||
values.append(item["side_data_type"])
|
||||
return ", ".join(values)
|
||||
|
||||
|
||||
def ffprobe_format_summary(ffprobe: dict[str, Any]) -> dict[str, str]:
|
||||
"""Summarize ffprobe container/format-level metadata."""
|
||||
fmt = ffprobe.get("format") or {}
|
||||
return {
|
||||
"filename": fmt.get("filename", ""),
|
||||
"format": fmt.get("format_name", ""),
|
||||
"format_long": fmt.get("format_long_name", ""),
|
||||
"duration": format_duration(fmt.get("duration")),
|
||||
"size": human_size(float(fmt["size"])) if fmt.get("size") else "",
|
||||
"bit_rate": format_bitrate(fmt.get("bit_rate")),
|
||||
"stream_count": str(fmt.get("nb_streams", "")),
|
||||
}
|
||||
|
||||
|
||||
def summarize_video_streams(ffprobe: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Return video-only stream rows with video/HDR-related fields."""
|
||||
rows = []
|
||||
for stream in ffprobe.get("streams", []):
|
||||
if stream.get("codec_type") != "video":
|
||||
continue
|
||||
tags = _tags(stream)
|
||||
rows.append(
|
||||
{
|
||||
"index": stream.get("index"),
|
||||
"codec": stream.get("codec_name"),
|
||||
"profile": stream.get("profile"),
|
||||
"resolution": f"{stream.get('width', '')}x{stream.get('height', '')}",
|
||||
"pix_fmt": stream.get("pix_fmt"),
|
||||
"bit_rate": format_bitrate(stream.get("bit_rate")),
|
||||
"avg_fps": stream.get("avg_frame_rate"),
|
||||
"color_range": stream.get("color_range"),
|
||||
"color_space": stream.get("color_space"),
|
||||
"color_transfer": stream.get("color_transfer"),
|
||||
"color_primaries": stream.get("color_primaries"),
|
||||
"side_data": _side_data_types(stream),
|
||||
"language": tags.get("language"),
|
||||
"title": tags.get("title"),
|
||||
"default": _disposition(stream, "default"),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def summarize_audio_streams(ffprobe: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Return audio-only stream rows with channel/language/default fields."""
|
||||
rows = []
|
||||
for stream in ffprobe.get("streams", []):
|
||||
if stream.get("codec_type") != "audio":
|
||||
continue
|
||||
tags = _tags(stream)
|
||||
rows.append(
|
||||
{
|
||||
"index": stream.get("index"),
|
||||
"codec": stream.get("codec_name"),
|
||||
"profile": stream.get("profile"),
|
||||
"channels": stream.get("channels"),
|
||||
"layout": stream.get("channel_layout"),
|
||||
"sample_rate": stream.get("sample_rate"),
|
||||
"bit_rate": format_bitrate(stream.get("bit_rate")),
|
||||
"language": tags.get("language"),
|
||||
"title": tags.get("title"),
|
||||
"default": _disposition(stream, "default"),
|
||||
"forced": _disposition(stream, "forced"),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def summarize_subtitle_streams(ffprobe: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Return subtitle-only stream rows with language/forced/default fields."""
|
||||
rows = []
|
||||
for stream in ffprobe.get("streams", []):
|
||||
if stream.get("codec_type") != "subtitle":
|
||||
continue
|
||||
tags = _tags(stream)
|
||||
rows.append(
|
||||
{
|
||||
"index": stream.get("index"),
|
||||
"codec": stream.get("codec_name"),
|
||||
"codec_long": stream.get("codec_long_name"),
|
||||
"language": tags.get("language"),
|
||||
"title": tags.get("title"),
|
||||
"default": _disposition(stream, "default"),
|
||||
"forced": _disposition(stream, "forced"),
|
||||
"hearing_impaired": _disposition(stream, "hearing_impaired"),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def summarize_streams(ffprobe: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
rows = []
|
||||
for stream in ffprobe.get("streams", []):
|
||||
rows.append(
|
||||
{
|
||||
"index": stream.get("index"),
|
||||
"type": stream.get("codec_type"),
|
||||
"codec": stream.get("codec_name"),
|
||||
"profile": stream.get("profile"),
|
||||
"width": stream.get("width"),
|
||||
"height": stream.get("height"),
|
||||
"pix_fmt": stream.get("pix_fmt"),
|
||||
"color_transfer": stream.get("color_transfer"),
|
||||
"color_primaries": stream.get("color_primaries"),
|
||||
"color_space": stream.get("color_space"),
|
||||
"bit_rate": format_bitrate(stream.get("bit_rate")),
|
||||
"channels": stream.get("channels"),
|
||||
"sample_rate": stream.get("sample_rate"),
|
||||
"language": stream.get("tags", {}).get("language"),
|
||||
"title": stream.get("tags", {}).get("title"),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
Reference in New Issue
Block a user