krz/nfogen

A scene-style ASCII .nfo generator.

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

v1.2.1: 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 subprocess
 13import sys
 14import textwrap
 15from datetime import date
 16from itertools import zip_longest
 17from pathlib import Path
 18
 19SCRIPT_DIR = Path(__file__).resolve().parent
 20
 21# --- field schema ------------------------------------------------------------
 22# One source of truth. Drives argparse flags, interactive prompts, and render.
 23#   key    : internal name / config key / flag name
 24#   label  : shown in the rendered nfo (info rows) or as a section header
 25#   help   : argparse + prompt help text
 26#   kind   : "line"  scalar shown as an aligned INFO row
 27#            "list"  many values, shown space-joined in its own section
 28#            "block" free text, wrapped in its own section
 29#   col    : "L"/"R" panel column for "line" fields; None otherwise
 30
 31FIELDS = [
 32    ("title",      "TITLE",      "release title (required)",            "line",  None),
 33    # --- release information (left panel) ---
 34    ("date",       "DATE",       "release date",                        "line",  "L"),
 35    ("source",     "SOURCE",     "source (BluRay, WEB-DL, CD, ...)",    "line",  "L"),
 36    ("cracker",    "CRACKER",    "cracker / cracked by",                "line",  "L"),
 37    ("supplier",   "SUPPLIER",   "supplier / supplied by",              "line",  "L"),
 38    ("packager",   "PACKAGER",   "packager / packed by",                "line",  "L"),
 39    ("protection", "PROTECTION", "protection type",                     "line",  "L"),
 40    ("install",    "INSTALL",    "install method",                      "line",  "L"),
 41    ("rating",     "RATING",     "rating",                              "line",  "L"),
 42    # --- game / media information (right panel) ---
 43    ("type",       "TYPE",       "release type (MOVIE, TV, APP, ...)",  "line",  "R"),
 44    ("publisher",  "PUBLISHER",  "publisher / vendor",                  "line",  "R"),
 45    ("format",     "FORMAT",     "container / format",                  "line",  "R"),
 46    ("disks",      "DISKS",      "number of disks / archives",          "line",  "R"),
 47    ("video",      "VIDEO",      "video codec / details",               "line",  "R"),
 48    ("audio",      "AUDIO",      "audio codec / details",               "line",  "R"),
 49    ("resolution", "RESOLUTION", "resolution",                          "line",  "R"),
 50    ("language",   "LANGUAGE",   "language(s)",                         "line",  "R"),
 51    ("genre",      "GENRE",      "genre",                               "line",  "R"),
 52    ("runtime",    "RUNTIME",    "runtime / duration",                  "line",  "R"),
 53    ("size",       "SIZE",       "total size",                          "line",  "R"),
 54    ("files",      "FILES",      "file count / listing",                "line",  "R"),
 55    ("url",        "URL",        "reference url (imdb, homepage, ...)", "line",  "R"),
 56    # --- free-form sections ---
 57    ("notes",      "NOTES",      "free-text notes / description",       "block", None),
 58    ("greets",     "GREETS",     "groups to greet (space/comma sep)",   "list",  None),
 59]
 60FIELD_KINDS = {k: kind for k, _, _, kind, _ in FIELDS}
 61INFO_KEYS = [k for k, _, _, kind, _ in FIELDS if kind == "line" and k != "title"]
 62
 63DEFAULT_SITE = "krz.sh"
 64DEFAULT_GROUP = "KRZ"
 65DEFAULT_WIDTH = 79  # DOS 80-column standard, less one margin column
 66DEFAULT_STYLE = "double"
 67DEFAULT_FOOTER = (
 68    "SUPPORT THE COMPANIES THAT PRODUCE QUALITY SOFTWARE\n"
 69    "if you enjoyed this release, buy it!"
 70)
 71DEFAULT_LAYOUT = "rows"
 72PANEL_TITLES = ("Release Information", "Game Information")
 73
 74# Box-drawing character sets. "single"/"double" are CP437-encodable; "block"
 75# is a solid fill frame. Keys: corners + horizontal/vertical + tee separators.
 76# jt/jm/jb are the down-tee, cross, and up-tee used where the panel's
 77# vertical column divider meets a horizontal rule.
 78STYLES = {
 79    "single": {"tl": "", "tr": "", "bl": "", "br": "", "h": "", "v": "",
 80               "ls": "", "rs": "", "jt": "", "jm": "", "jb": ""},
 81    "double": {"tl": "", "tr": "", "bl": "", "br": "", "h": "", "v": "",
 82               "ls": "", "rs": "", "jt": "", "jm": "", "jb": ""},
 83    "block":  {"tl": "", "tr": "", "bl": "", "br": "", "h": "", "v": "",
 84               "ls": "", "rs": "", "jt": "", "jm": "", "jb": ""},
 85}
 86
 87CONFIG_CANDIDATES = [
 88    Path("nfogen.toml"),
 89    Path(".nfogen.toml"),
 90    Path.home() / ".config" / "nfogen" / "config.toml",
 91]
 92
 93
 94# --- config ------------------------------------------------------------------
 95def load_config(explicit: str | None) -> dict:
 96    """Load defaults from a TOML or JSON file. Returns {} if none found."""
 97    path = None
 98    if explicit:
 99        path = Path(explicit)  # NOSONAR local CLI: the user's own path, no trust boundary
