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