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