From 661ee019e5b0e71c66a5a76f67083e05270e031e Mon Sep 17 00:00:00 2001 From: SirBlobby Date: Sat, 31 Oct 2026 22:53:17 -0400 Subject: [PATCH] Add content CLI --- .dockerignore | 2 + .gitignore | 1 + README.md | 17 ++++++ package.json | 1 + tools/content/cli.py | 27 +++++++++ tools/content/imdb.py | 44 +++++++++++++++ tools/content/jsonfile.py | 70 +++++++++++++++++++++++ tools/content/media.py | 115 ++++++++++++++++++++++++++++++++++++++ tools/content/music.py | 71 +++++++++++++++++++++++ tools/content/youtube.py | 39 +++++++++++++ 10 files changed, 387 insertions(+) create mode 100644 tools/content/cli.py create mode 100644 tools/content/imdb.py create mode 100644 tools/content/jsonfile.py create mode 100644 tools/content/media.py create mode 100644 tools/content/music.py create mode 100644 tools/content/youtube.py diff --git a/.dockerignore b/.dockerignore index c017004..9af5bc9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,6 +13,8 @@ PROJECT.md Update.md UPDATE.md blogs +tools +__pycache__ *.log .DS_Store Thumbs.db diff --git a/.gitignore b/.gitignore index db93f1c..0e1e905 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ vite.config.js.timestamp-* vite.config.ts.timestamp-* bun.lock +__pycache__/ Update.md PROJECT.md blogs/ diff --git a/README.md b/README.md index e82a6f9..aeb7066 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,23 @@ Copy `.env.example` to `.env` and fill in what you need. Every value is optional Site content lives in `content/`. +### Content CLI + +A small Python tool (standard library only) edits the playlist, shows and movies without touching the JSON by hand. + +```bash +bun run content music list --titles +bun run content music add https://www.youtube.com/watch?v=VIDEO_ID +bun run content music remove VIDEO_ID +bun run content movie add tt0816692 --rating 10 +bun run content show add https://www.imdb.com/title/tt14688458/ --rating 10 --name "Silo" +bun run content show rate Silo 9 +bun run content movie remove Interstellar +bun run content show list +``` + +Titles are looked up on OMDb with `OMDB_API_KEY` from `.env`; pass `--name` to skip the lookup. Run `bun run content --help` for every option. + | File | Holds | | --- | --- | | `profile.json` | Name, bio, socials, skills and photos | diff --git a/package.json b/package.json index af44e7b..eeaee7d 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "vite build", "preview": "vite preview", "start": "bun --bun vite build && bun ./build/index.js", + "content": "python3 tools/content/cli.py", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" diff --git a/tools/content/cli.py b/tools/content/cli.py new file mode 100644 index 0000000..90df527 --- /dev/null +++ b/tools/content/cli.py @@ -0,0 +1,27 @@ +import argparse +import sys + +import media +import music + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="content", description="Edit the website content in content/") + subparsers = parser.add_subparsers(dest="section", required=True) + music.register(subparsers) + media.register(subparsers) + return parser + + +def main() -> int: + arguments = build_parser().parse_args() + try: + arguments.handler(arguments) + except (OSError, ValueError) as error: + print(f"Error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/content/imdb.py b/tools/content/imdb.py new file mode 100644 index 0000000..cc99414 --- /dev/null +++ b/tools/content/imdb.py @@ -0,0 +1,44 @@ +import json +import os +import re +import urllib.parse +import urllib.request + +from jsonfile import PROJECT_ROOT + +IMDB_ID_PATTERN = re.compile(r"tt\d{7,9}") +ENV_FILE = PROJECT_ROOT / ".env" + + +def imdb_id(text: str) -> str | None: + match = IMDB_ID_PATTERN.search(text) + return match.group(0) if match else None + + +def title_url(identifier: str) -> str: + return f"https://www.imdb.com/title/{identifier}/" + + +def omdb_api_key() -> str | None: + if os.environ.get("OMDB_API_KEY"): + return os.environ["OMDB_API_KEY"] + if not ENV_FILE.exists(): + return None + for line in ENV_FILE.read_text(encoding="utf-8").splitlines(): + key, _, value = line.partition("=") + if key.strip() == "OMDB_API_KEY" and value.strip(): + return value.strip().strip("\"'") + return None + + +def lookup(identifier: str) -> dict | None: + api_key = omdb_api_key() + if not api_key: + return None + query = urllib.parse.urlencode({"apikey": api_key, "i": identifier}) + try: + with urllib.request.urlopen(f"https://www.omdbapi.com/?{query}", timeout=10) as response: + result = json.load(response) + except (OSError, ValueError): + return None + return result if result.get("Response") == "True" else None diff --git a/tools/content/jsonfile.py b/tools/content/jsonfile.py new file mode 100644 index 0000000..a5a8be2 --- /dev/null +++ b/tools/content/jsonfile.py @@ -0,0 +1,70 @@ +import json +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +CONTENT_DIR = PROJECT_ROOT / "content" +INLINE_LIST_MAX_LENGTH = 80 +INDENT = "\t" + + +def content_path(name: str) -> Path: + return CONTENT_DIR / name + + +def load(name: str): + with content_path(name).open(encoding="utf-8") as file: + return json.load(file) + + +def save(name: str, data) -> None: + text = format_value(data, 0) + "\n" + content_path(name).write_text(text, encoding="utf-8") + + +def is_primitive(value) -> bool: + return not isinstance(value, (dict, list)) + + +def encode(value) -> str: + return json.dumps(value, ensure_ascii=False) + + +def format_inline_dict(data: dict) -> str: + if not data: + return "{}" + pairs = ", ".join(f"{encode(key)}: {format_value(value, 0)}" for key, value in data.items()) + return "{ " + pairs + " }" + + +def format_inline_list(items: list) -> str: + return "[" + ", ".join(encode(item) for item in items) + "]" + + +def format_value(value, depth: int) -> str: + if isinstance(value, dict): + return format_dict(value, depth) + if isinstance(value, list): + return format_list(value, depth) + return encode(value) + + +def format_dict(data: dict, depth: int) -> str: + if depth > 0 and all(is_primitive(value) or is_short_primitive_list(value) for value in data.values()): + return format_inline_dict(data) + inner_indent = INDENT * (depth + 1) + lines = [f"{inner_indent}{encode(key)}: {format_value(value, depth + 1)}" for key, value in data.items()] + return "{\n" + ",\n".join(lines) + "\n" + INDENT * depth + "}" + + +def is_short_primitive_list(value) -> bool: + return isinstance(value, list) and all(is_primitive(item) for item in value) and len(format_inline_list(value)) <= INLINE_LIST_MAX_LENGTH + + +def format_list(items: list, depth: int) -> str: + if not items: + return "[]" + if is_short_primitive_list(items): + return format_inline_list(items) + inner_indent = INDENT * (depth + 1) + lines = [f"{inner_indent}{format_value(item, depth + 1)}" for item in items] + return "[\n" + ",\n".join(lines) + "\n" + INDENT * depth + "]" diff --git a/tools/content/media.py b/tools/content/media.py new file mode 100644 index 0000000..b4aa208 --- /dev/null +++ b/tools/content/media.py @@ -0,0 +1,115 @@ +import imdb +import jsonfile + +ABOUT_FILE = "about.json" +MEDIA_KINDS = { + "show": {"key": "shows", "label": "TV show", "omdb_type": "series"}, + "movie": {"key": "movies", "label": "movie", "omdb_type": "movie"} +} + + +def load_items(kind: str) -> tuple[dict, list[dict]]: + data = jsonfile.load(ABOUT_FILE) + return data, data.setdefault(MEDIA_KINDS[kind]["key"], []) + + +def find_index(items: list[dict], text: str) -> int | None: + identifier = imdb.imdb_id(text) + for index, item in enumerate(items): + if identifier and imdb.imdb_id(item.get("url", "")) == identifier: + return index + if item.get("name", "").lower() == text.lower(): + return index + return None + + +def valid_rating(value: str) -> int: + rating = int(value) + if not 0 <= rating <= 10: + raise ValueError("Rating must be between 0 and 10") + return rating + + +def list_items(arguments) -> None: + _, items = load_items(arguments.kind) + if not items: + print(f"No {MEDIA_KINDS[arguments.kind]['label']}s yet.") + return + for item in sorted(items, key=lambda entry: -entry.get("rating", 0)): + print(f"{item.get('rating', '?'):>3}/10 {item.get('name')} {item.get('url')}") + + +def resolve_name(arguments, identifier: str) -> str | None: + if arguments.name: + return arguments.name + details = imdb.lookup(identifier) + if not details: + print("Could not look up the title. Pass --name, or set OMDB_API_KEY in .env.") + return None + expected_type = MEDIA_KINDS[arguments.kind]["omdb_type"] + if details.get("Type") != expected_type: + print(f"Warning: OMDb lists {details.get('Title')} as a {details.get('Type')}, not a {expected_type}.") + return details.get("Title") + + +def add_item(arguments) -> None: + identifier = imdb.imdb_id(arguments.title) + if not identifier: + print(f"{arguments.title} is not an IMDb link or ID.") + return + data, items = load_items(arguments.kind) + if find_index(items, identifier) is not None: + print(f"{identifier} is already listed. Use rate to change its rating.") + return + name = resolve_name(arguments, identifier) + if not name: + return + items.append({"name": name, "rating": arguments.rating, "url": imdb.title_url(identifier)}) + jsonfile.save(ABOUT_FILE, data) + print(f"Added {name} ({arguments.rating}/10)") + + +def remove_item(arguments) -> None: + data, items = load_items(arguments.kind) + index = find_index(items, arguments.title) + if index is None: + print(f"{arguments.title} is not listed.") + return + removed = items.pop(index) + jsonfile.save(ABOUT_FILE, data) + print(f"Removed {removed.get('name')}") + + +def rate_item(arguments) -> None: + data, items = load_items(arguments.kind) + index = find_index(items, arguments.title) + if index is None: + print(f"{arguments.title} is not listed.") + return + items[index]["rating"] = arguments.rating + jsonfile.save(ABOUT_FILE, data) + print(f"Rated {items[index].get('name')} {arguments.rating}/10") + + +def register(subparsers) -> None: + for kind, settings in MEDIA_KINDS.items(): + parser = subparsers.add_parser(kind, help=f"Manage the {settings['label']}s on the about page") + parser.set_defaults(kind=kind) + actions = parser.add_subparsers(dest="action", required=True) + + actions.add_parser("list", help=f"List {settings['label']}s").set_defaults(handler=list_items) + + add_parser = actions.add_parser("add", help=f"Add a {settings['label']} by IMDb link or ID") + add_parser.add_argument("title", help="IMDb link or ID, like tt0816692") + add_parser.add_argument("--rating", type=valid_rating, required=True, help="Your rating from 0 to 10") + add_parser.add_argument("--name", help="Display name, looked up on OMDb when left out") + add_parser.set_defaults(handler=add_item) + + remove_parser = actions.add_parser("remove", help=f"Remove a {settings['label']}") + remove_parser.add_argument("title", help="IMDb link, ID or name") + remove_parser.set_defaults(handler=remove_item) + + rate_parser = actions.add_parser("rate", help=f"Change a {settings['label']}'s rating") + rate_parser.add_argument("title", help="IMDb link, ID or name") + rate_parser.add_argument("rating", type=valid_rating, help="New rating from 0 to 10") + rate_parser.set_defaults(handler=rate_item) diff --git a/tools/content/music.py b/tools/content/music.py new file mode 100644 index 0000000..03790b4 --- /dev/null +++ b/tools/content/music.py @@ -0,0 +1,71 @@ +import jsonfile +import youtube + +MUSIC_FILE = "music.json" + + +def load_videos() -> tuple[dict, list[str]]: + data = jsonfile.load(MUSIC_FILE) + return data, data.setdefault("videos", []) + + +def find_index(videos: list[str], identifier: str) -> int | None: + return next((index for index, video in enumerate(videos) if youtube.video_id(video) == identifier), None) + + +def list_songs(arguments) -> None: + _, videos = load_videos() + if not videos: + print("The playlist is empty.") + return + for number, video in enumerate(videos, start=1): + identifier = youtube.video_id(video) or "?" + title = youtube.video_title(identifier) if arguments.titles else None + print(f"{number:>3}. {identifier} {title or video}") + + +def add_songs(arguments) -> None: + data, videos = load_videos() + added = 0 + for text in arguments.songs: + identifier = youtube.video_id(text) + if not identifier: + print(f"Skipped {text}: not a YouTube link or video ID.") + continue + if find_index(videos, identifier) is not None: + print(f"Skipped {identifier}: already in the playlist.") + continue + videos.append(youtube.watch_url(identifier)) + added += 1 + print(f"Added {youtube.video_title(identifier) or identifier}") + if added: + jsonfile.save(MUSIC_FILE, data) + + +def remove_song(arguments) -> None: + data, videos = load_videos() + identifier = youtube.video_id(arguments.song) + index = find_index(videos, identifier) if identifier else None + if index is None: + print(f"{arguments.song} is not in the playlist.") + return + videos.pop(index) + jsonfile.save(MUSIC_FILE, data) + print(f"Removed {identifier}") + + +def register(subparsers) -> None: + parser = subparsers.add_parser("music", help="Manage the music playlist") + actions = parser.add_subparsers(dest="action", required=True) + + list_parser = actions.add_parser("list", help="List songs in the playlist") + list_parser.add_argument("--titles", action="store_true", help="Look up song titles on YouTube") + list_parser.set_defaults(handler=list_songs) + + add_parser = actions.add_parser("add", help="Add songs by YouTube link or video ID") + add_parser.add_argument("songs", nargs="+", help="YouTube links or video IDs") + add_parser.set_defaults(handler=add_songs) + + remove_parser = actions.add_parser("remove", help="Remove a song by YouTube link or video ID") + remove_parser.add_argument("song", help="YouTube link or video ID") + remove_parser.set_defaults(handler=remove_song) diff --git a/tools/content/youtube.py b/tools/content/youtube.py new file mode 100644 index 0000000..88827f2 --- /dev/null +++ b/tools/content/youtube.py @@ -0,0 +1,39 @@ +import json +import re +import urllib.parse +import urllib.request + +VIDEO_ID_PATTERN = re.compile(r"^[\w-]{11}$") +YOUTUBE_HOSTS = {"youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com", "youtu.be"} +PATH_PREFIXES = ("/shorts/", "/embed/", "/live/") + + +def video_id(text: str) -> str | None: + text = text.strip() + if VIDEO_ID_PATTERN.match(text): + return text + + url = urllib.parse.urlparse(text if "://" in text else f"https://{text}") + if url.hostname not in YOUTUBE_HOSTS: + return None + if url.hostname == "youtu.be": + candidate = url.path.strip("/").split("/")[0] + elif url.path == "/watch": + candidate = urllib.parse.parse_qs(url.query).get("v", [""])[0] + else: + prefix = next((prefix for prefix in PATH_PREFIXES if url.path.startswith(prefix)), None) + candidate = url.path[len(prefix):].split("/")[0] if prefix else "" + return candidate if VIDEO_ID_PATTERN.match(candidate) else None + + +def watch_url(identifier: str) -> str: + return f"https://music.youtube.com/watch?v={identifier}" + + +def video_title(identifier: str) -> str | None: + query = urllib.parse.urlencode({"url": f"https://www.youtube.com/watch?v={identifier}", "format": "json"}) + try: + with urllib.request.urlopen(f"https://www.youtube.com/oembed?{query}", timeout=10) as response: + return json.load(response).get("title") + except (OSError, ValueError): + return None