| @@ -10,12 +10,15 @@ from __future__ import annotations |
| 10 | import argparse |
10 | import argparse |
| 11 | import json |
11 | import json |
| 12 | import os |
12 | import os |
| |
13 | import subprocess |
| 13 | import sys |
14 | import sys |
| 14 | import textwrap |
15 | import textwrap |
| 15 | from datetime import date |
16 | from datetime import date |
| 16 | from itertools import zip_longest |
17 | from itertools import zip_longest |
| 17 | from pathlib import Path |
18 | from pathlib import Path |
| 18 | |
19 | |
| |
20 | SCRIPT_DIR = Path(__file__).resolve().parent |
| |
21 | |
| 19 | # --- field schema ------------------------------------------------------------ |
22 | # --- field schema ------------------------------------------------------------ |
| 20 | # One source of truth. Drives argparse flags, interactive prompts, and render. |
23 | # One source of truth. Drives argparse flags, interactive prompts, and render. |
| 21 | # key : internal name / config key / flag name |
24 | # key : internal name / config key / flag name |
| @@ -112,6 +115,104 @@ def load_config(explicit: str | None) -> dict: |
| 112 | return tomllib.loads(text) |
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 | # --- interactive ------------------------------------------------------------- |
216 | # --- interactive ------------------------------------------------------------- |
| 116 | def prompt_missing(data: dict, force_all: bool) -> None: |
217 | def prompt_missing(data: dict, force_all: bool) -> None: |
| 117 | """Fill fields from stdin. Prompts every field when force_all, else only |
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 | return widths |
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 | def render(data: dict, group: str, site: str, width: int, |
289 | def render(data: dict, group: str, site: str, width: int, |
| 152 | style: str = DEFAULT_STYLE, presents: bool = False, |
290 | style: str = DEFAULT_STYLE, presents: bool = False, |
| 153 | footer: str | None = None, layout: str = DEFAULT_LAYOUT, |
291 | footer: str | None = None, layout: str = DEFAULT_LAYOUT, |
| @@ -338,6 +476,12 @@ def build_parser() -> argparse.ArgumentParser: |
| 338 | help="config file (.toml or .json) with field defaults") |
476 | help="config file (.toml or .json) with field defaults") |
| 339 | p.add_argument("-p", "--profile", metavar="PATH", |
477 | p.add_argument("-p", "--profile", metavar="PATH", |
| 340 | help="group profile (logo, roster, boards) merged under the config") |
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 | p.add_argument("-o", "--output", metavar="PATH", |
485 | p.add_argument("-o", "--output", metavar="PATH", |
| 342 | help="write to PATH instead of stdout") |
486 | help="write to PATH instead of stdout") |
| 343 | p.add_argument("-g", "--group", help=f"release group (default {DEFAULT_GROUP})") |
487 | p.add_argument("-g", "--group", help=f"release group (default {DEFAULT_GROUP})") |
| @@ -352,6 +496,9 @@ def build_parser() -> argparse.ArgumentParser: |
| 352 | help="art file to place above the box") |
496 | help="art file to place above the box") |
| 353 | p.add_argument("--logo-encoding", choices=["cp437", "utf8"], |
497 | p.add_argument("--logo-encoding", choices=["cp437", "utf8"], |
| 354 | help="encoding of the logo file (default cp437)") |
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 | p.add_argument("--encoding", choices=["utf8", "cp437"], default="utf8", |
502 | p.add_argument("--encoding", choices=["utf8", "cp437"], default="utf8", |
| 356 | help="output encoding (default utf8; cp437 for true DOS nfos)") |
503 | help="output encoding (default utf8; cp437 for true DOS nfos)") |
| 357 | p.add_argument("--presents", action=argparse.BooleanOptionalAction, |
504 | p.add_argument("--presents", action=argparse.BooleanOptionalAction, |
| @@ -368,15 +515,37 @@ def build_parser() -> argparse.ArgumentParser: |
| 368 | return p |
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 | def main(argv: list[str] | None = None) -> int: |
527 | def main(argv: list[str] | None = None) -> int: |
| 372 | args = build_parser().parse_args(argv) |
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 | release = load_config(args.config) |
535 | release = load_config(args.config) |
| 374 | |
536 | |
| 375 | # a group profile supplies base values (logo, roster, style, footer, ...); |
537 | # layered base: a style template underneath a group profile; the release |
| 376 | # the release config overrides it, and CLI flags override both. |
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 | profile_path = args.profile or release.get("profile") |
546 | profile_path = args.profile or release.get("profile") |
| 378 | profile = load_config(profile_path) if profile_path else {} |
547 | profile = load_config(profile_path) if profile_path else {} |
| 379 | config = {**profile, **release} |
548 | config = {**template, **profile, **release} |
| 380 | |
549 | |
| 381 | # merge: config defaults, then any CLI flag that was supplied |
550 | # merge: config defaults, then any CLI flag that was supplied |
| 382 | data = {k: config[k] for k, *_ in FIELDS if k in config and config[k] != ""} |
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 | val = getattr(args, key) |
553 | val = getattr(args, key) |
| 385 | if val is not None: |
554 | if val is not None: |
| 386 | data[key] = val |
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 | data.setdefault("date", date.today().isoformat()) |
563 | data.setdefault("date", date.today().isoformat()) |
| 388 | |
564 | |
| 389 | group = args.group or config.get("group") or DEFAULT_GROUP |
565 | group = args.group or config.get("group") or DEFAULT_GROUP |
| @@ -405,6 +581,7 @@ def main(argv: list[str] | None = None) -> int: |
| 405 | panel_titles = (config.get("panel_left") or PANEL_TITLES[0], |
581 | panel_titles = (config.get("panel_left") or PANEL_TITLES[0], |
| 406 | config.get("panel_right") or PANEL_TITLES[1]) |
582 | config.get("panel_right") or PANEL_TITLES[1]) |
| 407 | |
583 | |
| |
584 | # logo: an art file wins; otherwise an optional generated banner |
| 408 | logo = None |
585 | logo = None |
| 409 | logo_path = args.logo or config.get("logo") |
586 | logo_path = args.logo or config.get("logo") |
| 410 | if logo_path: |
587 | if logo_path: |
| @@ -413,6 +590,8 @@ def main(argv: list[str] | None = None) -> int: |
| 413 | logo = Path(logo_path).read_text(encoding=enc, errors="replace") |
590 | logo = Path(logo_path).read_text(encoding=enc, errors="replace") |
| 414 | except FileNotFoundError: |
591 | except FileNotFoundError: |
| 415 | sys.exit(f"nfogen: logo not found: {logo_path}") |
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 | roster = {k: config[k] for k in |
596 | roster = {k: config[k] for k in |
| 418 | ("news", "members", "couriers", "boards", "affiliates", "outposts") |
597 | ("news", "members", "couriers", "boards", "affiliates", "outposts") |