krz/nfogen

A scene-style ASCII .nfo generator.

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

8efe76f580129941630216ae920851ca911477a3

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-08T05:33:15Z

add nfogen generator, example config, and self-generated README
 README.nfo  |  53 +++++++++++---
 nfogen.py   | 228 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 nfogen.toml |  37 ++++++++++
 3 files changed, 307 insertions(+), 11 deletions(-)

diff --git a/README.nfo b/README.nfo
index 00dd9e1..c20aefd 100644
--- a/README.nfo
+++ b/README.nfo
@@ -1,13 +1,44 @@
 ┌──────────────────────────────────────────────────────────────┐
-│  N F O G E N                               [ KRZ ]   krz.sh  │
-└──────────────────────────────────────────────────────────────┘
-
-WHAT
-  nfogen.
-
-STATUS
-  placeholder. no code yet.
-
-┌──────────────────────────────────────────────────────────────┐
-│  krz.sh                                                      │
+│                     N F O G E N   1 . 0                      │
+│                       [ KRZ ]   krz.sh                       │
+├──────────────────────────────────────────────────────────────┤
+│   DATE   2026-08-08                                          │
+│   TYPE   APP                                                 │
+│   FILES  nfogen.py                                           │
+│   URL    krz.sh                                              │
+├──────────────────────────────────────────────────────────────┤
+│   NOTES                                                      │
+│                                                              │
+│   A scene-style ASCII .nfo generator. A single               │
+│   zero-dependency Python file. Builds this very box          │
+│   from CLI flags, a config file, prompts, or a mix.          │
+│                                                              │
+│   REQUIRES python3 (3.11+ for TOML, else JSON).              │
+│                                                              │
+│   USAGE                                                      │
+│   nfogen.py --title "nfogen 1.0" --type APP                  │
+│   nfogen.py -c nfogen.toml --title "nfogen 1.0"              │
+│   nfogen.py -i  prompts for every field.                     │
+│   nfogen.py alone prompts when no title given.               │
+│                                                              │
+│   ORDER: config file < CLI flags < prompts.                  │
+│                                                              │
+│   CONFIG: a .toml or .json auto-loaded from                  │
+│   ./nfogen.toml, ./.nfogen.toml, or the path                 │
+│   ~/.config/nfogen/config.toml. Holds any field              │
+│   default plus group, site, width. Or use -c PATH.           │
+│                                                              │
+│   FIELDS (all optional but title):                           │
+│   core: title date type source size files                    │
+│   url notes greets                                           │
+│   media: format video audio resolution                       │
+│   language genre runtime                                     │
+│                                                              │
+│   OPTIONS: -o -c -g -s -w -i --no-input                      │
+│                                                              │
+│   Generated by nfogen itself.                                │
+├──────────────────────────────────────────────────────────────┤
+│   GREETS                                                     │
+│                                                              │
+│   you   the-scene                                            │
 └──────────────────────────────────────────────────────────────┘
