| @@ -10,12 +10,15 @@ from __future__ import annotations |
| 10 | 10 | import argparse |
| 11 | 11 | import json |
| 12 | 12 | import os |
| 13 | import subprocess |
| 13 | 14 | import sys |
| 14 | 15 | import textwrap |
| 15 | 16 | from datetime import date |
| 16 | 17 | from itertools import zip_longest |
| 17 | 18 | from pathlib import Path |
| 18 | 19 | |
| 20 | SCRIPT_DIR = Path(__file__).resolve().parent |
| 21 | |
| 19 | 22 | # --- field schema ------------------------------------------------------------ |
| 20 | 23 | # One source of truth. Drives argparse flags, interactive prompts, and render. |
| 21 | 24 | # key : internal name / config key / flag name |
| @@ -112,6 +115,104 @@ def load_config(explicit: str | None) -> dict: |
| 112 | 115 | return tomllib.loads(text) |
| 113 | 116 | |
| 114 | 117 | |
| 118 | # --- mediainfo import -------------------------------------------------------- |
| 119 | def load_mediainfo(path: str) -> dict: |
| 120 | """Read a `mediainfo --Output=JSON` dump (or run mediainfo on a media file) |
| 121 | and return values for the video/audio/resolution/size/runtime/format keys.""" |
| 122 | p = Path(path) |
| 123 | if p.suffix.lower() == ".json": |
| 124 | raw = p.read_text(encoding="utf-8") |
| 125 | else: |
| 126 | try: |
| 127 | proc = subprocess.run(["mediainfo", "--Output=JSON", str(p)], |
| 128 | capture_output=True, text=True, check=True) |
| 129 | except FileNotFoundError: |
| 130 | sys.exit("nfogen: mediainfo not installed; pass a JSON dump to --mediainfo") |
| 131 | except subprocess.CalledProcessError as e: |
| 132 | sys.exit(f"nfogen: mediainfo failed: {(e.stderr or '').strip()}") |
| 133 | raw = proc.stdout |
| 134 | try: |
| 135 | doc = json.loads(raw) |
| 136 | except json.JSONDecodeError: |
| 137 | sys.exit(f"nfogen: not valid mediainfo JSON: {path}") |
| 138 | return _extract_media(doc) |
| 139 | |
| 140 | |
| 141 | def _mi_int(x) -> str: |
| 142 | try: |
| 143 | return str(int(float(str(x).replace(" ", "")))) |
| 144 | except ValueError: |
| 145 | return str(x) |
| 146 | |
| 147 | |
| 148 | def _mi_size(b) -> str: |
| 149 | try: |
| 150 | n = float(b) |
| 151 | except (TypeError, ValueError): |
| 152 | return str(b) |
| 153 | for unit, div in (("GiB", 1 << 30), ("MiB", 1 << 20), ("KiB", 1 << 10)): |
| 154 | if n >= div: |
| 155 | return f"{n / div:.2f} {unit}" |
| 156 | return f"{int(n)} B" |
| 157 | |
| 158 | |
| 159 | def _mi_duration(s) -> str: |
| 160 | try: |
| 161 | sec = int(float(s)) |
| 162 | except (TypeError, ValueError): |
| 163 | return str(s) |
| 164 | h, m = sec // 3600, (sec % 3600) // 60 |
| 165 | return f"{h}h {m:02d}m" if h else f"{m}m {sec % 60:02d}s" |
| 166 | |
| 167 | |
| 168 | def _extract_media(doc: dict) -> dict: |
| 169 | tracks = (doc.get("media") or {}).get("track") or [] |
| 170 | g = next((t for t in tracks if t.get("@type") == "General"), {}) |
| 171 | v = next((t for t in tracks if t.get("@type") == "Video"), {}) |
| 172 | auds = [t for t in tracks if t.get("@type") == "Audio"] |
| 173 | txts = [t for t in tracks if t.get("@type") == "Text"] |
| 174 | a = auds[0] if auds else {} |
| 175 | channels = {"1": "1.0", "2": "2.0", "6": "5.1", "8": "7.1"} |
| 176 | out: dict = {} |
| 177 | |
| 178 | if g.get("Format"): |
| 179 | out["format"] = g["Format"] |
| 180 | if g.get("FileSize"): |
| 181 | out["size"] = _mi_size(g["FileSize"]) |
| 182 | if g.get("Duration") or v.get("Duration"): |
| 183 | out["runtime"] = _mi_duration(g.get("Duration") or v.get("Duration")) |
| 184 | |
| 185 | if v: |
| 186 | parts = [v.get("Encoded_Library_Name") or v.get("Format")] |
| 187 | if v.get("BitRate"): |
| 188 | parts.append(f"{int(float(v['BitRate'])) // 1000} kbps") |
| 189 | if v.get("FrameRate"): |
| 190 | parts.append(f"{float(v['FrameRate']):g} fps") |
| 191 | out["video"] = ", ".join(p for p in parts if p) |
| 192 | if v.get("Width") and v.get("Height"): |
| 193 | out["resolution"] = f"{_mi_int(v['Width'])}x{_mi_int(v['Height'])}" |
| 194 | |
| 195 | if a: |
| 196 | parts = [a.get("Format")] |
| 197 | if a.get("Channels"): |
| 198 | parts.append(channels.get(str(a["Channels"]), f"{a['Channels']}ch")) |
| 199 | if a.get("BitRate"): |
| 200 | parts.append(f"{int(float(a['BitRate'])) // 1000} kbps") |
| 201 | audio = ", ".join(p for p in parts if p) |
| 202 | if a.get("Language"): |
| 203 | audio = f"{audio} ({a['Language']})" if audio else a["Language"] |
| 204 | out["audio"] = audio |
| 205 | |
| 206 | subs = list(dict.fromkeys(t.get("Language") for t in txts if t.get("Language"))) |
| 207 | lang = a.get("Language") or "" |
| 208 | if subs: |
| 209 | out["language"] = (f"{lang} (subs: {', '.join(subs)})").strip() |
| 210 | elif lang: |
| 211 | out["language"] = lang |
| 212 | |
| 213 | return {k: val for k, val in out.items() if val} |
| 214 | |
| 215 | |
| 115 | 216 | # --- interactive ------------------------------------------------------------- |
| 116 | 217 | def prompt_missing(data: dict, force_all: bool) -> None: |
| 117 | 218 | """Fill fields from stdin. Prompts every field when force_all, else only |
| @@ -148,6 +249,43 @@ def _col_widths(available: int, fracs: list[float]) -> list[int]: |
| 148 | 249 | return widths |
| 149 | 250 | |
| 150 | 251 | |
| 252 | # A compact 5-row block font for the banner generator. Each glyph is five |
| 253 | # rows joined by "/". Uppercase letters, digits, space, and a few symbols. |
| 254 | _FONT = { |
| 255 | "A": " ### /# #/#####/# #/# #", "B": "#### /# #/#### /# #/#### ", |
| 256 | "C": " ####/# /# /# / ####", "D": "#### /# #/# #/# #/#### ", |
| 257 | "E": "#####/# /### /# /#####", "F": "#####/# /### /# /# ", |
| 258 | "G": " ####/# /# ##/# #/ ####", "H": "# #/# #/#####/# #/# #", |
| 259 | "I": "#####/ # / # / # /#####", "J": "#####/ # / # /# # / ## ", |
| 260 | "K": "# #/# # /### /# # /# #", "L": "# /# /# /# /#####", |
| 261 | "M": "# #/## ##/# # #/# #/# #", "N": "# #/## #/# # #/# ##/# #", |
| 262 | "O": " ### /# #/# #/# #/ ### ", "P": "#### /# #/#### /# /# ", |
| 263 | "Q": " ### /# #/# # #/# # / ## #", "R": "#### /# #/#### /# # /# #", |
| 264 | "S": " ####/# / ### / #/#### ", "T": "#####/ # / # / # / # ", |
| 265 | "U": "# #/# #/# #/# #/ ### ", "V": "# #/# #/# #/ # # / # ", |
| 266 | "W": "# #/# #/# # #/## ##/# #", "X": "# #/ # # / # / # # /# #", |
| 267 | "Y": "# #/ # # / # / # / # ", "Z": "#####/ # / # / # /#####", |
| 268 | "0": " ### /# ##/# # #/## #/ ### ", "1": " # / ## / # / # /#####", |
| 269 | "2": " ### /# #/ ## / # /#####", "3": "#### / #/ ### / #/#### ", |
| 270 | "4": "# #/# #/#####/ #/ #", "5": "#####/# /#### / #/#### ", |
| 271 | "6": " ####/# /#### /# #/ ### ", "7": "#####/ # / # / # /# ", |
| 272 | "8": " ### /# #/ ### /# #/ ### ", "9": " ### /# #/ ####/ #/#### ", |
| 273 | " ": " / / / / ", "-": " / /#####/ / ", |
| 274 | ".": " / / / / # ", "!": " # / # / # / / # ", |
| 275 | ":": " / # / / # / ", |
| 276 | } |
| 277 | |
| 278 | |
| 279 | def banner(text: str) -> str: |
| 280 | """Render text as a 5-row ASCII banner using the built-in block font.""" |
| 281 | rows = ["", "", "", "", ""] |
| 282 | for ch in text.upper(): |
| 283 | glyph = _FONT.get(ch, _FONT[" "]).split("/") |
| 284 | for i in range(5): |
| 285 | rows[i] += glyph[i] + " " |
| 286 | return "\n".join(r.rstrip() for r in rows) |
| 287 | |
| 288 | |
| 151 | 289 | def render(data: dict, group: str, site: str, width: int, |
| 152 | 290 | style: str = DEFAULT_STYLE, presents: bool = False, |
| 153 | 291 | footer: str | None = None, layout: str = DEFAULT_LAYOUT, |
| @@ -338,6 +476,12 @@ def build_parser() -> argparse.ArgumentParser: |
| 338 | 476 | help="config file (.toml or .json) with field defaults") |
| 339 | 477 | p.add_argument("-p", "--profile", metavar="PATH", |
| 340 | 478 | help="group profile (logo, roster, boards) merged under the config") |
| 479 | p.add_argument("-t", "--template", metavar="NAME", |
| 480 | help="bundled style template (see --list-templates)") |
| 481 | p.add_argument("--list-templates", action="store_true", |
| 482 | help="list bundled templates and exit") |
| 483 | p.add_argument("--mediainfo", metavar="PATH", |
| 484 | help="mediainfo JSON dump (or media file) to auto-fill media fields") |
| 341 | 485 | p.add_argument("-o", "--output", metavar="PATH", |
| 342 | 486 | help="write to PATH instead of stdout") |
| 343 | 487 | p.add_argument("-g", "--group", help=f"release group (default {DEFAULT_GROUP})") |
| @@ -352,6 +496,9 @@ def build_parser() -> argparse.ArgumentParser: |
| 352 | 496 | help="art file to place above the box") |
| 353 | 497 | p.add_argument("--logo-encoding", choices=["cp437", "utf8"], |
| 354 | 498 | help="encoding of the logo file (default cp437)") |
| 499 | p.add_argument("--banner", action=argparse.BooleanOptionalAction, |
| 500 | default=None, help="generate an ASCII banner logo (defaults to the group)") |
| 501 | p.add_argument("--banner-text", help="text for the generated banner") |
| 355 | 502 | p.add_argument("--encoding", choices=["utf8", "cp437"], default="utf8", |
| 356 | 503 | help="output encoding (default utf8; cp437 for true DOS nfos)") |
| 357 | 504 | p.add_argument("--presents", action=argparse.BooleanOptionalAction, |
| @@ -368,15 +515,37 @@ def build_parser() -> argparse.ArgumentParser: |
| 368 | 515 | return p |
| 369 | 516 | |
| 370 | 517 | |
| 518 | def _templates_dir() -> Path: |
| 519 | return SCRIPT_DIR / "templates" |
| 520 | |
| 521 | |
| 522 | def _list_templates() -> list[str]: |
| 523 | d = _templates_dir() |
| 524 | return sorted(p.stem for p in d.glob("*.toml")) if d.is_dir() else [] |
| 525 | |
| 526 | |
| 371 | 527 | def main(argv: list[str] | None = None) -> int: |
| 372 | 528 | args = build_parser().parse_args(argv) |
| 529 | |
| 530 | if args.list_templates: |
| 531 | names = _list_templates() |
| 532 | print("\n".join(names) if names else "nfogen: no templates bundled") |
| 533 | return 0 |
| 534 | |
| 373 | 535 | release = load_config(args.config) |
| 374 | 536 | |
| 375 | | # a group profile supplies base values (logo, roster, style, footer, ...); |
| 376 | | # the release config overrides it, and CLI flags override both. |
| 537 | # layered base: a style template underneath a group profile; the release |
| 538 | # config overrides both, and CLI flags override everything. |
| 539 | template = {} |
| 540 | if args.template: |
| 541 | tpath = _templates_dir() / f"{args.template}.toml" |
| 542 | if not tpath.exists(): |
| 543 | sys.exit(f"nfogen: unknown template '{args.template}' " |
| 544 | f"(have: {', '.join(_list_templates()) or 'none'})") |
| 545 | template = load_config(str(tpath)) |
| 377 | 546 | profile_path = args.profile or release.get("profile") |
| 378 | 547 | profile = load_config(profile_path) if profile_path else {} |
| 379 | | config = {**profile, **release} |
| 548 | config = {**template, **profile, **release} |
| 380 | 549 | |
| 381 | 550 | # merge: config defaults, then any CLI flag that was supplied |
| 382 | 551 | data = {k: config[k] for k, *_ in FIELDS if k in config and config[k] != ""} |
| @@ -384,6 +553,13 @@ def main(argv: list[str] | None = None) -> int: |
| 384 | 553 | val = getattr(args, key) |
| 385 | 554 | if val is not None: |
| 386 | 555 | data[key] = val |
| 556 | |
| 557 | # mediainfo fills any media field not already set by config or flags |
| 558 | mediainfo_src = args.mediainfo or config.get("mediainfo") |
| 559 | if mediainfo_src: |
| 560 | for key, val in load_mediainfo(mediainfo_src).items(): |
| 561 | data.setdefault(key, val) |
| 562 | |
| 387 | 563 | data.setdefault("date", date.today().isoformat()) |
| 388 | 564 | |
| 389 | 565 | group = args.group or config.get("group") or DEFAULT_GROUP |
| @@ -405,6 +581,7 @@ def main(argv: list[str] | None = None) -> int: |
| 405 | 581 | panel_titles = (config.get("panel_left") or PANEL_TITLES[0], |
| 406 | 582 | config.get("panel_right") or PANEL_TITLES[1]) |
| 407 | 583 | |
| 584 | # logo: an art file wins; otherwise an optional generated banner |
| 408 | 585 | logo = None |
| 409 | 586 | logo_path = args.logo or config.get("logo") |
| 410 | 587 | if logo_path: |
| @@ -413,6 +590,8 @@ def main(argv: list[str] | None = None) -> int: |
| 413 | 590 | logo = Path(logo_path).read_text(encoding=enc, errors="replace") |
| 414 | 591 | except FileNotFoundError: |
| 415 | 592 | sys.exit(f"nfogen: logo not found: {logo_path}") |
| 593 | elif resolve(args.banner, "banner", False): |
| 594 | logo = banner(args.banner_text or config.get("banner_text") or group) |
| 416 | 595 | |
| 417 | 596 | roster = {k: config[k] for k in |
| 418 | 597 | ("news", "members", "couriers", "boards", "affiliates", "outposts") |