| @@ -0,0 +1,228 @@ |
| |
1 | #!/usr/bin/env python3 |
| |
2 | """nfogen - a scene-style NFO generator. |
| |
3 | |
| |
4 | Builds a boxed ASCII .nfo from CLI flags, an optional config file, and |
| |
5 | interactive prompts for anything still missing. |
| |
6 | """ |
| |
7 | |
| |
8 | from __future__ import annotations |
| |
9 | |
| |
10 | import argparse |
| |
11 | import json |
| |
12 | import os |
| |
13 | import sys |
| |
14 | import textwrap |
| |
15 | from datetime import date |
| |
16 | from 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 | |
| |
27 | FIELDS = [ |
| |
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 | ] |
| |
45 | FIELD_KINDS = {k: kind for k, _, _, kind in FIELDS} |
| |
46 | INFO_KEYS = [k for k, _, _, kind in FIELDS if kind == "line" and k != "title"] |
| |
47 | |
| |
48 | DEFAULT_SITE = "krz.sh" |
| |
49 | DEFAULT_GROUP = "KRZ" |
| |
50 | DEFAULT_WIDTH = 64 |
| |
51 | CONFIG_CANDIDATES = [ |
| |
52 | Path("nfogen.toml"), |
| |
53 | Path(".nfogen.toml"), |
| |
54 | Path.home() / ".config" / "nfogen" / "config.toml", |
| |
55 | ] |
| |
56 | |
| |
57 | |
| |
58 | # --- config ------------------------------------------------------------------ |
| |
59 | def 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 ------------------------------------------------------------- |
| |
82 | def 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 --------------------------------------------------------------- |
| |
103 | def _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 | |
| |
109 | def 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 | |
| |
167 | def _clip(text: str, width: int) -> str: |
| |
168 | return text if len(text) <= width else text[: width - 1] + "…" |
| |
169 | |
| |
170 | |
| |
171 | # --- cli --------------------------------------------------------------------- |
| |
172 | def 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 | |
| |
194 | def 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 | |
| |
227 | if __name__ == "__main__": |
| |
228 | raise SystemExit(main()) |