diff --git a/nfogen.py b/nfogen.py
new file mode 100755
index 0000000..63ac5ad
--- /dev/null
+++ b/nfogen.py
@@ -0,0 +1,228 @@
+#!/usr/bin/env python3
+"""nfogen - a scene-style NFO generator.
+
+Builds a boxed ASCII .nfo from CLI flags, an optional config file, and
+interactive prompts for anything still missing.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+import textwrap
+from datetime import date
+from pathlib import Path
+
+# --- field schema ------------------------------------------------------------
+# One source of truth. Drives argparse flags, interactive prompts, and render.
+#   key    : internal name / config key / flag name
+#   label  : shown in the rendered nfo (info rows) or as a section header
+#   help   : argparse + prompt help text
+#   kind   : "line"  scalar shown as an aligned INFO row
+#            "list"  many values, shown space-joined in its own section
+#            "block" free text, wrapped in its own section
+
+FIELDS = [
+    ("title",      "TITLE",      "release title (required)",            "line"),
+    ("date",       "DATE",       "release date",                        "line"),
+    ("type",       "TYPE",       "release type (MOVIE, TV, APP, ...)",  "line"),
+    ("source",     "SOURCE",     "source (BluRay, WEB-DL, CD, ...)",    "line"),
+    ("format",     "FORMAT",     "container / format",                  "line"),
+    ("video",      "VIDEO",      "video codec / details",               "line"),
+    ("audio",      "AUDIO",      "audio codec / details",               "line"),
+    ("resolution", "RESOLUTION", "resolution",                          "line"),
+    ("language",   "LANGUAGE",   "language(s)",                         "line"),
+    ("genre",      "GENRE",      "genre",                               "line"),
+    ("runtime",    "RUNTIME",    "runtime / duration",                  "line"),
+    ("size",       "SIZE",       "total size",                          "line"),
+    ("files",      "FILES",      "file count / listing",                "line"),
+    ("url",        "URL",        "reference url (imdb, homepage, ...)", "line"),
+    ("notes",      "NOTES",      "free-text notes / description",       "block"),
+    ("greets",     "GREETS",     "groups to greet (space/comma sep)",   "list"),
+]
+FIELD_KINDS = {k: kind for k, _, _, kind in FIELDS}
+INFO_KEYS = [k for k, _, _, kind in FIELDS if kind == "line" and k != "title"]
+
+DEFAULT_SITE = "krz.sh"
+DEFAULT_GROUP = "KRZ"
+DEFAULT_WIDTH = 64
+CONFIG_CANDIDATES = [
+    Path("nfogen.toml"),
+    Path(".nfogen.toml"),
+    Path.home() / ".config" / "nfogen" / "config.toml",
+]
+
+
+# --- config ------------------------------------------------------------------
+def load_config(explicit: str | None) -> dict:
+    """Load defaults from a TOML or JSON file. Returns {} if none found."""
+    path = None
+    if explicit:
+        path = Path(explicit)
+        if not path.exists():
+            sys.exit(f"nfogen: config not found: {explicit}")
+    else:
+        path = next((p for p in CONFIG_CANDIDATES if p.exists()), None)
+    if path is None:
+        return {}
+
+    text = path.read_text(encoding="utf-8")
+    if path.suffix == ".json":
+        return json.loads(text)
+    try:
+        import tomllib
+    except ModuleNotFoundError:
+        sys.exit("nfogen: TOML config needs Python 3.11+ (or use a .json file)")
+    return tomllib.loads(text)
+
+
+# --- interactive -------------------------------------------------------------
+def prompt_missing(data: dict, force_all: bool) -> None:
+    """Fill fields from stdin. Prompts every field when force_all, else only
+    the missing ones. Enter keeps the current/blank value."""
+    keys = [k for k, *_ in FIELDS] if force_all else \
+        [k for k, *_ in FIELDS if not data.get(k)]
+    if not keys:
+        return
+    print("nfogen: interactive mode (blank to skip)\n", file=sys.stderr)
+    for key in keys:
+        _, label, help_text, _ = next(f for f in FIELDS if f[0] == key)
+        current = data.get(key, "")
+        suffix = f" [{current}]" if current else ""
+        try:
+            answer = input(f"{label} ({help_text}){suffix}: ").strip()
+        except EOFError:
+            break
+        if answer:
+            data[key] = answer
+
+
+# --- rendering ---------------------------------------------------------------
+def _split_list(value) -> list[str]:
+    if isinstance(value, (list, tuple)):
+        return [str(v).strip() for v in value if str(v).strip()]
+    return [p for p in str(value).replace(",", " ").split() if p]
+
+
+def render(data: dict, group: str, site: str, width: int) -> str:
+    inner = width - 4  # borders + one space of padding on each side
+    top = "┌" + "─" * (width - 2) + "┐"
+    sep = "├" + "─" * (width - 2) + "┤"
+    bot = "└" + "─" * (width - 2) + "┘"
+
+    def line(content: str = "") -> str:
+        return "│ " + content.ljust(inner) + " │"
+
+    def center(content: str) -> str:
+        return "│ " + content.center(inner) + " │"
+
+    out: list[str] = [top]
+
+    # header: spaced title, then group tag + site
+    title = str(data.get("title") or "UNTITLED").upper()
+    spaced = " ".join(title)
+    heading = spaced if len(spaced) <= inner else title
+    out.append(center(_clip(heading, inner)))
+    out.append(center(_clip(f"[ {group} ]   {site}", inner)))
+
+    # info rows
+    rows = [(label, str(data[key]))
+            for key, label, _, _ in FIELDS
+            if key in INFO_KEYS and data.get(key)]
+    if rows:
+        out.append(sep)
+        lw = max(len(label) for label, _ in rows)
+        avail = inner - (2 + lw + 2)
+        for label, value in rows:
+            wrapped = textwrap.wrap(value, avail) or [""]
+            out.append(line(f"  {label:<{lw}}  {wrapped[0]}"))
+            for cont in wrapped[1:]:
+                out.append(line(" " * (2 + lw + 2) + cont))
+
+    # notes block
+    notes = str(data.get("notes") or "").strip()
+    if notes:
+        out.append(sep)
+        out.append(line("  NOTES"))
+        out.append(line())
+        for para in notes.splitlines() or [""]:
+            for wrapped in (textwrap.wrap(para, inner - 4) or [""]):
+                out.append(line("  " + wrapped))
+
+    # greets block
+    greets = _split_list(data.get("greets") or [])
+    if greets:
+        out.append(sep)
+        out.append(line("  GREETS"))
+        out.append(line())
+        for wrapped in textwrap.wrap("   ".join(greets), inner - 4):
+            out.append(line("  " + wrapped))
+
+    out.append(bot)
+    return "\n".join(out) + "\n"
+
+
+def _clip(text: str, width: int) -> str:
+    return text if len(text) <= width else text[: width - 1] + "…"
+
+
+# --- cli ---------------------------------------------------------------------
+def build_parser() -> argparse.ArgumentParser:
+    p = argparse.ArgumentParser(
+        prog="nfogen",
+        description="Generate a scene-style ASCII .nfo file.",
+    )
+    p.add_argument("-c", "--config", metavar="PATH",
+                   help="config file (.toml or .json) with field defaults")
+    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})")
+    p.add_argument("-s", "--site", help=f"site tag (default {DEFAULT_SITE})")
+    p.add_argument("-w", "--width", type=int,
+                   help=f"box width in chars (default {DEFAULT_WIDTH})")
+    p.add_argument("-i", "--interactive", action="store_true",
+                   help="prompt for every field")
+    p.add_argument("--no-input", action="store_true",
+                   help="never prompt, even if fields are missing")
+    for key, _, help_text, _ in FIELDS:
+        p.add_argument(f"--{key}", help=help_text)
+    return p
+
+
+def main(argv: list[str] | None = None) -> int:
+    args = build_parser().parse_args(argv)
+    config = load_config(args.config)
+
+    # 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] != ""}
+    for key, *_ in FIELDS:
+        val = getattr(args, key)
+        if val is not None:
+            data[key] = val
+    data.setdefault("date", date.today().isoformat())
+
+    group = args.group or config.get("group") or DEFAULT_GROUP
+    site = args.site or config.get("site") or DEFAULT_SITE
+    width = args.width or config.get("width") or DEFAULT_WIDTH
+
+    interactive = sys.stdin.isatty() and not args.no_input
+    if args.interactive:
+        prompt_missing(data, force_all=True)
+    elif not data.get("title") and interactive:
+        prompt_missing(data, force_all=False)
+
+    if not data.get("title"):
+        sys.exit("nfogen: a title is required (use --title, a config, or -i)")
+
+    text = render(data, group=group, site=site, width=width)
+    if args.output:
+        Path(args.output).write_text(text, encoding="utf-8")
+    else:
+        sys.stdout.write(text)
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/nfogen.toml b/nfogen.toml
new file mode 100644
index 0000000..2726d9c
--- /dev/null
+++ b/nfogen.toml
@@ -0,0 +1,37 @@
+group = "KRZ"
+site  = "krz.sh"
+title = "nfogen 1.0"
+type  = "APP"
+files = "nfogen.py"
+url   = "krz.sh"
+greets = ["you", "the-scene"]
+notes = """
+A scene-style ASCII .nfo generator. A single
+zero-dependency Python file. Builds this very box
+from CLI flags, a config file, prompts, or a mix.
+
+REQUIRES python3 (3.11+ for TOML, else JSON).
+
+USAGE
+nfogen.py --title "nfogen 1.0" --type APP
+nfogen.py -c nfogen.toml --title "nfogen 1.0"
+nfogen.py -i  prompts for every field.
+nfogen.py alone prompts when no title given.
+
+ORDER: config file < CLI flags < prompts.
+
+CONFIG: a .toml or .json auto-loaded from
+./nfogen.toml, ./.nfogen.toml, or the path
+~/.config/nfogen/config.toml. Holds any field
+default plus group, site, width. Or use -c PATH.
+
+FIELDS (all optional but title):
+core: title date type source size files
+url notes greets
+media: format video audio resolution
+language genre runtime
+
+OPTIONS: -o -c -g -s -w -i --no-input
+
+Generated by nfogen itself.
+"""