krz/nfogen

A scene-style ASCII .nfo generator.

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

v1.0.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 sys
 14import textwrap
 15from datetime import date
 16from pathlib import Path
 17
 18# --- field schema ------------------------------------------------------------
 19# One source of truth. Drives argparse flags, interactive prompts, and render.
 20#   key    : internal name / config key / flag name
 21#   label  : shown in the rendered nfo (info rows) or as a section header
 22#   help   : argparse + prompt help text
 23#   kind   : "line"  scalar shown as an aligned INFO row
 24#            "list"  many values, shown space-joined in its own section
 25#            "block" free text, wrapped in its own section
 26
 27FIELDS = [
 28    ("title",      "TITLE",      "release title (required)",            "line"),
 29    ("date",       "DATE",       "release date",                        "line"),
 30    ("type",       "TYPE",       "release type (MOVIE, TV, APP, ...)",  "line"),
 31    ("source",     "SOURCE",     "source (BluRay, WEB-DL, CD, ...)",    "line"),
 32    ("format",     "FORMAT",     "container / format",                  "line"),
 33    ("video",      "VIDEO",      "video codec / details",               "line"),
 34    ("audio",      "AUDIO",      "audio codec / details",               "line"),
 35    ("resolution", "RESOLUTION", "resolution",                          "line"),
 36    ("language",   "LANGUAGE",   "language(s)",                         "line"),
 37    ("genre",      "GENRE",      "genre",                               "line"),
 38    ("runtime",    "RUNTIME",    "runtime / duration",                  "line"),
 39    ("size",       "SIZE",       "total size",                          "line"),
 40    ("files",      "FILES",      "file count / listing",                "line"),
 41    ("url",        "URL",        "reference url (imdb, homepage, ...)", "line"),
 42    ("notes",      "NOTES",      "free-text notes / description",       "block"),
 43    ("greets",     "GREETS",     "groups to greet (space/comma sep)",   "list"),
 44]
 45FIELD_KINDS = {k: kind for k, _, _, kind in FIELDS}
 46INFO_KEYS = [k for k, _, _, kind in FIELDS if kind == "line" and k != "title"]
 47
 48DEFAULT_SITE = "krz.sh"
 49DEFAULT_GROUP = "KRZ"
 50DEFAULT_WIDTH = 64
 51CONFIG_CANDIDATES = [
 52    Path("nfogen.toml"),
 53    Path(".nfogen.toml"),
 54    Path.home() / ".config" / "nfogen" / "config.toml",
 55]
 56
 57
 58# --- config ------------------------------------------------------------------
 59def load_config(explicit: str | None) -> dict:
 60    """Load defaults from a TOML or JSON file. Returns {} if none found."""
 61    path = None
 62    if explicit:
 63        path = Path(explicit)
 64        if not path.exists():
 65            sys.exit(f"nfogen: config not found: {explicit}")
 66    else:
 67        path = next((p for p in CONFIG_CANDIDATES if p.exists()), None)
 68    if path is None:
 69        return {}
 70
 71    text = path.read_text(encoding="utf-8")
 72    if path.suffix == ".json":
 73        return json.loads(text)
 74    try:
 75        import tomllib
 76    except ModuleNotFoundError:
 77        sys.exit("nfogen: TOML config needs Python 3.11+ (or use a .json file)")
 78    return tomllib.loads(text)
 79
 80
 81# --- interactive -------------------------------------------------------------
 82def prompt_missing(data: dict, force_all: bool) -> None:
 83    """Fill fields from stdin. Prompts every field when force_all, else only
 84    the missing ones. Enter keeps the current/blank value."""
 85    keys = [k for k, *_ in FIELDS] if force_all else \
 86        [k for k, *_ in FIELDS if not data.get(k)]
 87    if not keys:
 88        return
 89    print("nfogen: interactive mode (blank to skip)\n", file=sys.stderr)
 90    for key in keys:
 91        _, label, help_text, _ = next(f for f in FIELDS if f[0] == key)
 92        current = data.get(key, "")
 93        suffix = f" [{current}]" if current else ""
 94        try:
 95            answer = input(f"{label} ({help_text}){suffix}: ").strip()
 96        except EOFError:
 97            break
 98        if answer:
 99            data[key] = answer
