krz/nfogen

A scene-style ASCII .nfo generator. ascii cli generator nfo

Commit 2a15d6b26a

2a15d6b26a392341420db24bfb17a8ec28d4ff18

parent: e7963c089a

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-08 06:11 UTC

add mediainfo import, ASCII banner generator, and template gallery (tier 4)

Layout: unified · split

README.nfo +9 −2
@@ -45,8 +45,15 @@
4545│ members, couriers, boards, affiliates, │
4646│ news, outposts. release cfg overrides it. │
4747│ │
48│ OPTIONS: -o -c -p -g -s -w --style --layout --logo │
49│ --encoding --presents --footer -i --no-input │
48│ MEDIAINFO: --mediainfo dump.json auto-fills │
49│ video/audio/res/size/runtime/format. │
50│ BANNER: --banner makes an ASCII logo (--banner-text) │
51│ TEMPLATES: -t razor|modern|minimal presets │
52│ (--list-templates) │
53│ │
54│ OPTIONS: -o -c -p -t -g -s -w --style --layout │
55│ --logo --banner --mediainfo --encoding │
56│ --presents --footer -i --no-input │
5057│ │
5158│ Generated by nfogen itself. │
5259├──────────────────────────────────────────────────────────────┤
examples/mediainfo-demo.cp437.nfo added +39
@@ -0,0 +1,39 @@
1 # # #### #####
2 # # # # #
3 ### #### #
4 # # # # #
5 # # # # #####
6
7ÉÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍ»
8º THE.MIDNIGHT.CARTOGRAPHER.2026.1080P.BLURAY.X264-KRZ º
9º [ KRZ ] krz.sh º
10º -={ KRZ proudly presents }=- º
11ÌÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍËÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍ͹
12º Release Information º Game Information º
13ÌÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÎÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍ͹
14º DATE : 2026-08-08 º TYPE : MOVIE º
15º SOURCE : BluRay (Retail) º FORMAT : Matroska º
16º CRACKER : n/a º VIDEO : x264, 8500 kbps, 23... º
17º SUPPLIER : The Witch King º AUDIO : DTS, 5.1, 1509 kbps .. º
18º º RESOLUTION : 1920x1080 º
19º º LANGUAGE : en (subs: en, es, fr) º
20º º GENRE : Drama / Mystery º
21º º RUNTIME : 1h 52m º
22º º SIZE : 8.42 GiB º
23º º URL : imdb.com/title/tt999.. º
24ÌÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÊÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍ͹
25º NOTES º
26º º
27º A quiet cartographer maps a city that rearranges itself º
28º after midnight. Media details below were read straight from º
29º the source with mediainfo -- nothing typed by hand. º
30ÌÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍ͹
31º GREETS º
32º º
33º the-scene our-suppliers you º
34ÌÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍ͹
35º º
36º SUPPORT THE COMPANIES THAT PRODUCE QUALITY SOFTWARE º
37º if you enjoyed this release, buy it! º
38º º
39ÈÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍÍͼ
examples/mediainfo-demo.nfo added +39
@@ -0,0 +1,39 @@
1 # # #### #####
2 # # # # #
3 ### #### #
4 # # # # #
5 # # # # #####
6
7╔�����������������������������������������������������������������������������╗
8â•‘ THE.MIDNIGHT.CARTOGRAPHER.2026.1080P.BLURAY.X264-KRZ â•‘
9â•‘ [ KRZ ] krz.sh â•‘
10â•‘ -={ KRZ proudly presents }=- â•‘
11╠��������������������������������������╦��������������������������������������╣
12â•‘ Release Information â•‘ Game Information â•‘
13╠��������������������������������������╬��������������������������������������╣
14â•‘ DATE : 2026-08-08 â•‘ TYPE : MOVIE â•‘
15â•‘ SOURCE : BluRay (Retail) â•‘ FORMAT : Matroska â•‘
16â•‘ CRACKER : n/a â•‘ VIDEO : x264, 8500 kbps, 23... â•‘
17â•‘ SUPPLIER : The Witch King â•‘ AUDIO : DTS, 5.1, 1509 kbps .. â•‘
18â•‘ â•‘ RESOLUTION : 1920x1080 â•‘
19â•‘ â•‘ LANGUAGE : en (subs: en, es, fr) â•‘
20â•‘ â•‘ GENRE : Drama / Mystery â•‘
21â•‘ â•‘ RUNTIME : 1h 52m â•‘
22â•‘ â•‘ SIZE : 8.42 GiB â•‘
23â•‘ â•‘ URL : imdb.com/title/tt999.. â•‘
24╠��������������������������������������╩��������������������������������������╣
25â•‘ NOTES â•‘
26â•‘ â•‘
27â•‘ A quiet cartographer maps a city that rearranges itself â•‘
28â•‘ after midnight. Media details below were read straight from â•‘
29â•‘ the source with mediainfo -- nothing typed by hand. â•‘
30╠�����������������������������������������������������������������������������╣
31â•‘ GREETS â•‘
32â•‘ â•‘
33â•‘ the-scene our-suppliers you â•‘
34╠�����������������������������������������������������������������������������╣
35â•‘ â•‘
36â•‘ SUPPORT THE COMPANIES THAT PRODUCE QUALITY SOFTWARE â•‘
37â•‘ if you enjoyed this release, buy it! â•‘
38â•‘ â•‘
39╚������������������������������������������������������������������������������
examples/mediainfo-demo.toml added +32
@@ -0,0 +1,32 @@
1# Tier 4 demo: the media fields (video/audio/resolution/size/runtime/format/
2# language) are auto-filled from a mediainfo dump, and the header logo is a
3# generated ASCII banner. Regenerate with:
4# python3 nfogen.py -c examples/mediainfo-demo.toml -o examples/mediainfo-demo.nfo
5group = "KRZ"
6site = "krz.sh"
7style = "double"
8layout = "panel"
9presents = true
10footer = true
11
12banner = true
13banner_text = "KRZ"
14
15mediainfo = "examples/sample.mediainfo.json"
16
17title = "The.Midnight.Cartographer.2026.1080p.BluRay.x264-KRZ"
18date = "2026-08-08"
19type = "MOVIE"
20source = "BluRay (Retail)"
21cracker = "n/a"
22supplier = "The Witch King"
23genre = "Drama / Mystery"
24url = "imdb.com/title/tt9999999"
25
26notes = """
27A quiet cartographer maps a city that rearranges itself
28after midnight. Media details below were read straight from
29the source with mediainfo -- nothing typed by hand.
30"""
31
32greets = ["the-scene", "our-suppliers", "you"]
examples/sample.mediainfo.json added +13
@@ -0,0 +1,13 @@
1{
2 "media": {
3 "track": [
4 { "@type": "General", "Format": "Matroska", "FileSize": "9040412672", "Duration": "6742.038" },
5 { "@type": "Video", "Format": "AVC", "Encoded_Library_Name": "x264",
6 "Width": "1920", "Height": "1080", "BitRate": "8500000", "FrameRate": "23.976" },
7 { "@type": "Audio", "Format": "DTS", "Channels": "6", "BitRate": "1509000", "Language": "en" },
8 { "@type": "Text", "Language": "en" },
9 { "@type": "Text", "Language": "es" },
10 { "@type": "Text", "Language": "fr" }
11 ]
12 }
13}
nfogen.py +182 −3
@@ -10,12 +10,15 @@ from __future__ import annotations
1010import argparse
1111import json
1212import os
13import subprocess
1314import sys
1415import textwrap
1516from datetime import date
1617from itertools import zip_longest
1718from pathlib import Path
1819
20SCRIPT_DIR = Path(__file__).resolve().parent
21
1922# --- field schema ------------------------------------------------------------
2023# One source of truth. Drives argparse flags, interactive prompts, and render.
2124# key : internal name / config key / flag name
@@ -112,6 +115,104 @@ def load_config(explicit: str | None) -> dict:
112115 return tomllib.loads(text)
113116
114117
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
115216# --- interactive -------------------------------------------------------------
116217def prompt_missing(data: dict, force_all: bool) -> None:
117218 """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]:
148249 return widths
149250
150251
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
151289def render(data: dict, group: str, site: str, width: int,
152290 style: str = DEFAULT_STYLE, presents: bool = False,
153291 footer: str | None = None, layout: str = DEFAULT_LAYOUT,
@@ -338,6 +476,12 @@ def build_parser() -> argparse.ArgumentParser:
338476 help="config file (.toml or .json) with field defaults")
339477 p.add_argument("-p", "--profile", metavar="PATH",
340478 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")
341485 p.add_argument("-o", "--output", metavar="PATH",
342486 help="write to PATH instead of stdout")
343487 p.add_argument("-g", "--group", help=f"release group (default {DEFAULT_GROUP})")
@@ -352,6 +496,9 @@ def build_parser() -> argparse.ArgumentParser:
352496 help="art file to place above the box")
353497 p.add_argument("--logo-encoding", choices=["cp437", "utf8"],
354498 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")
355502 p.add_argument("--encoding", choices=["utf8", "cp437"], default="utf8",
356503 help="output encoding (default utf8; cp437 for true DOS nfos)")
357504 p.add_argument("--presents", action=argparse.BooleanOptionalAction,
@@ -368,15 +515,37 @@ def build_parser() -> argparse.ArgumentParser:
368515 return p
369516
370517
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
371527def main(argv: list[str] | None = None) -> int:
372528 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
373535 release = load_config(args.config)
374536
375 # a group profile supplies base values (logo, roster, style, footer, ...);
376 # the release config overrides it, and CLI flags override both.
537 # layered base: a style template underneath a group profile; the release
538 # config overrides both, and CLI flags override everything.
539 template = {}
540 if args.template:
541 tpath = _templates_dir() / f"{args.template}.toml"
542 if not tpath.exists():
543 sys.exit(f"nfogen: unknown template '{args.template}' "
544 f"(have: {', '.join(_list_templates()) or 'none'})")
545 template = load_config(str(tpath))
377546 profile_path = args.profile or release.get("profile")
378547 profile = load_config(profile_path) if profile_path else {}
379 config = {**profile, **release}
548 config = {**template, **profile, **release}
380549
381550 # merge: config defaults, then any CLI flag that was supplied
382551 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:
384553 val = getattr(args, key)
385554 if val is not None:
386555 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
387563 data.setdefault("date", date.today().isoformat())
388564
389565 group = args.group or config.get("group") or DEFAULT_GROUP
@@ -405,6 +581,7 @@ def main(argv: list[str] | None = None) -> int:
405581 panel_titles = (config.get("panel_left") or PANEL_TITLES[0],
406582 config.get("panel_right") or PANEL_TITLES[1])
407583
584 # logo: an art file wins; otherwise an optional generated banner
408585 logo = None
409586 logo_path = args.logo or config.get("logo")
410587 if logo_path:
@@ -413,6 +590,8 @@ def main(argv: list[str] | None = None) -> int:
413590 logo = Path(logo_path).read_text(encoding=enc, errors="replace")
414591 except FileNotFoundError:
415592 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)
416595
417596 roster = {k: config[k] for k in
418597 ("news", "members", "couriers", "boards", "affiliates", "outposts")
nfogen.toml +9 −2
@@ -44,8 +44,15 @@ PROFILE: -p group.toml supplies logo, footer,
4444 members, couriers, boards, affiliates,
4545 news, outposts. release cfg overrides it.
4646
47OPTIONS: -o -c -p -g -s -w --style --layout --logo
48 --encoding --presents --footer -i --no-input
47MEDIAINFO: --mediainfo dump.json auto-fills
48 video/audio/res/size/runtime/format.
49BANNER: --banner makes an ASCII logo (--banner-text)
50TEMPLATES: -t razor|modern|minimal presets
51 (--list-templates)
52
53OPTIONS: -o -c -p -t -g -s -w --style --layout
54 --logo --banner --mediainfo --encoding
55 --presents --footer -i --no-input
4956
5057Generated by nfogen itself.
5158"""
templates/minimal.toml added +6
@@ -0,0 +1,6 @@
1# Bare card: single-line box, simple rows, house width. Just the facts.
2style = "single"
3layout = "rows"
4width = 64
5presents = false
6footer = false
templates/modern.toml added +6
@@ -0,0 +1,6 @@
1# Cleaner modern look: single-line box, two-column panel, no scene boilerplate.
2style = "single"
3layout = "panel"
4width = 79
5presents = false
6footer = false
templates/razor.toml added +12
@@ -0,0 +1,12 @@
1# RAZOR-era look: double box, two-column panel, generated banner + presents,
2# and the classic disclaimer footer. Bring your own title and fields.
3style = "double"
4layout = "panel"
5width = 79
6presents = true
7banner = true
8footer = true
9footer_text = """
10SUPPORT THE COMPANIES THAT PRODUCE QUALITY SOFTWARE
11if you enjoyed this release, buy the original!
12"""