100        if not path.exists():
101            sys.exit(f"nfogen: config not found: {explicit}")
102    else:
103        path = next((p for p in CONFIG_CANDIDATES if p.exists()), None)
104    if path is None:
105        return {}
106
107    text = path.read_text(encoding="utf-8")  # NOSONAR local CLI: the user's own path
108    if path.suffix == ".json":
109        return json.loads(text)
110    try:
111        import tomllib
112    except ModuleNotFoundError:
113        sys.exit("nfogen: TOML config needs Python 3.11+ (or use a .json file)")
114    return tomllib.loads(text)
115
116
117# --- mediainfo import --------------------------------------------------------
118_MI_TYPE = "@type"  # mediainfo track-type key
119
120
121def load_mediainfo(path: str) -> dict:
122    """Read a `mediainfo --Output=JSON` dump (or run mediainfo on a media file)
123    and return values for the video/audio/resolution/size/runtime/format keys."""
124    p = Path(path)  # NOSONAR local CLI: the user's own path, no trust boundary
125    if p.suffix.lower() == ".json":
126        raw = p.read_text(encoding="utf-8")  # NOSONAR local CLI: the user's own path
127    else:
128        try:
129            proc = subprocess.run(["mediainfo", "--Output=JSON", str(p)],  # NOSONAR local CLI: the user's own file
130                                  capture_output=True, text=True, check=True)
131        except FileNotFoundError:
132            sys.exit("nfogen: mediainfo not installed; pass a JSON dump to --mediainfo")
133        except subprocess.CalledProcessError as e:
134            sys.exit(f"nfogen: mediainfo failed: {(e.stderr or '').strip()}")
135        raw = proc.stdout
136    try:
137        doc = json.loads(raw)
138    except json.JSONDecodeError:
139        sys.exit(f"nfogen: not valid mediainfo JSON: {path}")
140    return _extract_media(doc)
141
142
143def _mi_int(x) -> str:
144    try:
145        return str(int(float(str(x).replace(" ", ""))))
146    except ValueError:
147        return str(x)
148
149
150def _mi_size(b) -> str:
151    try:
152        n = float(b)
153    except (TypeError, ValueError):
154        return str(b)
155    for unit, div in (("GiB", 1 << 30), ("MiB", 1 << 20), ("KiB", 1 << 10)):
156        if n >= div:
157            return f"{n / div:.2f} {unit}"
158    return f"{int(n)} B"
159
160
161def _mi_duration(s) -> str:
162    try:
163        sec = int(float(s))
164    except (TypeError, ValueError):
165        return str(s)
166    h, m = sec // 3600, (sec % 3600) // 60
167    return f"{h}h {m:02d}m" if h else f"{m}m {sec % 60:02d}s"
168
169
170def _mi_tracks(tracks: list, kind: str) -> list:
171    return [t for t in tracks if t.get(_MI_TYPE) == kind]
172
173
174def _mi_general(g: dict) -> dict:
175    out = {}
176    if g.get("Format"):
177        out["format"] = g["Format"]
178    if g.get("FileSize"):
179        out["size"] = _mi_size(g["FileSize"])
180    return out
181
182
183def _mi_video(v: dict) -> dict:
184    if not v:
185        return {}
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    return out
195
196
197def _mi_audio(a: dict) -> dict:
198    if not a:
199        return {}
200    channels = {"1": "1.0", "2": "2.0", "6": "5.1", "8": "7.1"}
201    parts = [a.get("Format")]
202    if a.get("Channels"):
203        parts.append(channels.get(str(a["Channels"]), f"{a['Channels']}ch"))
204    if a.get("BitRate"):
205        parts.append(f"{int(float(a['BitRate'])) // 1000} kbps")
206    audio = ", ".join(p for p in parts if p)
207    if a.get("Language"):
208        audio = f"{audio} ({a['Language']})" if audio else a["Language"]
209    return {"audio": audio}
210
211
212def _mi_language(a: dict, txts: list) -> str:
213    subs = list(dict.fromkeys(t.get("Language") for t in txts if t.get("Language")))
214    lang = a.get("Language") or ""
215    if subs:
216        return f"{lang} (subs: {', '.join(subs)})".strip()
217    return lang
218
219
220def _extract_media(doc: dict) -> dict:
221    tracks = (doc.get("media") or {}).get("track") or []
222    g = next(iter(_mi_tracks(tracks, "General")), {})
223    v = next(iter(_mi_tracks(tracks, "Video")), {})
224    auds = _mi_tracks(tracks, "Audio")
225    txts = _mi_tracks(tracks, "Text")
226    a = auds[0] if auds else {}
227
228    out = {**_mi_general(g), **_mi_video(v), **_mi_audio(a)}
229    dur = g.get("Duration") or v.get("Duration")
230    if dur:
231        out["runtime"] = _mi_duration(dur)
232    lang = _mi_language(a, txts)
233    if lang:
234        out["language"] = lang
235
236    return {k: val for k, val in out.items() if val}
237
238
239# --- interactive -------------------------------------------------------------
240def prompt_missing(data: dict, force_all: bool) -> None:
241    """Fill fields from stdin. Prompts every field when force_all, else only
242    the missing ones. Enter keeps the current/blank value."""
243    keys = [k for k, *_ in FIELDS] if force_all else \
244        [k for k, *_ in FIELDS if not data.get(k)]
245    if not keys:
246        return
247    print("nfogen: interactive mode (blank to skip)\n", file=sys.stderr)
248    for key in keys:
249        _, label, help_text, _, _ = next(f for f in FIELDS if f[0] == key)
250        current = data.get(key, "")
251        suffix = f" [{current}]" if current else ""
252        try:
253            answer = input(f"{label} ({help_text}){suffix}: ").strip()
254        except EOFError:
255            break
256        if answer:
257            data[key] = answer
258
259
260# --- rendering ---------------------------------------------------------------
261def _split_list(value) -> list[str]:
262    if isinstance(value, (list, tuple)):
263        return [str(v).strip() for v in value if str(v).strip()]
264    return [p for p in str(value).replace(",", " ").split() if p]
265
266
267def _col_widths(available: int, fracs: list[float]) -> list[int]:
268    """Split `available` chars into columns by fraction; last column absorbs
269    the remainder so the widths always sum exactly."""
270    widths = [max(4, int(available * f)) for f in fracs[:-1]]
271    widths.append(available - sum(widths))
272    return widths
273
274
275# A compact 5-row block font for the banner generator. Each glyph is five
276# rows joined by "/". Uppercase letters, digits, space, and a few symbols.
277_FONT = {
278    "A": " ### /#   #/#####/#   #/#   #", "B": "#### /#   #/#### /#   #/#### ",
279    "C": " ####/#    /#    /#    / ####", "D": "#### /#   #/#   #/#   #/#### ",
280    "E": "#####/#    /###  /#    /#####", "F": "#####/#    /###  /#    /#    ",
281    "G": " ####/#    /#  ##/#   #/ ####", "H": "#   #/#   #/#####/#   #/#   #",
282    "I": "#####/  #  /  #  /  #  /#####", "J": "#####/   # /   # /#  # / ##  ",
283    "K": "#   #/#  # /###  /#  # /#   #", "L": "#    /#    /#    /#    /#####",
284    "M": "#   #/## ##/# # #/#   #/#   #", "N": "#   #/##  #/# # #/#  ##/#   #",
285    "O": " ### /#   #/#   #/#   #/ ### ", "P": "#### /#   #/#### /#    /#    ",
286    "Q": " ### /#   #/# # #/#  # / ## #", "R": "#### /#   #/#### /#  # /#   #",
287    "S": " ####/#    / ### /    #/#### ", "T": "#####/  #  /  #  /  #  /  #  ",
288    "U": "#   #/#   #/#   #/#   #/ ### ", "V": "#   #/#   #/#   #/ # # /  #  ",
289    "W": "#   #/#   #/# # #/## ##/#   #", "X": "#   #/ # # /  #  / # # /#   #",
290    "Y": "#   #/ # # /  #  /  #  /  #  ", "Z": "#####/   # /  #  / #   /#####",
291    "0": " ### /#  ##/# # #/##  #/ ### ", "1": "  #  / ##  /  #  /  #  /#####",
292    "2": " ### /#   #/  ## / #   /#####", "3": "#### /    #/ ### /    #/#### ",
293    "4": "#   #/#   #/#####/    #/    #", "5": "#####/#    /#### /    #/#### ",
294    "6": " ####/#    /#### /#   #/ ### ", "7": "#####/   # /  #  / #   /#    ",
295    "8": " ### /#   #/ ### /#   #/ ### ", "9": " ### /#   #/ ####/    #/#### ",
296    " ": "     /     /     /     /     ", "-": "     /     /#####/     /     ",
297    ".": "     /     /     /     /  #  ", "!": "  #  /  #  /  #  /     /  #  ",
298    ":": "     /  #  /     /  #  /     ",
299}
300
301
302def banner(text: str) -> str:
303    """Render text as a 5-row ASCII banner using the built-in block font."""
304    rows = ["", "", "", "", ""]
305    for ch in text.upper():
306        glyph = _FONT.get(ch, _FONT[" "]).split("/")
307        for i in range(5):
308            rows[i] += glyph[i] + "  "
309    return "\n".join(r.rstrip() for r in rows)
310
311
312class _Canvas:
313    """Accumulates boxed lines and holds the geometry for a width and style."""
314
315    def __init__(self, width: int, style: str):
316        self.s = STYLES[style]
317        self.width = width
318        self.inner = width - 4        # single-column content width
319        self.cl = (width - 7) // 2    # left panel content width
320        self.cr = (width - 7) - self.cl
321        self.rfill = width - self.cl - 5
322        self.out: list[str] = []
323        self.prev_panel = False
324
325    def add(self, text: str = "") -> None:
326        self.out.append(text)
327
328    def hrule(self, left: str, right: str, joint: str | None = None) -> str:
329        h = self.s["h"]
330        if joint is None:
331            return left + h * (self.width - 2) + right
332        return left + h * (self.cl + 2) + joint + h * self.rfill + right
333
334    def line(self, content: str = "") -> str:
335        v = self.s["v"]
336        return f"{v} " + content.ljust(self.inner) + f" {v}"
337
338    def center(self, content: str) -> str:
339        v = self.s["v"]
340        return f"{v} " + content.center(self.inner) + f" {v}"
341
342    def prow(self, left: str, right: str, centered: bool = False) -> str:
343        v = self.s["v"]
344        fn = str.center if centered else str.ljust
345        return (f"{v} " + fn(_clip(left, self.cl), self.cl)
346                + f" {v} " + fn(_clip(right, self.cr), self.cr) + f" {v}")
347
348    def sep_before(self) -> str:
349        joint = self.s["jb"] if self.prev_panel else None
350        self.prev_panel = False
351        return self.hrule(self.s["ls"], self.s["rs"], joint)
352
353    def section(self, header: str) -> None:
354        self.add(self.sep_before())
355        self.add(self.line(f"  {header}"))
356        self.add(self.line())
357
358    def names_block(self, names: list[str]) -> None:
359        for wrapped in textwrap.wrap("   ".join(names), self.inner - 4):
360            self.add(self.center(wrapped))
361        self.add(self.line())
362
363    def wrapped_body(self, text: str) -> None:
364        for para in text.splitlines() or [""]:
365            for chunk in (textwrap.wrap(para, self.inner - 4) or [""]):
366                self.add(self.line("  " + chunk))
367
368
369def _render_logo(cvs: _Canvas, logo: str | None) -> None:
370    if not logo:
371        return
372    art = logo.rstrip("\n").split("\n")
373    pad = max((cvs.width - max((len(a) for a in art), default=0)) // 2, 0)
374    cvs.out.extend((" " * pad + a).rstrip() for a in art)
375    cvs.add("")
376
377
378def _render_header(cvs: _Canvas, data: dict, group: str, site: str,
379                   presents: bool) -> None:
380    title = str(data.get("title") or "UNTITLED").upper()
381    spaced = " ".join(title)
382    heading = spaced if len(spaced) <= cvs.inner else title
383    cvs.add(cvs.center(_clip(heading, cvs.inner)))
384    cvs.add(cvs.center(_clip(f"[ {group} ]   {site}", cvs.inner)))
385    if presents:
386        cvs.add(cvs.center(
387            _clip(f"-={{ {group.upper()} proudly presents }}=-", cvs.inner)))
388
389
390def _line_fields(data: dict) -> list:
391    return [(k, lbl, col) for k, lbl, _, kind, col in FIELDS
392            if kind == "line" and k != "title" and data.get(k)]
393
394
395def _render_panel(cvs: _Canvas, data: dict, fields: list,
396                  panel_titles: tuple[str, str]) -> None:
397    s = cvs.s
398    left = [(lbl, str(data[k])) for k, lbl, col in fields if col == "L"]
399    right = [(lbl, str(data[k])) for k, lbl, col in fields if col == "R"]
400    lw_l = max((len(lbl) for lbl, _ in left), default=0)
401    lw_r = max((len(lbl) for lbl, _ in right), default=0)
402
403    def cell(pair, lw):
404        return f" {pair[0]:<{lw}} : {pair[1]}" if pair else ""
405
406    cvs.add(cvs.hrule(s["ls"], s["rs"], s["jt"]))
407    cvs.add(cvs.prow(panel_titles[0], panel_titles[1], centered=True))
408    cvs.add(cvs.hrule(s["ls"], s["rs"], s["jm"]))
409    for lp, rp in zip_longest(left, right):
410        cvs.add(cvs.prow(cell(lp, lw_l), cell(rp, lw_r)))
411    cvs.prev_panel = True
412
413
414def _render_rows(cvs: _Canvas, data: dict, fields: list) -> None:
415    cvs.add(cvs.hrule(cvs.s["ls"], cvs.s["rs"]))
416    lw = max(len(lbl) for _, lbl, _ in fields)
417    avail = cvs.inner - (2 + lw + 2)
418    for k, lbl, _ in fields:
419        wrapped = textwrap.wrap(str(data[k]), avail) or [""]
420        cvs.add(cvs.line(f"  {lbl:<{lw}}  {wrapped[0]}"))
421        for cont in wrapped[1:]:
422            cvs.add(cvs.line(" " * (2 + lw + 2) + cont))
423
424
425def _render_info(cvs: _Canvas, data: dict, layout: str,
426                 panel_titles: tuple[str, str]) -> None:
427    fields = _line_fields(data)
428    if not fields:
429        return
430    if layout == "panel":
431        _render_panel(cvs, data, fields, panel_titles)
432    else:
433        _render_rows(cvs, data, fields)
434
435
436def _render_notes(cvs: _Canvas, data: dict) -> None:
437    notes = str(data.get("notes") or "").strip()
438    if not notes:
439        return
440    cvs.section("NOTES")
441    cvs.wrapped_body(notes)
442
443
444def _render_greets(cvs: _Canvas, data: dict) -> None:
445    greets = _split_list(data.get("greets") or [])
446    if not greets:
447        return
448    cvs.section("GREETS")
449    for wrapped in textwrap.wrap("   ".join(greets), cvs.inner - 4):
450        cvs.add(cvs.line("  " + wrapped))
451
452
453def _render_table(cvs: _Canvas, header: str, entries: list) -> None:
454    cvs.section(header)
455    if entries and isinstance(entries[0], dict):
456        ws = _col_widths(cvs.inner - 2, [0.34, 0.24, 0.22, 0.20])
457        keys = ["name", "role", "sysop", "phone"]
458
459        def row(vals):
460            cells = "".join(_clip(str(x), w - 1).ljust(w)
461                            for x, w in zip(vals, ws))
462            return cvs.line("  " + cells)
463
464        cvs.add(row(["BOARD", "ROLE", "SYSOP", "CONTACT"]))
465        for e in entries:
466            cvs.add(row([e.get(k, "") for k in keys]))
467    else:
468        cvs.names_block(_split_list(entries))
469
470
471def _render_roster(cvs: _Canvas, roster: dict | None) -> None:
472    r = roster or {}
473    news = str(r.get("news") or "").strip()
474    if news:
475        cvs.section("GROUP NEWS")
476        cvs.wrapped_body(news)
477    members = _split_list(r.get("members") or [])
478    if members:
479        cvs.section("MEMBERS")
480        cvs.names_block(members)
481    couriers = r.get("couriers") or {}
482    if couriers:
483        cvs.section("COURIERS")
484        for tier, who in couriers.items():
485            cvs.add(cvs.center(f"- {tier} -"))
486            cvs.names_block(_split_list(who))
487    if r.get("boards"):
488        _render_table(cvs, "BOARDS", r["boards"])
489    if r.get("affiliates"):
490        _render_table(cvs, "AFFILIATES", r["affiliates"])
491    outposts = _split_list(r.get("outposts") or [])
492    if outposts:
493        cvs.section("OUTPOSTS")
494        cvs.names_block(outposts)
495
496
497def _render_footer(cvs: _Canvas, footer: str | None) -> None:
498    if not footer:
499        return
500    cvs.add(cvs.sep_before())
501    cvs.add(cvs.line())
502    for para in footer.splitlines():
503        for wrapped in (textwrap.wrap(para, cvs.inner - 4) or [""]):
504            cvs.add(cvs.center(wrapped))
505    cvs.add(cvs.line())
506
507
508def render(data: dict, group: str, site: str, width: int,
509           style: str = DEFAULT_STYLE, presents: bool = False,
510           footer: str | None = None, layout: str = DEFAULT_LAYOUT,
511           logo: str | None = None, roster: dict | None = None,
512           panel_titles: tuple[str, str] = PANEL_TITLES) -> str:
513    cvs = _Canvas(width, style)
514    _render_logo(cvs, logo)
515    cvs.add(cvs.hrule(cvs.s["tl"], cvs.s["tr"]))
516    _render_header(cvs, data, group, site, presents)
517    _render_info(cvs, data, layout, panel_titles)
518    _render_notes(cvs, data)
519    _render_greets(cvs, data)
520    _render_roster(cvs, roster)
521    _render_footer(cvs, footer)
522    cvs.add(cvs.hrule(cvs.s["bl"], cvs.s["br"],
523                      cvs.s["jb"] if cvs.prev_panel else None))
524    return "\n".join(cvs.out) + "\n"
525
526
527def _clip(text: str, width: int) -> str:
528    return text if len(text) <= width else text[: width - 2] + ".."
529
530
531# --- cli ---------------------------------------------------------------------
532def build_parser() -> argparse.ArgumentParser:
533    p = argparse.ArgumentParser(
534        prog="nfogen",
535        description="Generate a scene-style ASCII .nfo file.",
536    )
537    p.add_argument("-c", "--config", metavar="PATH",
538                   help="config file (.toml or .json) with field defaults")
539    p.add_argument("-p", "--profile", metavar="PATH",
540                   help="group profile (logo, roster, boards) merged under the config")
541    p.add_argument("-t", "--template", metavar="NAME",
542                   help="bundled style template (see --list-templates)")
543    p.add_argument("--list-templates", action="store_true",
544                   help="list bundled templates and exit")
545    p.add_argument("--mediainfo", metavar="PATH",
546                   help="mediainfo JSON dump (or media file) to auto-fill media fields")
547    p.add_argument("-o", "--output", metavar="PATH",
548                   help="write to PATH instead of stdout")
549    p.add_argument("-g", "--group", help=f"release group (default {DEFAULT_GROUP})")
550    p.add_argument("-s", "--site", help=f"site tag (default {DEFAULT_SITE})")
551    p.add_argument("-w", "--width", type=int,
552                   help=f"box width in chars (default {DEFAULT_WIDTH})")
553    p.add_argument("--style", choices=sorted(STYLES),
554                   help=f"box-drawing style (default {DEFAULT_STYLE})")
555    p.add_argument("--layout", choices=["rows", "panel"],
556                   help=f"info layout (default {DEFAULT_LAYOUT})")
557    p.add_argument("--logo", metavar="PATH",
558                   help="art file to place above the box")
559    p.add_argument("--logo-encoding", choices=["cp437", "utf8"],
560                   help="encoding of the logo file (default cp437)")
561    p.add_argument("--banner", action=argparse.BooleanOptionalAction,
562                   default=None, help="generate an ASCII banner logo (defaults to the group)")
563    p.add_argument("--banner-text", help="text for the generated banner")
564    p.add_argument("--encoding", choices=["utf8", "cp437"], default="utf8",
565                   help="output encoding (default utf8; cp437 for true DOS nfos)")
566    p.add_argument("--presents", action=argparse.BooleanOptionalAction,
567                   default=None, help="show the 'proudly presents' banner")
568    p.add_argument("--footer", action=argparse.BooleanOptionalAction,
569                   default=None, help="show the closing disclaimer block")
570    p.add_argument("--footer-text", help="custom disclaimer text")
571    p.add_argument("-i", "--interactive", action="store_true",
572                   help="prompt for every field")
573    p.add_argument("--no-input", action="store_true",
574                   help="never prompt, even if fields are missing")
575    for key, _, help_text, _, _ in FIELDS:
576        p.add_argument(f"--{key}", help=help_text)
577    return p
578
579
580def _templates_dir() -> Path:
581    return SCRIPT_DIR / "templates"
582
583
584def _list_templates() -> list[str]:
585    d = _templates_dir()
586    return sorted(p.stem for p in d.glob("*.toml")) if d.is_dir() else []
587
588
589def _resolve(flag, config: dict, key: str, default):
590    return flag if flag is not None else config.get(key, default)
591
592
593def _layered_config(args) -> dict:
594    """Merge template < profile < release-config (CLI flags applied later)."""
595    release = load_config(args.config)
596    template = {}
597    if args.template:
598        tpath = _templates_dir() / f"{args.template}.toml"
599        if not tpath.exists():
600            sys.exit(f"nfogen: unknown template '{args.template}' "
601                     f"(have: {', '.join(_list_templates()) or 'none'})")
602        template = load_config(str(tpath))
603    profile_path = args.profile or release.get("profile")
604    profile = load_config(profile_path) if profile_path else {}
605    return {**template, **profile, **release}
606
607
608def _collect_data(args, config: dict) -> dict:
609    data = {k: config[k] for k, *_ in FIELDS if k in config and config[k] != ""}
610    for key, *_ in FIELDS:
611        val = getattr(args, key)
612        if val is not None:
613            data[key] = val
614    mediainfo_src = args.mediainfo or config.get("mediainfo")
615    if mediainfo_src:
616        for key, val in load_mediainfo(mediainfo_src).items():
617            data.setdefault(key, val)
618    data.setdefault("date", date.today().isoformat())
619    return data
620
621
622def _fill_interactive(args, data: dict) -> None:
623    interactive = sys.stdin.isatty() and not args.no_input
624    if args.interactive:
625        prompt_missing(data, force_all=True)
626    elif not data.get("title") and interactive:
627        prompt_missing(data, force_all=False)
628
629
630def _resolve_logo(args, config: dict, group: str) -> str | None:
631    """An explicit art file wins; otherwise an optional generated banner."""
632    logo_path = args.logo or config.get("logo")
633    if logo_path:
634        enc = args.logo_encoding or config.get("logo_encoding") or "cp437"
635        try:
636            return Path(logo_path).read_text(encoding=enc, errors="replace")  # NOSONAR local CLI: the user's own path
637        except FileNotFoundError:
638            sys.exit(f"nfogen: logo not found: {logo_path}")
639    if _resolve(args.banner, config, "banner", False):
640        return banner(args.banner_text or config.get("banner_text") or group)
641    return None
642
643
644def _resolve_options(args, config: dict) -> dict:
645    group = args.group or config.get("group") or DEFAULT_GROUP
646    show_footer = _resolve(args.footer, config, "footer", False)
647    footer_text = args.footer_text or config.get("footer_text") or DEFAULT_FOOTER
648    return {
649        "group": group,
650        "site": args.site or config.get("site") or DEFAULT_SITE,
651        "width": args.width or config.get("width") or DEFAULT_WIDTH,
652        "style": args.style or config.get("style") or DEFAULT_STYLE,
653        "presents": _resolve(args.presents, config, "presents", False),
654        "footer": footer_text if show_footer else None,
655        "layout": args.layout or config.get("layout") or DEFAULT_LAYOUT,
656        "logo": _resolve_logo(args, config, group),
657        "roster": {k: config[k] for k in
658                   ("news", "members", "couriers", "boards", "affiliates", "outposts")
659                   if config.get(k)},
660        "panel_titles": (config.get("panel_left") or PANEL_TITLES[0],
661                         config.get("panel_right") or PANEL_TITLES[1]),
662    }
663
664
665def _write_output(text: str, args) -> None:
666    if args.encoding == "cp437":
667        raw = text.encode("cp437", errors="replace")
668        if args.output:
669            Path(args.output).write_bytes(raw)  # NOSONAR local CLI: the user's own path
670        else:
671            sys.stdout.buffer.write(raw)
672    elif args.output:
673        Path(args.output).write_text(text, encoding="utf-8")  # NOSONAR local CLI: the user's own path
674    else:
675        sys.stdout.write(text)
676
677
678def main(argv: list[str] | None = None) -> None:
679    args = build_parser().parse_args(argv)
680
681    if args.list_templates:
682        names = _list_templates()
683        print("\n".join(names) if names else "nfogen: no templates bundled")
684        return
685
686    config = _layered_config(args)
687    data = _collect_data(args, config)
688    _fill_interactive(args, data)
689    if not data.get("title"):
690        sys.exit("nfogen: a title is required (use --title, a config, or -i)")
691
692    text = render(data, **_resolve_options(args, config))
693    _write_output(text, args)
694
695
696if __name__ == "__main__":
697    main()