krz/nfogen

A scene-style ASCII .nfo generator.

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

2a15d6b26a392341420db24bfb17a8ec28d4ff18

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-08T06:11:22Z

add mediainfo import, ASCII banner generator, and template gallery (tier 4)
 README.nfo                        |  11 ++-
 examples/mediainfo-demo.cp437.nfo |  39 ++++++++
 examples/mediainfo-demo.nfo       |  39 ++++++++
 examples/mediainfo-demo.toml      |  32 +++++++
 examples/sample.mediainfo.json    |  13 +++
 nfogen.py                         | 185 +++++++++++++++++++++++++++++++++++++-
 nfogen.toml                       |  11 ++-
 templates/minimal.toml            |   6 ++
 templates/modern.toml             |   6 ++
 templates/razor.toml              |  12 +++
 10 files changed, 347 insertions(+), 7 deletions(-)

diff --git a/README.nfo b/README.nfo
index 7ec181f..9376346 100644
--- a/README.nfo
+++ b/README.nfo
@@ -45,8 +45,15 @@
 │            members, couriers, boards, affiliates,            │
 │            news, outposts. release cfg overrides it.         │
 │                                                              │
-│   OPTIONS: -o -c -p -g -s -w --style --layout --logo         │
-│            --encoding --presents --footer -i --no-input      │
+│   MEDIAINFO: --mediainfo dump.json auto-fills                │
+│              video/audio/res/size/runtime/format.            │
+│   BANNER: --banner makes an ASCII logo (--banner-text)       │
+│   TEMPLATES: -t razor|modern|minimal presets                 │
+│              (--list-templates)                              │
+│                                                              │
+│   OPTIONS: -o -c -p -t -g -s -w --style --layout             │
+│            --logo --banner --mediainfo --encoding            │
+│            --presents --footer -i --no-input                 │
 │                                                              │
 │   Generated by nfogen itself.                                │
 ├──────────────────────────────────────────────────────────────┤
diff --git a/examples/mediainfo-demo.cp437.nfo b/examples/mediainfo-demo.cp437.nfo
new file mode 100644
index 0000000..8f4b2e5
--- /dev/null
+++ b/examples/mediainfo-demo.cp437.nfo
@@ -0,0 +1,39 @@
+                              #   #  ####   #####
+                              #  #   #   #     #
+                              ###    ####     #
+                              #  #   #  #    #
+                              #   #  #   #  #####
+

+             THE.MIDNIGHT.CARTOGRAPHER.2026.1080P.BLURAY.X264-KRZ            
+                               [ KRZ ]   krz.sh                              
+                         -={ KRZ proudly presents }=-                        

+         Release Information                     Game Information           

+  DATE     : 2026-08-08                 TYPE       : MOVIE                  
+  SOURCE   : BluRay (Retail)            FORMAT     : Matroska               
+  CRACKER  : n/a                        VIDEO      : x264, 8500 kbps, 23... 
+  SUPPLIER : The Witch King             AUDIO      : DTS, 5.1, 1509 kbps .. 
+                                        RESOLUTION : 1920x1080              
+                                        LANGUAGE   : en (subs: en, es, fr)  
+                                        GENRE      : Drama / Mystery        
+                                        RUNTIME    : 1h 52m                 
+                                        SIZE       : 8.42 GiB               
+                                        URL        : imdb.com/title/tt999.. 

+   NOTES                                                                     
+                                                                             
+   A quiet cartographer maps a city that rearranges itself                   
+   after midnight. Media details below were read straight from               
+   the source with mediainfo -- nothing typed by hand.                       

+   GREETS                                                                    
+                                                                             
+   the-scene   our-suppliers   you                                           

+                                                                             
+             SUPPORT THE COMPANIES THAT PRODUCE QUALITY SOFTWARE             
+                     if you enjoyed this release, buy it!                    
+                                                                             

