Add content CLI
This commit is contained in:
1 parent
3da32a4a90
commit
661ee019e5
10 files changed
+387
No files matched your search
@@ -13,6 +13,8 @@ PROJECT.md
|
|||||||
Update.md
|
Update.md
|
||||||
UPDATE.md
|
UPDATE.md
|
||||||
blogs
|
blogs
|
||||||
|
tools
|
||||||
|
__pycache__
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
@@ -23,6 +23,7 @@ vite.config.js.timestamp-*
|
|||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
bun.lock
|
bun.lock
|
||||||
|
__pycache__/
|
||||||
Update.md
|
Update.md
|
||||||
PROJECT.md
|
PROJECT.md
|
||||||
blogs/
|
blogs/
|
||||||
@@ -97,6 +97,23 @@ Copy `.env.example` to `.env` and fill in what you need. Every value is optional
|
|||||||
|
|
||||||
Site content lives in `content/`.
|
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 |
|
| File | Holds |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `profile.json` | Name, bio, socials, skills and photos |
|
| `profile.json` | Name, bio, socials, skills and photos |
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"start": "bun --bun vite build && bun ./build/index.js",
|
"start": "bun --bun vite build && bun ./build/index.js",
|
||||||
|
"content": "python3 tools/content/cli.py",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -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 + "]"
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user