Files
manage/backend/utils.py
T
alex 3c432473e5 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)
2026-04-30 21:40:18 +02:00

228 lines
7.4 KiB
Python

"""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