diff --git a/examples/mediainfo-demo.nfo b/examples/mediainfo-demo.nfo
new file mode 100644
index 0000000..0342079
--- /dev/null
+++ b/examples/mediainfo-demo.nfo
@@ -0,0 +1,39 @@
+                              #   #  ####   #####
+                              #  #   #   #     #
+                              ###    ####     #
+                              #  #   #  #    #
+                              #   #  #   #  #####
+
+╔═════════════════════════════════════════════════════════════════════════════╗
+║             THE.MIDNIGHT.CARTOGRAPHER.2026.1080P.BLURAY.X264-KRZ            ║
+║                               [ KRZ ]   krz.sh                              ║
+║                         -={ KRZ proudly presents }=-                        ║
+╠══════════════════════════════════════╦══════════════════════════════════════╣
+║         Release Information          ║           Game Information           ║
+╠══════════════════════════════════════╬══════════════════════════════════════╣
+║  DATE     : 2026-08-08               ║  TYPE       : MOVIE                  ║
+║  SOURCE   : BluRay (Retail)          ║  FORMAT     : Matroska               ║
+║  CRACKER  : n/a                      ║  VIDEO      : x264, 8500 kbps, 23... ║
+║  SUPPLIER : The Witch King           ║  AUDIO      : DTS, 5.1, 1509 kbps .. ║
+║                                      ║  RESOLUTION : 1920x1080              ║
+║                                      ║  LANGUAGE   : en (subs: en, es, fr)  ║
+║                                      ║  GENRE      : Drama / Mystery        ║
+║                                      ║  RUNTIME    : 1h 52m                 ║
+║                                      ║  SIZE       : 8.42 GiB               ║
+║                                      ║  URL        : imdb.com/title/tt999.. ║
+╠══════════════════════════════════════╩══════════════════════════════════════╣
+║   NOTES                                                                     ║
+║                                                                             ║
+║   A quiet cartographer maps a city that rearranges itself                   ║
+║   after midnight. Media details below were read straight from               ║
+║   the source with mediainfo -- nothing typed by hand.                       ║
+╠═════════════════════════════════════════════════════════════════════════════╣
+║   GREETS                                                                    ║
+║                                                                             ║
+║   the-scene   our-suppliers   you                                           ║
+╠═════════════════════════════════════════════════════════════════════════════╣
+║                                                                             ║
+║             SUPPORT THE COMPANIES THAT PRODUCE QUALITY SOFTWARE             ║
+║                     if you enjoyed this release, buy it!                    ║
+║                                                                             ║
+╚═════════════════════════════════════════════════════════════════════════════╝
diff --git a/examples/mediainfo-demo.toml b/examples/mediainfo-demo.toml
new file mode 100644
index 0000000..f7f875d
--- /dev/null
+++ b/examples/mediainfo-demo.toml
@@ -0,0 +1,32 @@
+# Tier 4 demo: the media fields (video/audio/resolution/size/runtime/format/
+# language) are auto-filled from a mediainfo dump, and the header logo is a
+# generated ASCII banner. Regenerate with:
+#   python3 nfogen.py -c examples/mediainfo-demo.toml -o examples/mediainfo-demo.nfo
+group    = "KRZ"
+site     = "krz.sh"
+style    = "double"
+layout   = "panel"
+presents = true
+footer   = true
+
+banner      = true
+banner_text = "KRZ"
+
+mediainfo = "examples/sample.mediainfo.json"
+
+title    = "The.Midnight.Cartographer.2026.1080p.BluRay.x264-KRZ"
+date     = "2026-08-08"
+type     = "MOVIE"
+source   = "BluRay (Retail)"
+cracker  = "n/a"
+supplier = "The Witch King"
+genre    = "Drama / Mystery"
+url      = "imdb.com/title/tt9999999"
+
+notes = """
+A quiet cartographer maps a city that rearranges itself
+after midnight. Media details below were read straight from
+the source with mediainfo -- nothing typed by hand.
+"""
+
+greets = ["the-scene", "our-suppliers", "you"]
diff --git a/examples/sample.mediainfo.json b/examples/sample.mediainfo.json
new file mode 100644
index 0000000..3f444de
--- /dev/null
+++ b/examples/sample.mediainfo.json
@@ -0,0 +1,13 @@
+{
+  "media": {
+    "track": [
+      { "@type": "General", "Format": "Matroska", "FileSize": "9040412672", "Duration": "6742.038" },
+      { "@type": "Video", "Format": "AVC", "Encoded_Library_Name": "x264",
+        "Width": "1920", "Height": "1080", "BitRate": "8500000", "FrameRate": "23.976" },
+      { "@type": "Audio", "Format": "DTS", "Channels": "6", "BitRate": "1509000", "Language": "en" },
+      { "@type": "Text", "Language": "en" },
+      { "@type": "Text", "Language": "es" },
+      { "@type": "Text", "Language": "fr" }
+    ]
+  }
+}
diff --git a/nfogen.py b/nfogen.py
index aae6817..cd44a21 100755
--- a/nfogen.py
+++ b/nfogen.py
@@ -10,12 +10,15 @@ from __future__ import annotations
 import argparse
 import json
 import os
