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())