krz/nfogen

A scene-style ASCII .nfo generator.

clone: git clone https://gitbay.org/krz/nfogen.git

v1.2.0: nfogen.py · raw

  1#!/usr/bin/env python3
  2"""nfogen - a scene-style NFO generator.
  3
  4Builds a boxed ASCII .nfo from CLI flags, an optional config file, and
  5interactive prompts for anything still missing.
  6"""
  7
  8from __future__ import annotations
  9
 10import argparse
 11import json
 12import os
 13import subprocess
 14import sys
 15import textwrap
 16from datetime import date
 17from itertools import zip_longest
 18from pathlib import Path
 19
 20SCRIPT_DIR = Path(__file__).resolve().parent
 21
 22# --- field schema ------------------------------------------------------------
 23# One source of truth. Drives argparse flags, interactive prompts, and render.
 24#   key    : internal name / config key / flag name
 25#   label  : shown in the rendered nfo (info rows) or as a section header
 26#   help   : argparse + prompt help text
 27#   kind   : "line"  scalar shown as an aligned INFO row
 28#            "list"  many values, shown space-joined in its own section
 29#            "block" free text, wrapped in its own section
 30#   col    : "L"/"R" panel column for "line" fields; None otherwise
 31
 32FIELDS = [
 33    ("title",      "TITLE",      "release title (required)",            "line",  None),
 34    # --- release information (left panel) ---
 35    ("date",       "DATE",       "release date",                        "line",  "L"),
 36    ("source",     "SOURCE",     "source (BluRay, WEB-DL, CD, ...)",    "line",  "L"),
 37    ("cracker",    "CRACKER",    "cracker / cracked by",                "line",  "L"),
 38    ("supplier",   "SUPPLIER",   "supplier / supplied by",              "line",  "L"),
 39    ("packager",   "PACKAGER",   "packager / packed by",                "line",  "L"),
 40    ("protection", "PROTECTION", "protection type",                     "line",  "L"),
 41    ("install",    "INSTALL",    "install method",                      "line",  "L"),
 42    ("rating",     "RATING",     "rating",                              "line",  "L"),
 43    # --- game / media information (right panel) ---
 44    ("type",       "TYPE",       "release type (MOVIE, TV, APP, ...)",  "line",  "R"),
 45    ("publisher",  "PUBLISHER",  "publisher / vendor",                  "line",  "R"),
 46    ("format",     "FORMAT",     "container / format",                  "line",  "R"),
 47    ("disks",      "DISKS",      "number of disks / archives",          "line",  "R"),
 48    ("video",      "VIDEO",      "video codec / details",               "line",  "R"),
 49    ("audio",      "AUDIO",      "audio codec / details",               "line",  "R"),
 50    ("resolution", "RESOLUTION", "resolution",                          "line",  "R"),
 51    ("language",   "LANGUAGE",   "language(s)",                         "line",  "R"),
 52    ("genre",      "GENRE",      "genre",                               "line",  "R"),
 53    ("runtime",    "RUNTIME",    "runtime / duration",                  "line",  "R"),
 54    ("size",       "SIZE",       "total size",                          "line",  "R"),
 55    ("files",      "FILES",      "file count / listing",                "line",  "R"),
 56    ("url",        "URL",        "reference url (imdb, homepage, ...)", "line",  "R"),
 57    # --- free-form sections ---
 58    ("notes",      "NOTES",      "free-text notes / description",       "block", None),
 59    ("greets",     "GREETS",     "groups to greet (space/comma sep)",   "list",  None),
 60]
 61FIELD_KINDS = {k: kind for k, _, _, kind, _ in FIELDS}
 62INFO_KEYS = [k for k, _, _, kind, _ in FIELDS if kind == "line" and k != "title"]
 63
 64DEFAULT_SITE = "krz.sh"
 65DEFAULT_GROUP = "KRZ"
 66DEFAULT_WIDTH = 79  # DOS 80-column standard, less one margin column
 67DEFAULT_STYLE = "double"
 68DEFAULT_FOOTER = (
 69    "SUPPORT THE COMPANIES THAT PRODUCE QUALITY SOFTWARE\n"
 70    "if you enjoyed this release, buy it!"
 71)
 72DEFAULT_LAYOUT = "rows"
 73PANEL_TITLES = ("Release Information", "Game Information")
 74
 75# Box-drawing character sets. "single"/"double" are CP437-encodable; "block"
 76# is a solid fill frame. Keys: corners + horizontal/vertical + tee separators.
 77# jt/jm/jb are the down-tee, cross, and up-tee used where the panel's
 78# vertical column divider meets a horizontal rule.
 79STYLES = {
 80    "single": dict(tl="", tr="", bl="", br="", h="", v="",
 81                   ls="", rs="", jt="", jm="", jb=""),
 82    "double": dict(tl="", tr="", bl="", br="", h="", v="",
 83                   ls="", rs="", jt="", jm="", jb=""),
 84    "block":  dict(tl="", tr="", bl="", br="", h="", v="",
 85                   ls="", rs="", jt="", jm="", jb=""),
 86}
 87
 88CONFIG_CANDIDATES = [
 89    Path("nfogen.toml"),
 90    Path(".nfogen.toml"),
 91    Path.home() / ".config" / "nfogen" / "config.toml",
 92]
 93
 94
 95# --- config ------------------------------------------------------------------
 96def load_config(explicit: str | None) -> dict:
 97    """Load defaults from a TOML or JSON file. Returns {} if none found."""
 98    path = None
 99    if explicit:
100        path = Path(explicit)
101        if not path.exists():
102            sys.exit(f"nfogen: config not found: {explicit}")
103    else:
104        path = next((p for p in CONFIG_CANDIDATES if p.exists()), None)
105    if path is None:
106        return {}
107
108    text = path.read_text(encoding="utf-8")
109    if path.suffix == ".json":
110        return json.loads(text)
111    try:
112        import tomllib
113    except ModuleNotFoundError:
114        sys.exit("nfogen: TOML config needs Python 3.11+ (or use a .json file)")
115    return tomllib.loads(text)
116
117
118# --- mediainfo import --------------------------------------------------------
119def 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
141def _mi_int(x) -> str:
142    try:
143        return str(int(float(str(x).replace(" ", ""))))
144    except ValueError:
145        return str(x)
146
147
148def _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
159def _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
168def _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
216# --- interactive -------------------------------------------------------------
217def prompt_missing(data: dict, force_all: bool) -> None:
218    """Fill fields from stdin. Prompts every field when force_all, else only
219    the missing ones. Enter keeps the current/blank value."""
220    keys = [k for k, *_ in FIELDS] if force_all else \
221        [k for k, *_ in FIELDS if not data.get(k)]
222    if not keys:
223        return
224    print("nfogen: interactive mode (blank to skip)\n", file=sys.stderr)
225    for key in keys:
226        _, label, help_text, _, _ = next(f for f in FIELDS if f[0] == key)
227        current = data.get(key, "")
228        suffix = f" [{current}]" if current else ""
229        try:
230            answer = input(f"{label} ({help_text}){suffix}: ").strip()
231        except EOFError:
232            break
233        if answer:
234            data[key] = answer
235
236
237# --- rendering ---------------------------------------------------------------
238def _split_list(value) -> list[str]:
239    if isinstance(value, (list, tuple)):
240        return [str(v).strip() for v in value if str(v).strip()]
241    return [p for p in str(value).replace(",", " ").split() if p]
242
243
244def _col_widths(available: int, fracs: list[float]) -> list[int]:
245    """Split `available` chars into columns by fraction; last column absorbs
246    the remainder so the widths always sum exactly."""
247    widths = [max(4, int(available * f)) for f in fracs[:-1]]
248    widths.append(available - sum(widths))
249    return widths
250
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
279def 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
289def render(data: dict, group: str, site: str, width: int,
290           style: str = DEFAULT_STYLE, presents: bool = False,
291           footer: str | None = None, layout: str = DEFAULT_LAYOUT,
292           logo: str | None = None, roster: dict | None = None,
293           panel_titles: tuple[str, str] = PANEL_TITLES) -> str:
294    s = STYLES[style]
295    h, v = s["h"], s["v"]
296    inner = width - 4          # single-column content width (1 space padding)
297    cl = (width - 7) // 2      # left panel content width
298    cr = (width - 7) - cl      # right panel content width
299    rfill = width - cl - 5     # h-run right of the divider in a rule
300
301    def hrule(left: str, right: str, joint: str | None = None) -> str:
302        if joint is None:
303            return left + h * (width - 2) + right
304        return left + h * (cl + 2) + joint + h * rfill + right
305
306    def line(content: str = "") -> str:
307        return f"{v} " + content.ljust(inner) + f" {v}"
308
309    def center(content: str) -> str:
310        return f"{v} " + content.center(inner) + f" {v}"
311
312    def prow(left: str, right: str, centered: bool = False) -> str:
313        fn = str.center if centered else str.ljust
314        return (f"{v} " + fn(_clip(left, cl), cl)
315                + f" {v} " + fn(_clip(right, cr), cr) + f" {v}")
316
317    out: list[str] = []
318
319    # logo art above the box, block-centered to the full width
320    if logo:
321        art = logo.rstrip("\n").split("\n")
322        pad = max((width - max((len(a) for a in art), default=0)) // 2, 0)
323        out.extend((" " * pad + a).rstrip() for a in art)
324        out.append("")
325
326    out.append(hrule(s["tl"], s["tr"]))
327
328    # header: spaced title, then group tag + site, optional presents banner
329    title = str(data.get("title") or "UNTITLED").upper()
330    spaced = " ".join(title)
331    heading = spaced if len(spaced) <= inner else title
332    out.append(center(_clip(heading, inner)))
333    out.append(center(_clip(f"[ {group} ]   {site}", inner)))
334    if presents:
335        out.append(center(_clip(f"-={{ {group.upper()} proudly presents }}=-", inner)))
336
337    line_fields = [(k, lbl, col) for k, lbl, _, kind, col in FIELDS
338                   if kind == "line" and k != "title" and data.get(k)]
339    prev_panel = False
340
341    def sep_before() -> str:
342        nonlocal prev_panel
343        joint = s["jb"] if prev_panel else None
344        prev_panel = False
345        return hrule(s["ls"], s["rs"], joint)
346
347    # info: two-column panel or single-column rows
348    if layout == "panel" and line_fields:
349        left = [(lbl, str(data[k])) for k, lbl, col in line_fields if col == "L"]
350        right = [(lbl, str(data[k])) for k, lbl, col in line_fields if col == "R"]
351        lw_l = max((len(lbl) for lbl, _ in left), default=0)
352        lw_r = max((len(lbl) for lbl, _ in right), default=0)
353
354        def cell(pair, lw):
355            return f" {pair[0]:<{lw}} : {pair[1]}" if pair else ""
356
357        out.append(hrule(s["ls"], s["rs"], s["jt"]))
358        out.append(prow(panel_titles[0], panel_titles[1], centered=True))
359        out.append(hrule(s["ls"], s["rs"], s["jm"]))
360        for lp, rp in zip_longest(left, right):
361            out.append(prow(cell(lp, lw_l), cell(rp, lw_r)))
362        prev_panel = True
363    elif line_fields:
364        out.append(hrule(s["ls"], s["rs"]))
365        lw = max(len(lbl) for _, lbl, _ in line_fields)
366        avail = inner - (2 + lw + 2)
367        for k, lbl, _ in line_fields:
368            wrapped = textwrap.wrap(str(data[k]), avail) or [""]
369            out.append(line(f"  {lbl:<{lw}}  {wrapped[0]}"))
370            for cont in wrapped[1:]:
371                out.append(line(" " * (2 + lw + 2) + cont))
372
373    # notes block
374    notes = str(data.get("notes") or "").strip()
375    if notes:
376        out.append(sep_before())
377        out.append(line("  NOTES"))
378        out.append(line())
379        for para in notes.splitlines() or [""]:
380            for wrapped in (textwrap.wrap(para, inner - 4) or [""]):
381                out.append(line("  " + wrapped))
382
383    # greets block
384    greets = _split_list(data.get("greets") or [])
385    if greets:
386        out.append(sep_before())
387        out.append(line("  GREETS"))
388        out.append(line())
389        for wrapped in textwrap.wrap("   ".join(greets), inner - 4):
390            out.append(line("  " + wrapped))
391
392    # group roster sections (from a profile): news, members, couriers,
393    # boards/affiliates tables, outposts
394    r = roster or {}
395
396    def section(header: str) -> None:
397        out.append(sep_before())
398        out.append(line(f"  {header}"))
399        out.append(line())
400
401    def names_block(names: list[str]) -> None:
402        for wrapped in textwrap.wrap("   ".join(names), inner - 4):
403            out.append(center(wrapped))
404        out.append(line())
405
406    def table(header: str, entries: list) -> None:
407        section(header)
408        if entries and isinstance(entries[0], dict):
409            ws = _col_widths(inner - 2, [0.34, 0.24, 0.22, 0.20])
410            keys = ["name", "role", "sysop", "phone"]
411
412            def row(vals: list[str]) -> str:
413                cells = "".join(_clip(str(x), w - 1).ljust(w)
414                                for x, w in zip(vals, ws))
415                return line("  " + cells)
416
417            out.append(row(["BOARD", "ROLE", "SYSOP", "CONTACT"]))
418            for e in entries:
419                out.append(row([e.get(k, "") for k in keys]))
420        else:
421            names_block(_split_list(entries))
422
423    news = str(r.get("news") or "").strip()
424    if news:
425        section("GROUP NEWS")
426        for para in news.splitlines():
427            for wrapped in (textwrap.wrap(para, inner - 4) or [""]):
428                out.append(line("  " + wrapped))
429
430    members = _split_list(r.get("members") or [])
431    if members:
432        section("MEMBERS")
433        names_block(members)
434
435    couriers = r.get("couriers") or {}
436    if couriers:
437        section("COURIERS")
438        for tier, who in couriers.items():
439            out.append(center(f"- {tier} -"))
440            names_block(_split_list(who))
441
442    if r.get("boards"):
443        table("BOARDS", r["boards"])
444    if r.get("affiliates"):
445        table("AFFILIATES", r["affiliates"])
446
447    outposts = _split_list(r.get("outposts") or [])
448    if outposts:
449        section("OUTPOSTS")
450        names_block(outposts)
451
452    # footer disclaimer, centered
453    if footer:
454        out.append(sep_before())
455        out.append(line())
456        for para in footer.splitlines():
457            for wrapped in (textwrap.wrap(para, inner - 4) or [""]):
458                out.append(center(wrapped))
459        out.append(line())
460
461    out.append(hrule(s["bl"], s["br"], s["jb"] if prev_panel else None))
462    return "\n".join(out) + "\n"
463
464
465def _clip(text: str, width: int) -> str:
466    return text if len(text) <= width else text[: width - 2] + ".."
467
468
469# --- cli ---------------------------------------------------------------------
470def build_parser() -> argparse.ArgumentParser:
471    p = argparse.ArgumentParser(
472        prog="nfogen",
473        description="Generate a scene-style ASCII .nfo file.",
474    )
475    p.add_argument("-c", "--config", metavar="PATH",
476                   help="config file (.toml or .json) with field defaults")
477    p.add_argument("-p", "--profile", metavar="PATH",
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")
485    p.add_argument("-o", "--output", metavar="PATH",
486                   help="write to PATH instead of stdout")
487    p.add_argument("-g", "--group", help=f"release group (default {DEFAULT_GROUP})")
488    p.add_argument("-s", "--site", help=f"site tag (default {DEFAULT_SITE})")
489    p.add_argument("-w", "--width", type=int,
490                   help=f"box width in chars (default {DEFAULT_WIDTH})")
491    p.add_argument("--style", choices=sorted(STYLES),
492                   help=f"box-drawing style (default {DEFAULT_STYLE})")
493    p.add_argument("--layout", choices=["rows", "panel"],
494                   help=f"info layout (default {DEFAULT_LAYOUT})")
495    p.add_argument("--logo", metavar="PATH",
496                   help="art file to place above the box")
497    p.add_argument("--logo-encoding", choices=["cp437", "utf8"],
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")
502    p.add_argument("--encoding", choices=["utf8", "cp437"], default="utf8",
503                   help="output encoding (default utf8; cp437 for true DOS nfos)")
504    p.add_argument("--presents", action=argparse.BooleanOptionalAction,
505                   default=None, help="show the 'proudly presents' banner")
506    p.add_argument("--footer", action=argparse.BooleanOptionalAction,
507                   default=None, help="show the closing disclaimer block")
508    p.add_argument("--footer-text", help="custom disclaimer text")
509    p.add_argument("-i", "--interactive", action="store_true",
510                   help="prompt for every field")
511    p.add_argument("--no-input", action="store_true",
512                   help="never prompt, even if fields are missing")
513    for key, _, help_text, _, _ in FIELDS:
514        p.add_argument(f"--{key}", help=help_text)
515    return p
516
517
518def _templates_dir() -> Path:
519    return SCRIPT_DIR / "templates"
520
521
522def _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
527def main(argv: list[str] | None = None) -> int:
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
535    release = load_config(args.config)
536
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))
546    profile_path = args.profile or release.get("profile")
547    profile = load_config(profile_path) if profile_path else {}
548    config = {**template, **profile, **release}
549
550    # merge: config defaults, then any CLI flag that was supplied
551    data = {k: config[k] for k, *_ in FIELDS if k in config and config[k] != ""}
552    for key, *_ in FIELDS:
553        val = getattr(args, key)
554        if val is not None:
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
563    data.setdefault("date", date.today().isoformat())
564
565    group = args.group or config.get("group") or DEFAULT_GROUP
566    site = args.site or config.get("site") or DEFAULT_SITE
567    width = args.width or config.get("width") or DEFAULT_WIDTH
568    style = args.style or config.get("style") or DEFAULT_STYLE
569
570    def resolve(flag, key, default):
571        if flag is not None:
572            return flag
573        return config.get(key, default)
574
575    presents = resolve(args.presents, "presents", False)
576    show_footer = resolve(args.footer, "footer", False)
577    footer_text = args.footer_text or config.get("footer_text") or DEFAULT_FOOTER
578    footer = footer_text if show_footer else None
579
580    layout = args.layout or config.get("layout") or DEFAULT_LAYOUT
581    panel_titles = (config.get("panel_left") or PANEL_TITLES[0],
582                    config.get("panel_right") or PANEL_TITLES[1])
583
584    # logo: an art file wins; otherwise an optional generated banner
585    logo = None
586    logo_path = args.logo or config.get("logo")
587    if logo_path:
588        enc = args.logo_encoding or config.get("logo_encoding") or "cp437"
589        try:
590            logo = Path(logo_path).read_text(encoding=enc, errors="replace")
591        except FileNotFoundError:
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)
595
596    roster = {k: config[k] for k in
597              ("news", "members", "couriers", "boards", "affiliates", "outposts")
598              if config.get(k)}
599
600    interactive = sys.stdin.isatty() and not args.no_input
601    if args.interactive:
602        prompt_missing(data, force_all=True)
603    elif not data.get("title") and interactive:
604        prompt_missing(data, force_all=False)
605
606    if not data.get("title"):
607        sys.exit("nfogen: a title is required (use --title, a config, or -i)")
608
609    text = render(data, group=group, site=site, width=width, style=style,
610                  presents=presents, footer=footer, layout=layout,
611                  logo=logo, roster=roster, panel_titles=panel_titles)
612
613    if args.encoding == "cp437":
614        raw = text.encode("cp437", errors="replace")
615        if args.output:
616            Path(args.output).write_bytes(raw)
617        else:
618            sys.stdout.buffer.write(raw)
619    elif args.output:
620        Path(args.output).write_text(text, encoding="utf-8")
621    else:
622        sys.stdout.write(text)
623    return 0
624
625
626if __name__ == "__main__":
627    raise SystemExit(main())