+import subprocess
 import sys
 import textwrap
 from datetime import date
 from itertools import zip_longest
 from pathlib import Path
 
+SCRIPT_DIR = Path(__file__).resolve().parent
+
 # --- field schema ------------------------------------------------------------
 # One source of truth. Drives argparse flags, interactive prompts, and render.
 #   key    : internal name / config key / flag name
@@ -112,6 +115,104 @@ def load_config(explicit: str | None) -> dict:
     return tomllib.loads(text)
 
 
+# --- mediainfo import --------------------------------------------------------
+def load_mediainfo(path: str) -> dict:
+    """Read a `mediainfo --Output=JSON` dump (or run mediainfo on a media file)
+    and return values for the video/audio/resolution/size/runtime/format keys."""
+    p = Path(path)
+    if p.suffix.lower() == ".json":
+        raw = p.read_text(encoding="utf-8")
+    else:
+        try:
+            proc = subprocess.run(["mediainfo", "--Output=JSON", str(p)],
+                                  capture_output=True, text=True, check=True)
+        except FileNotFoundError:
+            sys.exit("nfogen: mediainfo not installed; pass a JSON dump to --mediainfo")
+        except subprocess.CalledProcessError as e:
+            sys.exit(f"nfogen: mediainfo failed: {(e.stderr or '').strip()}")
+        raw = proc.stdout
+    try:
+        doc = json.loads(raw)
+    except json.JSONDecodeError:
+        sys.exit(f"nfogen: not valid mediainfo JSON: {path}")
+    return _extract_media(doc)
+
+
+def _mi_int(x) -> str:
+    try:
+        return str(int(float(str(x).replace(" ", ""))))
+    except ValueError:
+        return str(x)
+
+
+def _mi_size(b) -> str:
+    try:
+        n = float(b)
+    except (TypeError, ValueError):
+        return str(b)
+    for unit, div in (("GiB", 1 << 30), ("MiB", 1 << 20), ("KiB", 1 << 10)):
+        if n >= div:
+            return f"{n / div:.2f} {unit}"
+    return f"{int(n)} B"
+
+
+def _mi_duration(s) -> str:
+    try:
+        sec = int(float(s))
+    except (TypeError, ValueError):
+        return str(s)
+    h, m = sec // 3600, (sec % 3600) // 60
+    return f"{h}h {m:02d}m" if h else f"{m}m {sec % 60:02d}s"
+
+
+def _extract_media(doc: dict) -> dict:
+    tracks = (doc.get("media") or {}).get("track") or []
+    g = next((t for t in tracks if t.get("@type") == "General"), {})
+    v = next((t for t in tracks if t.get("@type") == "Video"), {})
+    auds = [t for t in tracks if t.get("@type") == "Audio"]
+    txts = [t for t in tracks if t.get("@type") == "Text"]
+    a = auds[0] if auds else {}
+    channels = {"1": "1.0", "2": "2.0", "6": "5.1", "8": "7.1"}
+    out: dict = {}
+
+    if g.get("Format"):
+        out["format"] = g["Format"]
+    if g.get("FileSize"):
+        out["size"] = _mi_size(g["FileSize"])
+    if g.get("Duration") or v.get("Duration"):
+        out["runtime"] = _mi_duration(g.get("Duration") or v.get("Duration"))
+
+    if v:
+        parts = [v.get("Encoded_Library_Name") or v.get("Format")]
+        if v.get("BitRate"):
+            parts.append(f"{int(float(v['BitRate'])) // 1000} kbps")
+        if v.get("FrameRate"):
+            parts.append(f"{float(v['FrameRate']):g} fps")
+        out["video"] = ", ".join(p for p in parts if p)
+        if v.get("Width") and v.get("Height"):
+            out["resolution"] = f"{_mi_int(v['Width'])}x{_mi_int(v['Height'])}"
+
+    if a:
+        parts = [a.get("Format")]
+        if a.get("Channels"):
+            parts.append(channels.get(str(a["Channels"]), f"{a['Channels']}ch"))
+        if a.get("BitRate"):
+            parts.append(f"{int(float(a['BitRate'])) // 1000} kbps")
+        audio = ", ".join(p for p in parts if p)
+        if a.get("Language"):
+            audio = f"{audio} ({a['Language']})" if audio else a["Language"]
+        out["audio"] = audio
+
+    subs = list(dict.fromkeys(t.get("Language") for t in txts if t.get("Language")))
+    lang = a.get("Language") or ""
+    if subs:
+        out["language"] = (f"{lang} (subs: {', '.join(subs)})").strip()
+    elif lang:
+        out["language"] = lang
+
+    return {k: val for k, val in out.items() if val}
+
+
 # --- interactive -------------------------------------------------------------
 def prompt_missing(data: dict, force_all: bool) -> None:
     """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]:
     return widths
 
 
+# A compact 5-row block font for the banner generator. Each glyph is five
+# rows joined by "/". Uppercase letters, digits, space, and a few symbols.
+_FONT = {
+    "A": " ### /#   #/#####/#   #/#   #", "B": "#### /#   #/#### /#   #/#### ",
+    "C": " ####/#    /#    /#    / ####", "D": "#### /#   #/#   #/#   #/#### ",
+    "E": "#####/#    /###  /#    /#####", "F": "#####/#    /###  /#    /#    ",
+    "G": " ####/#    /#  ##/#   #/ ####", "H": "#   #/#   #/#####/#   #/#   #",
+    "I": "#####/  #  /  #  /  #  /#####", "J": "#####/   # /   # /#  # / ##  ",
+    "K": "#   #/#  # /###  /#  # /#   #", "L": "#    /#    /#    /#    /#####",
+    "M": "#   #/## ##/# # #/#   #/#   #", "N": "#   #/##  #/# # #/#  ##/#   #",
+    "O": " ### /#   #/#   #/#   #/ ### ", "P": "#### /#   #/#### /#    /#    ",
+    "Q": " ### /#   #/# # #/#  # / ## #", "R": "#### /#   #/#### /#  # /#   #",
+    "S": " ####/#    / ### /    #/#### ", "T": "#####/  #  /  #  /  #  /  #  ",
+    "U": "#   #/#   #/#   #/#   #/ ### ", "V": "#   #/#   #/#   #/ # # /  #  ",
+    "W": "#   #/#   #/# # #/## ##/#   #", "X": "#   #/ # # /  #  / # # /#   #",
+    "Y": "#   #/ # # /  #  /  #  /  #  ", "Z": "#####/   # /  #  / #   /#####",
+    "0": " ### /#  ##/# # #/##  #/ ### ", "1": "  #  / ##  /  #  /  #  /#####",
+    "2": " ### /#   #/  ## / #   /#####", "3": "#### /    #/ ### /    #/#### ",
+    "4": "#   #/#   #/#####/    #/    #", "5": "#####/#    /#### /    #/#### ",
+    "6": " ####/#    /#### /#   #/ ### ", "7": "#####/   # /  #  / #   /#    ",
+    "8": " ### /#   #/ ### /#   #/ ### ", "9": " ### /#   #/ ####/    #/#### ",
+    " ": "     /     /     /     /     ", "-": "     /     /#####/     /     ",
+    ".": "     /     /     /     /  #  ", "!": "  #  /  #  /  #  /     /  #  ",
+    ":": "     /  #  /     /  #  /     ",
+}
+
+
+def banner(text: str) -> str:
+    """Render text as a 5-row ASCII banner using the built-in block font."""
+    rows = ["", "", "", "", ""]
+    for ch in text.upper():
+        glyph = _FONT.get(ch, _FONT[" "]).split("/")
+        for i in range(5):
+            rows[i] += glyph[i] + "  "
+    return "\n".join(r.rstrip() for r in rows)
+
+
 def render(data: dict, group: str, site: str, width: int,
            style: str = DEFAULT_STYLE, presents: bool = False,
            footer: str | None = None, layout: str = DEFAULT_LAYOUT,
@@ -338,6 +476,12 @@ def build_parser() -> argparse.ArgumentParser:
                    help="config file (.toml or .json) with field defaults")
     p.add_argument("-p", "--profile", metavar="PATH",
                    help="group profile (logo, roster, boards) merged under the config")
+    p.add_argument("-t", "--template", metavar="NAME",
+                   help="bundled style template (see --list-templates)")
+    p.add_argument("--list-templates", action="store_true",
+                   help="list bundled templates and exit")
+    p.add_argument("--mediainfo", metavar="PATH",
+                   help="mediainfo JSON dump (or media file) to auto-fill media fields")
     p.add_argument("-o", "--output", metavar="PATH",
                    help="write to PATH instead of stdout")
     p.add_argument("-g", "--group", help=f"release group (default {DEFAULT_GROUP})")
@@ -352,6 +496,9 @@ def build_parser() -> argparse.ArgumentParser:
                    help="art file to place above the box")
     p.add_argument("--logo-encoding", choices=["cp437", "utf8"],
                    help="encoding of the logo file (default cp437)")
+    p.add_argument("--banner", action=argparse.BooleanOptionalAction,
+                   default=None, help="generate an ASCII banner logo (defaults to the group)")
+    p.add_argument("--banner-text", help="text for the generated banner")
     p.add_argument("--encoding", choices=["utf8", "cp437"], default="utf8",
                    help="output encoding (default utf8; cp437 for true DOS nfos)")
     p.add_argument("--presents", action=argparse.BooleanOptionalAction,
@@ -368,15 +515,37 @@ def build_parser() -> argparse.ArgumentParser:
     return p
 
 
+def _templates_dir() -> Path:
+    return SCRIPT_DIR / "templates"
+
+
+def _list_templates() -> list[str]:
+    d = _templates_dir()
+    return sorted(p.stem for p in d.glob("*.toml")) if d.is_dir() else []
+
+
 def main(argv: list[str] | None = None) -> int:
     args = build_parser().parse_args(argv)
+
+    if args.list_templates:
+        names = _list_templates()
+        print("\n".join(names) if names else "nfogen: no templates bundled")
+        return 0
+
     release = load_config(args.config)
 
-    # a group profile supplies base values (logo, roster, style, footer, ...);
-    # the release config overrides it, and CLI flags override both.
+    # layered base: a style template underneath a group profile; the release
+    # config overrides both, and CLI flags override everything.
+    template = {}
+    if args.template:
+        tpath = _templates_dir() / f"{args.template}.toml"
+        if not tpath.exists():
+            sys.exit(f"nfogen: unknown template '{args.template}' "
+                     f"(have: {', '.join(_list_templates()) or 'none'})")
+        template = load_config(str(tpath))
     profile_path = args.profile or release.get("profile")
     profile = load_config(profile_path) if profile_path else {}
-    config = {**profile, **release}
+    config = {**template, **profile, **release}
 
     # merge: config defaults, then any CLI flag that was supplied
     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:
         val = getattr(args, key)
         if val is not None:
             data[key] = val
+
+    # mediainfo fills any media field not already set by config or flags
+    mediainfo_src = args.mediainfo or config.get("mediainfo")
+    if mediainfo_src:
+        for key, val in load_mediainfo(mediainfo_src).items():
+            data.setdefault(key, val)
+
     data.setdefault("date", date.today().isoformat())
 
     group = args.group or config.get("group") or DEFAULT_GROUP
@@ -405,6 +581,7 @@ def main(argv: list[str] | None = None) -> int:
     panel_titles = (config.get("panel_left") or PANEL_TITLES[0],
                     config.get("panel_right") or PANEL_TITLES[1])
 
+    # logo: an art file wins; otherwise an optional generated banner
     logo = None
     logo_path = args.logo or config.get("logo")
     if logo_path:
@@ -413,6 +590,8 @@ def main(argv: list[str] | None = None) -> int:
             logo = Path(logo_path).read_text(encoding=enc, errors="replace")
         except FileNotFoundError:
             sys.exit(f"nfogen: logo not found: {logo_path}")
+    elif resolve(args.banner, "banner", False):
+        logo = banner(args.banner_text or config.get("banner_text") or group)
 
     roster = {k: config[k] for k in
               ("news", "members", "couriers", "boards", "affiliates", "outposts")
diff --git a/nfogen.toml b/nfogen.toml
index 1a44471..aa1fcc6 100644
--- a/nfogen.toml
+++ b/nfogen.toml
@@ -44,8 +44,15 @@ PROFILE: -p group.toml supplies logo, footer,
          members, couriers, boards, affiliates,
          news, outposts. release cfg overrides it.
 
-OPTIONS: -o -c -p -g -s -w --style --layout --logo
-         --encoding --presents --footer -i --no-input
+MEDIAINFO: --mediainfo dump.json auto-fills
+           video/audio/res/size/runtime/format.
+BANNER: --banner makes an ASCII logo (--banner-text)
+TEMPLATES: -t razor|modern|minimal presets
+           (--list-templates)
+
+OPTIONS: -o -c -p -t -g -s -w --style --layout
+         --logo --banner --mediainfo --encoding
+         --presents --footer -i --no-input
 
 Generated by nfogen itself.
 """
