diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f8a8a69
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,30 @@
+# Build output
+public/
+resources/_gen/
+
+# Hugo lock file
+.hugo_build.lock
+
+# Editor/IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# OS files
+.DS_Store
+Thumbs.db
+
+# Environment/secrets
+.env
+*.env
+
+# Python
+__pycache__/
+*.pyc
+.venv/
+venv/
+
+# Claude Code
+.claude/
diff --git a/archetypes/movie.md b/archetypes/movie.md
index e8a794a..ab216c2 100644
--- a/archetypes/movie.md
+++ b/archetypes/movie.md
@@ -4,7 +4,14 @@ date: {{ .Date }}
draft: true
series: "Frank's Couch"
summary: ""
+# Fill in IMDB ID, then run: python scripts/fetch_movie_data.py
imdb: ""
+# Auto-filled by fetch_movie_data.py:
+poster: ""
+year:
+runtime:
+director: ""
+genres: []
tags:
- gucci
- ghost theater
diff --git a/content/posts/lethal-tender.md b/content/posts/lethal-tender.md
new file mode 100644
index 0000000..c5217a9
--- /dev/null
+++ b/content/posts/lethal-tender.md
@@ -0,0 +1,40 @@
+---
+title: 'Lethal Tender'
+date: 2025-12-24T22:47:20Z
+draft: true
+series: "Frank's Couch"
+summary: ""
+imdb: "tt0119520"
+poster: "/images/posters/lethal-tender.jpg"
+year: 1997
+runtime: 94
+director: "John Bradshaw"
+genres:
+ - Action
+ - Thriller
+tags:
+ - gucci
+ - ghost theater
+ - marcel
+ - amc-south
+ - amc-lakeline
+ - anticipated
+ - no-expectations
+ - had pizza
+---
+{{< imdbposter >}}
+
+| Date watched | |
+|---------------------|-------------------|
+| Show Time | |
+| Theater | |
+| Theater Number | |
+| Pizza | |
+| Tickets | |
+| Letterboxd Rating | |
+| Crew | |
+
+{{< /imdbposter >}}
+
+Write your review here...
+
diff --git a/hugo.yml b/hugo.yml
index 7f86fee..b8f6301 100644
--- a/hugo.yml
+++ b/hugo.yml
@@ -5,15 +5,13 @@ languageCode: en
DefaultContentLanguage: en
theme: poison
publishDir: /sdf/arpa/gm/m/mnw/html
-preserveTaxonomyNames: true
paginate: 10
favicon: favicon.ico
-pluralizelisttitles: false
+pluralizeListTitles: false
params:
brand: Double Lunch Dispatch
#remote_brand_image: https://mnw.sdf.org/blog/images/circular-me.png
brand_image: "/images/circular-me-250.png"
- description: There's still time to blog ...
dark_mode: true
description: "A blog about watching movies, drinking beers, and using technology."
keywords: "blog, movies, beers, technology, life experiences"
diff --git a/layouts/shortcodes/imdbposter.html b/layouts/shortcodes/imdbposter.html
index 9ec5cd3..a0f7b75 100644
--- a/layouts/shortcodes/imdbposter.html
+++ b/layouts/shortcodes/imdbposter.html
@@ -1,17 +1,46 @@
{{- $imdb := .Page.Params.imdb -}}
-{{- $poster := printf "https://img.omdbapi.com/?i=%s&apikey=d9641e70" $imdb -}}
+{{- $localPoster := .Page.Params.poster -}}
+{{- $year := .Page.Params.year -}}
+{{- $runtime := .Page.Params.runtime -}}
+{{- $director := .Page.Params.director -}}
+{{- $poster := "" -}}
+
+{{- if $localPoster -}}
+ {{- $poster = $localPoster -}}
+{{- else if $imdb -}}
+ {{- $poster = printf "https://img.omdbapi.com/?i=%s&apikey=d9641e70" $imdb -}}
+{{- end -}}
-
+
{{ .Inner | markdownify }}
-
-
-
+
+
+
+ {{- if $imdb -}}
+ {{- else if $poster -}}
+

