227 lines
7.4 KiB
Python
227 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
|