diff --git a/templates/minimal.toml b/templates/minimal.toml
new file mode 100644
index 0000000..fbeedee
--- /dev/null
+++ b/templates/minimal.toml
@@ -0,0 +1,6 @@
+# Bare card: single-line box, simple rows, house width. Just the facts.
+style    = "single"
+layout   = "rows"
+width    = 64
+presents = false
+footer   = false
diff --git a/templates/modern.toml b/templates/modern.toml
new file mode 100644
index 0000000..a94caf8
--- /dev/null
+++ b/templates/modern.toml
@@ -0,0 +1,6 @@
+# Cleaner modern look: single-line box, two-column panel, no scene boilerplate.
+style    = "single"
+layout   = "panel"
+width    = 79
+presents = false
+footer   = false
diff --git a/templates/razor.toml b/templates/razor.toml
new file mode 100644
index 0000000..a390992
--- /dev/null
+++ b/templates/razor.toml
@@ -0,0 +1,12 @@
+# RAZOR-era look: double box, two-column panel, generated banner + presents,
+# and the classic disclaimer footer. Bring your own title and fields.
+style    = "double"
+layout   = "panel"
+width    = 79
+presents = true
+banner   = true
+footer   = true
+footer_text = """
+SUPPORT THE COMPANIES THAT PRODUCE QUALITY SOFTWARE
+if you enjoyed this release, buy the original!
+"""