+ {{- end -}}
+ {{- if or $year $runtime $director -}}
+
+ {{- if $year -}}
{{ $year }}
{{- end -}}
+ {{- if $runtime -}}
{{ $runtime }} min
{{- end -}}
+ {{- if $director -}}
+
+ {{- if reflect.IsSlice $director -}}
+ {{ delimit $director ", " }}
+ {{- else -}}
+ {{ $director }}
+ {{- end -}}
+
+ {{- end -}}
+
+ {{- end -}}
diff --git a/scripts/build.sh b/scripts/build.sh
new file mode 100755
index 0000000..a5df9f4
--- /dev/null
+++ b/scripts/build.sh
@@ -0,0 +1,20 @@
+#!/bin/bash
+# Build script for marcus-web
+# Fetches movie data, then builds Hugo site
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
+
+cd "$PROJECT_ROOT"
+
+echo "=== Fetching movie data ==="
+python3 scripts/fetch_movie_data.py
+
+echo ""
+echo "=== Building Hugo site ==="
+hugo "$@"
+
+echo ""
+echo "=== Done ==="
diff --git a/scripts/fetch_movie_data.py b/scripts/fetch_movie_data.py
new file mode 100755
index 0000000..f42c2e5
--- /dev/null
+++ b/scripts/fetch_movie_data.py
@@ -0,0 +1,271 @@
+#!/usr/bin/env python3
+"""
+Fetch movie data for Hugo posts based on IMDB ID in frontmatter.
+
+Scans all posts with an `imdb` field and fetches missing data:
+- Poster (downloaded locally)
+- Runtime
+- Year
+- Director
+- Genres
+
+Usage:
+ python scripts/fetch_movie_data.py # Process all movie posts
+ python scripts/fetch_movie_data.py --dry-run # Show what would be updated
+ python scripts/fetch_movie_data.py --force # Re-fetch even if data exists
+"""
+
+import argparse
+import os
+import re
+import sys
+from pathlib import Path
+
+import requests
+import yaml
+
+# Configuration
+TMDB_API_KEY = "9e36fdcf70a4918e0dce327acc437dff"
+
+# Paths
+SCRIPT_DIR = Path(__file__).parent
+PROJECT_ROOT = SCRIPT_DIR.parent
+CONTENT_DIR = PROJECT_ROOT / "content" / "posts"
+IMAGES_DIR = PROJECT_ROOT / "static" / "images" / "posters"
+
+# Regex to split frontmatter from content
+FRONTMATTER_RE = re.compile(r'^---\s*\n(.*?)\n---\s*\n', re.DOTALL)
+
+
+def find_movie_by_imdb(imdb_id):
+ """Find TMDB movie by IMDB ID."""
+ url = f"https://api.themoviedb.org/3/find/{imdb_id}"
+ params = {
+ "api_key": TMDB_API_KEY,
+ "external_source": "imdb_id"
+ }
+ resp = requests.get(url, params=params, timeout=10)
+ resp.raise_for_status()
+ data = resp.json()
+
+ results = data.get("movie_results", [])
+ if results:
+ return results[0]
+ return None
+
+
+def get_movie_details(tmdb_id):
+ """Get full movie details from TMDB."""
+ url = f"https://api.themoviedb.org/3/movie/{tmdb_id}"
+ params = {
+ "api_key": TMDB_API_KEY,
+ "append_to_response": "credits"
+ }
+ resp = requests.get(url, params=params, timeout=10)
+ resp.raise_for_status()
+ return resp.json()
+
+
+def get_directors(credits):
+ """Extract director names from credits."""
+ crew = credits.get("crew", [])
+ directors = [p["name"] for p in crew if p.get("job") == "Director"]
+ return directors
+
+
+def slugify(title):
+ """Convert title to URL-friendly slug."""
+ slug = title.lower()
+ slug = re.sub(r"[^a-z0-9\s-]", "", slug)
+ slug = re.sub(r"[\s_]+", "-", slug)
+ slug = re.sub(r"-+", "-", slug)
+ return slug.strip("-")
+
+
+def download_poster(poster_path, filename):
+ """Download poster from TMDB."""
+ if not poster_path:
+ return None
+
+ url = f"https://image.tmdb.org/t/p/w500{poster_path}"
+ resp = requests.get(url, timeout=10)
+ resp.raise_for_status()
+
+ IMAGES_DIR.mkdir(parents=True, exist_ok=True)
+ filepath = IMAGES_DIR / filename
+ filepath.write_bytes(resp.content)
+ return f"/images/posters/{filename}"
+
+
+def parse_post(filepath):
+ """Parse a markdown post into frontmatter dict and content string."""
+ text = filepath.read_text()
+ match = FRONTMATTER_RE.match(text)
+ if not match:
+ return None, text
+
+ fm_text = match.group(1)
+ content = text[match.end():]
+
+ try:
+ frontmatter = yaml.safe_load(fm_text)
+ except yaml.YAMLError:
+ return None, text
+
+ return frontmatter, content
+
+
+def write_post(filepath, frontmatter, content):
+ """Write frontmatter and content back to markdown file."""
+ # Use default_flow_style=False for readable YAML
+ # Use allow_unicode=True for proper character handling
+ fm_text = yaml.dump(
+ frontmatter,
+ default_flow_style=False,
+ allow_unicode=True,
+ sort_keys=False
+ )
+ text = f"---\n{fm_text}---\n{content}"
+ filepath.write_text(text)
+
+
+def process_post(filepath, dry_run=False, force=False):
+ """Process a single post, fetching missing movie data."""
+ frontmatter, content = parse_post(filepath)
+ if frontmatter is None:
+ return False
+
+ imdb_id = frontmatter.get("imdb")
+ if not imdb_id:
+ return False
+
+ # Check what's missing
+ has_poster = bool(frontmatter.get("poster"))
+ has_runtime = bool(frontmatter.get("runtime"))
+ has_year = bool(frontmatter.get("year"))
+ has_director = bool(frontmatter.get("director"))
+
+ needs_update = not (has_poster and has_runtime and has_year and has_director)
+
+ if not needs_update and not force:
+ return False
+
+ print(f"\nProcessing: {filepath.name}")
+ print(f" IMDB: {imdb_id}")
+
+ if dry_run:
+ missing = []
+ if not has_poster:
+ missing.append("poster")
+ if not has_runtime:
+ missing.append("runtime")
+ if not has_year:
+ missing.append("year")
+ if not has_director:
+ missing.append("director")
+ print(f" Would fetch: {', '.join(missing)}")
+ return True
+
+ # Find movie on TMDB
+ print(" Finding movie on TMDB...")
+ movie = find_movie_by_imdb(imdb_id)
+ if not movie:
+ print(f" ERROR: Movie not found for IMDB ID: {imdb_id}")
+ return False
+
+ tmdb_id = movie["id"]
+ print(f" Found: {movie.get('title')} (TMDB: {tmdb_id})")
+
+ # Get full details
+ print(" Fetching details...")
+ details = get_movie_details(tmdb_id)
+
+ updated = False
+
+ # Update poster
+ if not has_poster or force:
+ poster_path = details.get("poster_path")
+ if poster_path:
+ title = frontmatter.get("title", "movie")
+ filename = f"{slugify(title)}.jpg"
+ print(f" Downloading poster...")
+ poster_url = download_poster(poster_path, filename)
+ if poster_url:
+ frontmatter["poster"] = poster_url
+ print(f" Poster saved: {poster_url}")
+ updated = True
+
+ # Update runtime
+ if not has_runtime or force:
+ runtime = details.get("runtime")
+ if runtime:
+ frontmatter["runtime"] = runtime
+ print(f" Runtime: {runtime} minutes")
+ updated = True
+
+ # Update year
+ if not has_year or force:
+ release_date = details.get("release_date", "")
+ if release_date:
+ year = release_date.split("-")[0]
+ frontmatter["year"] = int(year)
+ print(f" Year: {year}")
+ updated = True
+
+ # Update director
+ if not has_director or force:
+ credits = details.get("credits", {})
+ directors = get_directors(credits)
+ if directors:
+ # Store as string if single, list if multiple
+ if len(directors) == 1:
+ frontmatter["director"] = directors[0]
+ else:
+ frontmatter["director"] = directors
+ print(f" Director: {', '.join(directors)}")
+ updated = True
+
+ # Update genres (bonus)
+ if "genres" not in frontmatter or force:
+ genres = [g["name"] for g in details.get("genres", [])]
+ if genres:
+ frontmatter["genres"] = genres
+ updated = True
+
+ if updated:
+ write_post(filepath, frontmatter, content)
+ print(" Updated!")
+
+ return updated
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Fetch movie data for Hugo posts")
+ parser.add_argument("--dry-run", action="store_true", help="Show what would be updated")
+ parser.add_argument("--force", action="store_true", help="Re-fetch even if data exists")
+ parser.add_argument("file", nargs="?", help="Specific file to process")
+ args = parser.parse_args()
+
+ if args.file:
+ filepath = Path(args.file)
+ if not filepath.is_absolute():
+ filepath = PROJECT_ROOT / filepath
+ if not filepath.exists():
+ print(f"File not found: {filepath}")
+ sys.exit(1)
+ files = [filepath]
+ else:
+ files = list(CONTENT_DIR.glob("**/*.md"))
+
+ print(f"Scanning {len(files)} posts for movie data...")
+
+ updated = 0
+ for filepath in files:
+ if process_post(filepath, dry_run=args.dry_run, force=args.force):
+ updated += 1
+
+ print(f"\n{'Would update' if args.dry_run else 'Updated'}: {updated} posts")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/import_letterboxd.py b/scripts/import_letterboxd.py
new file mode 100755
index 0000000..ddbf3da
--- /dev/null
+++ b/scripts/import_letterboxd.py
@@ -0,0 +1,285 @@
+#!/usr/bin/env python3
+"""
+Import movies from Letterboxd diary to Hugo draft posts.
+
+Usage:
+ python scripts/import_letterboxd.py # Interactive mode - pick from recent
+ python scripts/import_letterboxd.py --latest # Import most recent entry
+ python scripts/import_letterboxd.py --list # Just list recent entries
+"""
+
+import argparse
+import os
+import re
+import sys
+from datetime import datetime
+from pathlib import Path
+from urllib.parse import urlparse
+import xml.etree.ElementTree as ET
+
+import requests
+
+# Configuration
+LETTERBOXD_USER = "marcusEID"
+TMDB_API_KEY = "9e36fdcf70a4918e0dce327acc437dff" # From get_posters.py
+RSS_URL = f"https://letterboxd.com/{LETTERBOXD_USER}/rss/"
+
+# Paths (relative to script location)
+SCRIPT_DIR = Path(__file__).parent
+PROJECT_ROOT = SCRIPT_DIR.parent
+CONTENT_DIR = PROJECT_ROOT / "content" / "posts"
+IMAGES_DIR = PROJECT_ROOT / "static" / "images" / "posters"
+
+# XML namespaces in Letterboxd RSS
+NAMESPACES = {
+ "letterboxd": "https://letterboxd.com",
+ "tmdb": "https://themoviedb.org",
+ "dc": "http://purl.org/dc/elements/1.1/",
+}
+
+
+def fetch_rss():
+ """Fetch and parse Letterboxd RSS feed."""
+ resp = requests.get(RSS_URL, timeout=10)
+ resp.raise_for_status()
+ return ET.fromstring(resp.content)
+
+
+def parse_movies(root):
+ """Extract movie entries from RSS (skip lists)."""
+ movies = []
+ for item in root.findall(".//item"):
+ # Skip lists (they don't have tmdb:movieId)
+ tmdb_id = item.find("tmdb:movieId", NAMESPACES)
+ if tmdb_id is None:
+ continue
+
+ title = item.find("letterboxd:filmTitle", NAMESPACES)
+ year = item.find("letterboxd:filmYear", NAMESPACES)
+ rating = item.find("letterboxd:memberRating", NAMESPACES)
+ watched = item.find("letterboxd:watchedDate", NAMESPACES)
+ rewatch = item.find("letterboxd:rewatch", NAMESPACES)
+ link = item.find("link")
+
+ movies.append({
+ "tmdb_id": tmdb_id.text,
+ "title": title.text if title is not None else "Unknown",
+ "year": year.text if year is not None else "",
+ "rating": rating.text if rating is not None else "",
+ "watched_date": watched.text if watched is not None else "",
+ "rewatch": rewatch.text if rewatch is not None else "No",
+ "letterboxd_url": link.text if link is not None else "",
+ })
+
+ return movies
+
+
+def get_tmdb_details(tmdb_id):
+ """Fetch movie details from TMDB including IMDB ID and poster."""
+ url = f"https://api.themoviedb.org/3/movie/{tmdb_id}"
+ params = {"api_key": TMDB_API_KEY}
+ resp = requests.get(url, params=params, timeout=10)
+ resp.raise_for_status()
+ data = resp.json()
+
+ return {
+ "imdb_id": data.get("imdb_id", ""),
+ "poster_path": data.get("poster_path", ""),
+ "overview": data.get("overview", ""),
+ }
+
+
+def download_poster(poster_path, filename):
+ """Download poster from TMDB to static/images/posters/."""
+ if not poster_path:
+ print(" No poster available")
+ return None
+
+ # Use w500 size for good quality without being huge
+ url = f"https://image.tmdb.org/t/p/w500{poster_path}"
+ resp = requests.get(url, timeout=10)
+ resp.raise_for_status()
+
+ IMAGES_DIR.mkdir(parents=True, exist_ok=True)
+ filepath = IMAGES_DIR / filename
+ filepath.write_bytes(resp.content)
+ print(f" Poster saved: {filepath.relative_to(PROJECT_ROOT)}")
+ return f"/images/posters/{filename}"
+
+
+def slugify(title):
+ """Convert title to URL-friendly slug."""
+ slug = title.lower()
+ slug = re.sub(r"[^a-z0-9\s-]", "", slug)
+ slug = re.sub(r"[\s_]+", "-", slug)
+ slug = re.sub(r"-+", "-", slug)
+ return slug.strip("-")
+
+
+def rating_to_stars(rating):
+ """Convert numeric rating to star display."""
+ if not rating:
+ return ""
+ r = float(rating)
+ full = int(r)
+ half = r - full >= 0.5
+ stars = "*" * full
+ if half:
+ stars += " 1/2"
+ return f"{stars} ({rating})"
+
+
+def create_draft_post(movie, tmdb_details, poster_url):
+ """Create a Hugo draft post for the movie."""
+ slug = slugify(movie["title"])
+ filename = f"{slug}.md"
+ filepath = CONTENT_DIR / filename
+
+ if filepath.exists():
+ print(f" Post already exists: {filepath.relative_to(PROJECT_ROOT)}")
+ return None
+
+ # Format the date for Hugo
+ now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
+
+ # Format watched date nicely
+ watched = movie["watched_date"]
+ if watched:
+ try:
+ dt = datetime.strptime(watched, "%Y-%m-%d")
+ watched_display = dt.strftime("%B %d, %Y")
+ except ValueError:
+ watched_display = watched
+ else:
+ watched_display = ""
+
+ imdb_id = tmdb_details.get("imdb_id", "")
+ rating_display = rating_to_stars(movie["rating"])
+
+ # Build the frontmatter and content
+ content = f'''---
+title: '{movie["title"]}'
+date: {now}
+draft: true
+series: "Frank's Couch"
+summary: ""
+imdb: "{imdb_id}"
+poster: "{poster_url or ''}"
+tags:
+ - gucci
+ - ghost theater
+ - marcel
+ - amc-south
+ - amc-lakeline
+ - anticipated
+ - no-expectations
+ - had pizza
+---
+{{{{< imdbposter >}}}}
+
+| Date watched | {watched_display} |
+|---------------------|-------------------|
+| Show Time | |
+| Theater | |
+| Theater Number | |
+| Pizza | |
+| Tickets | |
+| Letterboxd Rating | {rating_display} |
+| Crew | |
+
+{{{{< /imdbposter >}}}}
+
+Write your review here...
+
+'''
+
+ filepath.write_text(content)
+ print(f" Draft created: {filepath.relative_to(PROJECT_ROOT)}")
+ return filepath
+
+
+def display_movies(movies, limit=10):
+ """Display a list of recent movies."""
+ print(f"\nRecent movies from Letterboxd ({LETTERBOXD_USER}):\n")
+ for i, m in enumerate(movies[:limit], 1):
+ rewatch = " (rewatch)" if m["rewatch"] == "Yes" else ""
+ rating = f" - {m['rating']}*" if m["rating"] else ""
+ print(f" {i}. {m['title']} ({m['year']}){rating}{rewatch}")
+ print(f" Watched: {m['watched_date']}")
+ print()
+
+
+def import_movie(movie):
+ """Import a single movie: fetch details, download poster, create post."""
+ print(f"\nImporting: {movie['title']} ({movie['year']})")
+
+ # Get TMDB details
+ print(" Fetching TMDB details...")
+ tmdb = get_tmdb_details(movie["tmdb_id"])
+
+ # Download poster
+ poster_url = None
+ if tmdb["poster_path"]:
+ print(" Downloading poster...")
+ poster_filename = f"{slugify(movie['title'])}.jpg"
+ poster_url = download_poster(tmdb["poster_path"], poster_filename)
+
+ # Create draft post
+ print(" Creating draft post...")
+ filepath = create_draft_post(movie, tmdb, poster_url)
+
+ if filepath:
+ print(f"\nDone! Edit your draft at: {filepath.relative_to(PROJECT_ROOT)}")
+ if tmdb.get("imdb_id"):
+ print(f"IMDB: https://www.imdb.com/title/{tmdb['imdb_id']}/")
+
+ return filepath
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Import Letterboxd movies to Hugo")
+ parser.add_argument("--latest", action="store_true", help="Import most recent entry")
+ parser.add_argument("--list", action="store_true", help="Just list recent entries")
+ parser.add_argument("--count", type=int, default=10, help="Number of entries to show")
+ args = parser.parse_args()
+
+ print("Fetching Letterboxd RSS feed...")
+ try:
+ root = fetch_rss()
+ except Exception as e:
+ print(f"Error fetching RSS: {e}")
+ sys.exit(1)
+
+ movies = parse_movies(root)
+ if not movies:
+ print("No movies found in feed.")
+ sys.exit(1)
+
+ if args.list:
+ display_movies(movies, args.count)
+ sys.exit(0)
+
+ if args.latest:
+ import_movie(movies[0])
+ sys.exit(0)
+
+ # Interactive mode
+ display_movies(movies, args.count)
+
+ try:
+ choice = input("Enter number to import (or 'q' to quit): ").strip()
+ if choice.lower() == 'q':
+ sys.exit(0)
+ idx = int(choice) - 1
+ if 0 <= idx < len(movies):
+ import_movie(movies[idx])
+ else:
+ print("Invalid selection")
+ sys.exit(1)
+ except (ValueError, KeyboardInterrupt):
+ print("\nCancelled")
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/new_movie.py b/scripts/new_movie.py
new file mode 100755
index 0000000..6f17c66
--- /dev/null
+++ b/scripts/new_movie.py
@@ -0,0 +1,229 @@
+#!/usr/bin/env python3
+"""
+Create a new movie post from an IMDB ID.
+
+Usage:
+ python scripts/new_movie.py tt1234567
+ python scripts/new_movie.py tt1234567 --title "Custom Title"
+ python scripts/new_movie.py https://www.imdb.com/title/tt1234567/
+
+This fetches all metadata (title, year, runtime, director, poster) and creates
+a ready-to-edit draft post.
+"""
+
+import argparse
+import re
+import subprocess
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+
+import requests
+
+# Configuration
+TMDB_API_KEY = "9e36fdcf70a4918e0dce327acc437dff"
+
+# Paths
+SCRIPT_DIR = Path(__file__).parent
+PROJECT_ROOT = SCRIPT_DIR.parent
+CONTENT_DIR = PROJECT_ROOT / "content" / "posts"
+IMAGES_DIR = PROJECT_ROOT / "static" / "images" / "posters"
+
+
+def extract_imdb_id(input_str):
+ """Extract IMDB ID from URL or raw ID."""
+ match = re.search(r'(tt\d+)', input_str)
+ if match:
+ return match.group(1)
+ return None
+
+
+def find_movie_by_imdb(imdb_id):
+ """Find TMDB movie by IMDB ID."""
+ url = f"https://api.themoviedb.org/3/find/{imdb_id}"
+ params = {
+ "api_key": TMDB_API_KEY,
+ "external_source": "imdb_id"
+ }
+ resp = requests.get(url, params=params, timeout=10)
+ resp.raise_for_status()
+ data = resp.json()
+
+ results = data.get("movie_results", [])
+ if results:
+ return results[0]
+ return None
+
+
+def get_movie_details(tmdb_id):
+ """Get full movie details from TMDB."""
+ url = f"https://api.themoviedb.org/3/movie/{tmdb_id}"
+ params = {
+ "api_key": TMDB_API_KEY,
+ "append_to_response": "credits"
+ }
+ resp = requests.get(url, params=params, timeout=10)
+ resp.raise_for_status()
+ return resp.json()
+
+
+def get_directors(credits):
+ """Extract director names from credits."""
+ crew = credits.get("crew", [])
+ directors = [p["name"] for p in crew if p.get("job") == "Director"]
+ return directors
+
+
+def slugify(title):
+ """Convert title to URL-friendly slug."""
+ slug = title.lower()
+ slug = re.sub(r"[^a-z0-9\s-]", "", slug)
+ slug = re.sub(r"[\s_]+", "-", slug)
+ slug = re.sub(r"-+", "-", slug)
+ return slug.strip("-")
+
+
+def download_poster(poster_path, filename):
+ """Download and save poster. Returns local path."""
+ if not poster_path:
+ return ""
+
+ # w500 = 500px wide, good for 200px display on retina
+ url = f"https://image.tmdb.org/t/p/w500{poster_path}"
+ resp = requests.get(url, timeout=10)
+ resp.raise_for_status()
+
+ IMAGES_DIR.mkdir(parents=True, exist_ok=True)
+ filepath = IMAGES_DIR / filename
+ filepath.write_bytes(resp.content)
+
+ return f"/images/posters/{filename}"
+
+
+def create_post(imdb_id, details, poster_path, custom_title=None):
+ """Create the Hugo post with all metadata."""
+ title = custom_title or details.get("title", "Untitled")
+ slug = slugify(title)
+ filename = f"{slug}.md"
+ filepath = CONTENT_DIR / filename
+
+ if filepath.exists():
+ print(f"Post already exists: {filepath}")
+ return None
+
+ # Download poster
+ poster_url = ""
+ if details.get("poster_path"):
+ poster_filename = f"{slug}.jpg"
+ print(f"Downloading poster...")
+ poster_url = download_poster(details["poster_path"], poster_filename)
+
+ # Extract metadata
+ release_date = details.get("release_date", "")
+ year = int(release_date.split("-")[0]) if release_date else ""
+ runtime = details.get("runtime", "")
+
+ credits = details.get("credits", {})
+ directors = get_directors(credits)
+ director_str = directors[0] if len(directors) == 1 else directors if directors else ""
+
+ genres = [g["name"] for g in details.get("genres", [])]
+
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+ # Build frontmatter
+ # Using manual formatting to control output
+ genres_yaml = "\n".join(f" - {g}" for g in genres) if genres else "[]"
+ if isinstance(director_str, list):
+ director_yaml = "\n".join(f" - {d}" for d in director_str)
+ director_yaml = f"\n{director_yaml}"
+ else:
+ director_yaml = f' "{director_str}"' if director_str else ' ""'
+
+ content = f'''---
+title: '{title}'
+date: {now}
+draft: true
+series: "Frank's Couch"
+summary: ""
+imdb: "{imdb_id}"
+poster: "{poster_url}"
+year: {year}
+runtime: {runtime}
+director:{director_yaml}
+genres:
+{genres_yaml}
+tags:
+ - gucci
+ - ghost theater
+ - marcel
+ - amc-south
+ - amc-lakeline
+ - anticipated
+ - no-expectations
+ - had pizza
+---
+{{{{< imdbposter >}}}}
+
+| Date watched | |
+|---------------------|-------------------|
+| Show Time | |
+| Theater | |
+| Theater Number | |
+| Pizza | |
+| Tickets | |
+| Letterboxd Rating | |
+| Crew | |
+
+{{{{< /imdbposter >}}}}
+
+Write your review here...
+
+'''
+
+ filepath.write_text(content)
+ return filepath
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Create a new movie post from IMDB ID",
+ epilog="Example: python scripts/new_movie.py tt1234567"
+ )
+ parser.add_argument("imdb", help="IMDB ID or URL (e.g., tt1234567)")
+ parser.add_argument("--title", help="Custom title (default: from TMDB)")
+ args = parser.parse_args()
+
+ # Extract IMDB ID
+ imdb_id = extract_imdb_id(args.imdb)
+ if not imdb_id:
+ print(f"Invalid IMDB ID: {args.imdb}")
+ print("Expected format: tt1234567 or https://www.imdb.com/title/tt1234567/")
+ sys.exit(1)
+
+ print(f"Looking up {imdb_id}...")
+
+ # Find on TMDB
+ movie = find_movie_by_imdb(imdb_id)
+ if not movie:
+ print(f"Movie not found for IMDB ID: {imdb_id}")
+ sys.exit(1)
+
+ tmdb_id = movie["id"]
+ print(f"Found: {movie.get('title')} ({movie.get('release_date', '')[:4]})")
+
+ # Get full details
+ print("Fetching details...")
+ details = get_movie_details(tmdb_id)
+
+ # Create post
+ filepath = create_post(imdb_id, details, details.get("poster_path"), args.title)
+
+ if filepath:
+ print(f"\nCreated: {filepath.relative_to(PROJECT_ROOT)}")
+ print(f"IMDB: https://www.imdb.com/title/{imdb_id}/")
+ print(f"\nEdit your post, then run: ./scripts/build.sh")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/remote_publish.sh b/scripts/remote_publish.sh
new file mode 100755
index 0000000..816e790
--- /dev/null
+++ b/scripts/remote_publish.sh
@@ -0,0 +1,136 @@
+#!/bin/bash
+# Remote publish script for marcus-web
+# Pushes local changes if needed, then builds on SDF
+
+set -e
+
+SDF_HOST="mnw@sdf.org"
+REMOTE_DIR="~/marcus-web"
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+echo -e "${GREEN}=== Remote Publish ===${NC}"
+echo ""
+
+# Check if we're in a git repo
+if ! git rev-parse --git-dir > /dev/null 2>&1; then
+ echo -e "${RED}Error: Not in a git repository${NC}"
+ exit 1
+fi
+
+# Fetch latest from remote to compare
+echo "Checking repository status..."
+git fetch origin 2>/dev/null || true
+
+# Check for uncommitted changes (staged or unstaged)
+if ! git diff-index --quiet HEAD -- 2>/dev/null || [ -n "$(git ls-files --others --exclude-standard)" ]; then
+ echo -e "${YELLOW}You have uncommitted changes:${NC}"
+ git status --short
+ echo ""
+ read -p "Would you like to commit these changes? (y/n) " -n 1 -r
+ echo ""
+ if [[ $REPLY =~ ^[Yy]$ ]]; then
+ # Stage all changes
+ git add -A
+
+ read -p "Custom commit message? (y/n) " -n 1 -r
+ echo ""
+ if [[ $REPLY =~ ^[Yy]$ ]]; then
+ read -p "Enter commit message: " COMMIT_MSG
+ else
+ # Try to find a post title from recently modified markdown files
+ COMMIT_MSG=""
+ RECENT_MD=$(git diff --cached --name-only | grep '\.md$' | head -1)
+ if [ -n "$RECENT_MD" ] && [ -f "$RECENT_MD" ]; then
+ # Extract title from frontmatter
+ TITLE=$(grep -m1 "^title:" "$RECENT_MD" 2>/dev/null | sed "s/^title:[[:space:]]*['\"]*//" | sed "s/['\"].*$//")
+ if [ -n "$TITLE" ]; then
+ COMMIT_MSG="Remote Publish Auto Commit - $TITLE"
+ fi
+ fi
+ # Fallback to date-based message
+ if [ -z "$COMMIT_MSG" ]; then
+ COMMIT_MSG="Auto Commit - $(date '+%Y-%m-%d %H:%M')"
+ fi
+ echo "Using commit message: $COMMIT_MSG"
+ fi
+
+ git commit -m "$COMMIT_MSG"
+ echo -e "${GREEN}Changes committed${NC}"
+ echo ""
+ else
+ echo -e "${YELLOW}Continuing without committing...${NC}"
+ read -p "Are you sure? Remote will not have these changes. (y/n) " -n 1 -r
+ echo ""
+ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+ echo "Aborted."
+ exit 1
+ fi
+ fi
+fi
+
+# Check if local is ahead of remote
+LOCAL=$(git rev-parse HEAD 2>/dev/null)
+REMOTE=$(git rev-parse @{u} 2>/dev/null || echo "")
+BASE=$(git merge-base HEAD @{u} 2>/dev/null || echo "")
+
+if [ -z "$REMOTE" ]; then
+ echo -e "${YELLOW}Warning: No upstream branch configured${NC}"
+elif [ "$LOCAL" != "$REMOTE" ]; then
+ if [ "$LOCAL" = "$BASE" ]; then
+ echo -e "${YELLOW}Local is behind remote. You may want to pull first.${NC}"
+ elif [ "$REMOTE" = "$BASE" ]; then
+ echo -e "${YELLOW}Local commits not pushed to remote:${NC}"
+ git log --oneline @{u}..HEAD
+ echo ""
+ read -p "Push changes before publishing? (y/n) " -n 1 -r
+ echo ""
+ if [[ $REPLY =~ ^[Yy]$ ]]; then
+ echo "Pushing..."
+ git push
+ echo -e "${GREEN}Pushed successfully${NC}"
+ else
+ echo -e "${YELLOW}Skipping push. Remote will not have latest changes.${NC}"
+ read -p "Continue anyway? (y/n) " -n 1 -r
+ echo ""
+ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+ echo "Aborted."
+ exit 1
+ fi
+ fi
+ else
+ echo -e "${YELLOW}Warning: Local and remote have diverged${NC}"
+ read -p "Continue anyway? (y/n) " -n 1 -r
+ echo ""
+ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+ echo "Aborted."
+ exit 1
+ fi
+ fi
+else
+ echo -e "${GREEN}Repository is up to date with remote${NC}"
+fi
+
+echo ""
+echo -e "${GREEN}=== Connecting to SDF ===${NC}"
+echo ""
+
+# Build the remote command
+REMOTE_CMD="cd $REMOTE_DIR && git pull && hugo && mkhomepage -p"
+
+# Try SSH with key auth first, fall back to password prompt
+if ssh -o BatchMode=yes -o ConnectTimeout=5 "$SDF_HOST" "echo 'SSH key auth successful'" 2>/dev/null; then
+ echo "Using SSH key authentication..."
+ ssh "$SDF_HOST" "$REMOTE_CMD"
+else
+ echo "SSH key auth failed or not configured, using password..."
+ ssh "$SDF_HOST" "$REMOTE_CMD"
+fi
+
+echo ""
+echo -e "${GREEN}=== Published successfully! ===${NC}"
+echo "Site is live at: https://mnw.sdf.org/"
diff --git a/static/images/posters/lethal-tender.jpg b/static/images/posters/lethal-tender.jpg
new file mode 100644
index 0000000..5a51e05
Binary files /dev/null and b/static/images/posters/lethal-tender.jpg differ