100
101
102# --- rendering ---------------------------------------------------------------
103def _split_list(value) -> list[str]:
104    if isinstance(value, (list, tuple)):
105        return [str(v).strip() for v in value if str(v).strip()]
106    return [p for p in str(value).replace(",", " ").split() if p]
107
108
109def render(data: dict, group: str, site: str, width: int) -> str:
110    inner = width - 4  # borders + one space of padding on each side
111    top = "" + "" * (width - 2) + ""
112    sep = "" + "" * (width - 2) + ""
113    bot = "" + "" * (width - 2) + ""
114
115    def line(content: str = "") -> str:
116        return "" + content.ljust(inner) + ""
117
118    def center(content: str) -> str:
119        return "" + content.center(inner) + ""
120
121    out: list[str] = [top]
122
123    # header: spaced title, then group tag + site
124    title = str(data.get("title") or "UNTITLED").upper()
125    spaced = " ".join(title)
126    heading = spaced if len(spaced) <= inner else title
127    out.append(center(_clip(heading, inner)))
128    out.append(center(_clip(f"[ {group} ]   {site}", inner)))
129
130    # info rows
131    rows = [(label, str(data[key]))
132            for key, label, _, _ in FIELDS
133            if key in INFO_KEYS and data.get(key)]
134    if rows:
135        out.append(sep)
136        lw = max(len(label) for label, _ in rows)
137        avail = inner - (2 + lw + 2)
138        for label, value in rows:
139            wrapped = textwrap.wrap(value, avail) or [""]
140            out.append(line(f"  {label:<{lw}}  {wrapped[0]}"))
141            for cont in wrapped[1:]:
142                out.append(line(" " * (2 + lw + 2) + cont))
143
144    # notes block
145    notes = str(data.get("notes") or "").strip()
146    if notes:
147        out.append(sep)
148        out.append(line("  NOTES"))
149        out.append(line())
150        for para in notes.splitlines() or [""]:
151            for wrapped in (textwrap.wrap(para, inner - 4) or [""]):
152                out.append(line("  " + wrapped))
153
154    # greets block
155    greets = _split_list(data.get("greets") or [])
156    if greets:
157        out.append(sep)
158        out.append(line("  GREETS"))
159        out.append(line())
160        for wrapped in textwrap.wrap("   ".join(greets), inner - 4):
161            out.append(line("  " + wrapped))
162
163    out.append(bot)
164    return "\n".join(out) + "\n"
165
166
167def _clip(text: str, width: int) -> str:
168    return text if len(text) <= width else text[: width - 1] + ""
169
170
171# --- cli ---------------------------------------------------------------------
172def build_parser() -> argparse.ArgumentParser:
173    p = argparse.ArgumentParser(
174        prog="nfogen",
175        description="Generate a scene-style ASCII .nfo file.",
176    )
177    p.add_argument("-c", "--config", metavar="PATH",
178                   help="config file (.toml or .json) with field defaults")
179    p.add_argument("-o", "--output", metavar="PATH",
180                   help="write to PATH instead of stdout")
181    p.add_argument("-g", "--group", help=f"release group (default {DEFAULT_GROUP})")
182    p.add_argument("-s", "--site", help=f"site tag (default {DEFAULT_SITE})")
183    p.add_argument("-w", "--width", type=int,
184                   help=f"box width in chars (default {DEFAULT_WIDTH})")
185    p.add_argument("-i", "--interactive", action="store_true",
186                   help="prompt for every field")
187    p.add_argument("--no-input", action="store_true",
188                   help="never prompt, even if fields are missing")
189    for key, _, help_text, _ in FIELDS:
190        p.add_argument(f"--{key}", help=help_text)
191    return p
192
193
194def main(argv: list[str] | None = None) -> int:
195    args = build_parser().parse_args(argv)
196    config = load_config(args.config)
197
198    # merge: config defaults, then any CLI flag that was supplied
199    data = {k: config[k] for k, *_ in FIELDS if k in config and config[k] != ""}
200    for key, *_ in FIELDS:
201        val = getattr(args, key)
202        if val is not None:
203            data[key] = val
204    data.setdefault("date", date.today().isoformat())
205
206    group = args.group or config.get("group") or DEFAULT_GROUP
207    site = args.site or config.get("site") or DEFAULT_SITE
208    width = args.width or config.get("width") or DEFAULT_WIDTH
209
210    interactive = sys.stdin.isatty() and not args.no_input
211    if args.interactive:
212        prompt_missing(data, force_all=True)
213    elif not data.get("title") and interactive:
214        prompt_missing(data, force_all=False)
215
216    if not data.get("title"):
217        sys.exit("nfogen: a title is required (use --title, a config, or -i)")
218
219    text = render(data, group=group, site=site, width=width)
220    if args.output:
221        Path(args.output).write_text(text, encoding="utf-8")
222    else:
223        sys.stdout.write(text)
224    return 0
225
226
227if __name__ == "__main__":
228    raise SystemExit(main())