Trying to automate the movie review pages
This commit is contained in:
+30
@@ -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/
|
||||
@@ -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
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 -}}
|
||||
|
||||
<div style="display: flex; justify-content: flex-start; align-items: flex-start;">
|
||||
<!-- Left section: code block with viewing details -->
|
||||
<!-- Left section: viewing details -->
|
||||
<div style="flex: 1; padding-right: 20px;">
|
||||
{{ .Inner | markdownify }}
|
||||
</div>
|
||||
|
||||
<!-- Right section: movie poster -->
|
||||
<div style="flex-shrink: 0;">
|
||||
|
||||
<!-- Right section: movie poster and info -->
|
||||
<div style="flex-shrink: 0; text-align: center;">
|
||||
{{- if $imdb -}}
|
||||
<a href="https://www.imdb.com/title/{{ $imdb }}" target="_blank">
|
||||
<img src="{{ $poster }}" alt="Movie Poster" style="width: 200px;" />
|
||||
</a>
|
||||
{{- else if $poster -}}
|
||||
<img src="{{ $poster }}" alt="Movie Poster" style="width: 200px;" />
|
||||
{{- end -}}
|
||||
{{- if or $year $runtime $director -}}
|
||||
<div style="font-size: 0.85em; color: #666; margin-top: 8px;">
|
||||
{{- if $year -}}<div>{{ $year }}</div>{{- end -}}
|
||||
{{- if $runtime -}}<div>{{ $runtime }} min</div>{{- end -}}
|
||||
{{- if $director -}}
|
||||
<div>
|
||||
{{- if reflect.IsSlice $director -}}
|
||||
{{ delimit $director ", " }}
|
||||
{{- else -}}
|
||||
{{ $director }}
|
||||
{{- end -}}
|
||||
</div>
|
||||
{{- end -}}
|
||||
</div>
|
||||
{{- end -}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Executable
+20
@@ -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 ==="
|
||||
Executable
+271
@@ -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()
|
||||
Executable
+285
@@ -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()
|
||||
Executable
+229
@@ -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()
|
||||
Executable
+136
@@ -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/"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Reference in New Issue
Block a user