887b1b8d2e
Increased line height from 3.8 to 9.5 points for the small text at the bottom of card backs to prevent text overlap when song titles and artist names wrap to multiple lines. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
307 lines
11 KiB
Python
307 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
import argparse
|
|
import math
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import List, Optional, Tuple
|
|
|
|
import qrcode
|
|
from PIL import Image
|
|
from reportlab.lib import colors
|
|
from reportlab.lib.pagesizes import A4
|
|
from reportlab.lib.units import mm
|
|
from reportlab.lib.utils import ImageReader
|
|
from reportlab.pdfgen import canvas
|
|
import spotipy
|
|
from spotipy.oauth2 import SpotifyClientCredentials
|
|
|
|
|
|
# ---------- Data model ----------
|
|
|
|
@dataclass
|
|
class TrackCard:
|
|
title: str
|
|
artists: str
|
|
year: Optional[int]
|
|
url: str
|
|
qr_img: Image.Image # PIL image
|
|
|
|
|
|
# ---------- Spotify helpers ----------
|
|
|
|
PLAYLIST_REGEXES = [
|
|
re.compile(r"https?://open\.spotify\.com/playlist/([a-zA-Z0-9]+)"),
|
|
re.compile(r"spotify:playlist:([a-zA-Z0-9]+)"),
|
|
]
|
|
|
|
def extract_playlist_id(url: str) -> str:
|
|
for rx in PLAYLIST_REGEXES:
|
|
m = rx.search(url)
|
|
if m:
|
|
return m.group(1)
|
|
raise ValueError("Unsupported playlist URL. Expected open.spotify.com/playlist/<id> or spotify:playlist:<id>")
|
|
|
|
def spotify_client(client_id: str, client_secret: str) -> spotipy.Spotify:
|
|
auth = SpotifyClientCredentials(client_id=client_id, client_secret=client_secret)
|
|
return spotipy.Spotify(auth_manager=auth)
|
|
|
|
def fetch_tracks(sp: spotipy.Spotify, playlist_id: str, limit: Optional[int] = None) -> List[dict]:
|
|
items = []
|
|
offset = 0
|
|
page_size = 100
|
|
while True:
|
|
batch = sp.playlist_items(
|
|
playlist_id,
|
|
offset=offset,
|
|
limit=page_size,
|
|
fields="items(track(name,artists(name),external_urls.spotify,album(release_date))),total,next",
|
|
additional_types=["track"],
|
|
)
|
|
for it in batch["items"]:
|
|
if it and it.get("track"):
|
|
items.append(it["track"])
|
|
if limit and len(items) >= limit:
|
|
return items
|
|
if not batch.get("next"):
|
|
break
|
|
offset += page_size
|
|
return items
|
|
|
|
def safe_year_from_release_date(release_date: Optional[str]) -> Optional[int]:
|
|
if not release_date:
|
|
return None
|
|
try:
|
|
# release_date may be 'yyyy', 'yyyy-mm-dd', or 'yyyy-mm'
|
|
return int(release_date.split("-")[0])
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
# ---------- QR code helper ----------
|
|
|
|
def make_qr_pil(url: str, box_size: int = 8, border: int = 2) -> Image.Image:
|
|
qr = qrcode.QRCode(
|
|
version=None, # automatic
|
|
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
|
box_size=box_size,
|
|
border=border,
|
|
)
|
|
qr.add_data(url)
|
|
qr.make(fit=True)
|
|
img = qr.make_image(fill_color="black", back_color="white")
|
|
if not isinstance(img, Image.Image):
|
|
img = img.convert("RGB")
|
|
return img
|
|
|
|
|
|
# ---------- Typography ----------
|
|
|
|
def wrap_text(text: str, max_width: float, font_name: str, font_size: float, canv: canvas.Canvas) -> List[str]:
|
|
# Simple greedy line-wrapping
|
|
words = text.split()
|
|
lines = []
|
|
current = ""
|
|
for w in words:
|
|
test = (w if not current else current + " " + w)
|
|
if canv.stringWidth(test, font_name, font_size) <= max_width:
|
|
current = test
|
|
else:
|
|
if current:
|
|
lines.append(current)
|
|
current = w
|
|
if current:
|
|
lines.append(current)
|
|
return lines
|
|
|
|
|
|
# ---------- PDF layout ----------
|
|
|
|
PAGE_W, PAGE_H = A4
|
|
CARD_W = 63 * mm
|
|
CARD_H = 88 * mm
|
|
COLS = 3
|
|
ROWS = 3
|
|
MARGIN_LR = (210 * mm - COLS * CARD_W) / 2.0
|
|
MARGIN_TB = (297 * mm - ROWS * CARD_H) / 2.0
|
|
|
|
def grid_position(index: int) -> Tuple[int, int, int]:
|
|
"""
|
|
Returns (page_index, col, row) for a zero-based card index.
|
|
"""
|
|
per_page = COLS * ROWS
|
|
page = index // per_page
|
|
pos = index % per_page
|
|
row = pos // COLS
|
|
col = pos % COLS
|
|
return page, col, row
|
|
|
|
def card_origin(col: int, row: int) -> Tuple[float, float]:
|
|
x = MARGIN_LR + col * CARD_W
|
|
y = PAGE_H - MARGIN_TB - (row + 1) * CARD_H
|
|
return x, y
|
|
|
|
def draw_cut_border(c: canvas.Canvas, x: float, y: float, w: float, h: float):
|
|
c.setLineWidth(0.3)
|
|
c.setStrokeColor(colors.lightgrey)
|
|
c.rect(x, y, w, h, stroke=1, fill=0)
|
|
c.setStrokeColor(colors.black)
|
|
c.setLineWidth(1.0)
|
|
|
|
def draw_card_front(c: canvas.Canvas, x: float, y: float, card: TrackCard, qr_size_mm: float, show_year_on_front: bool):
|
|
draw_cut_border(c, x, y, CARD_W, CARD_H)
|
|
pad = 4 * mm
|
|
qr_size = qr_size_mm * mm
|
|
qr_x = x + (CARD_W - qr_size) / 2.0
|
|
qr_y = y + CARD_H - pad - qr_size
|
|
c.drawImage(ImageReader(card.qr_img), qr_x, qr_y, qr_size, qr_size, preserveAspectRatio=True, mask='auto')
|
|
|
|
# Title and artist
|
|
text_w = CARD_W - 2 * pad
|
|
c.setFillColor(colors.black)
|
|
# Title
|
|
c.setFont("Helvetica-Bold", 10)
|
|
title_lines = wrap_text(card.title, text_w, "Helvetica-Bold", 10, c)
|
|
ty = qr_y - 4 * mm
|
|
for line in title_lines[:3]:
|
|
# c.drawCentredString(x + CARD_W / 2.0, ty, line)
|
|
ty -= 4.5 * mm
|
|
# Artist(s)
|
|
c.setFont("Helvetica", 9)
|
|
artists = card.artists
|
|
artist_lines = wrap_text(artists, text_w, "Helvetica", 9, c)
|
|
for line in artist_lines[:2]:
|
|
# c.drawCentredString(x + CARD_W / 2.0, ty, line)
|
|
ty -= 4.2 * mm
|
|
|
|
# Optional year on front (small, bottom-right)
|
|
if show_year_on_front and card.year:
|
|
c.setFont("Helvetica", 8)
|
|
c.setFillColor(colors.darkgrey)
|
|
c.drawRightString(x + CARD_W - pad, y + pad, str(card.year))
|
|
|
|
def draw_card_back(c: canvas.Canvas, x: float, y: float, card: TrackCard):
|
|
draw_cut_border(c, x, y, CARD_W, CARD_H)
|
|
pad = 6 * mm
|
|
c.setFillColor(colors.black)
|
|
# Year in the centre
|
|
if card.year:
|
|
c.setFont("Helvetica-Bold", 28)
|
|
c.drawCentredString(x + CARD_W / 2.0, y + CARD_H / 2.0 + 4 * mm, str(card.year))
|
|
# Title and artist small at bottom
|
|
c.setFont("Helvetica", 8)
|
|
label = f"{card.title} — {card.artists}"
|
|
text_w = CARD_W - 2 * pad
|
|
lines = wrap_text(label, text_w, "Helvetica", 8, c)
|
|
line_height = 9.5
|
|
base_y = y + pad + (len(lines) - 1) * line_height
|
|
for i, line in enumerate(lines[:3]):
|
|
c.drawCentredString(x + CARD_W / 2.0, base_y - i * line_height, line)
|
|
|
|
def build_cards(tracks: List[dict], qr_size_mm: float) -> List[TrackCard]:
|
|
cards: List[TrackCard] = []
|
|
for t in tracks:
|
|
title = (t.get("name") or "").strip()
|
|
artists = ", ".join([a["name"] for a in (t.get("artists") or [])])
|
|
year = safe_year_from_release_date(t.get("album", {}).get("release_date"))
|
|
url = (t.get("external_urls") or {}).get("spotify") or ""
|
|
if not url:
|
|
continue
|
|
qr_img = make_qr_pil(url, box_size=8, border=2)
|
|
cards.append(TrackCard(title=title, artists=artists, year=year, url=url, qr_img=qr_img))
|
|
return cards
|
|
|
|
def paginate_draw(c: canvas.Canvas, cards: List[TrackCard], draw_fn):
|
|
per_page = COLS * ROWS
|
|
total_pages = math.ceil(len(cards) / per_page) or 1
|
|
for page in range(total_pages):
|
|
start = page * per_page
|
|
end = min(start + per_page, len(cards))
|
|
for i in range(start, end):
|
|
_, col, row = grid_position(i - start)
|
|
x, y = card_origin(col, row)
|
|
draw_fn(c, x, y, cards[i])
|
|
if page < total_pages - 1 or end == len(cards):
|
|
c.showPage()
|
|
|
|
def generate_pdfs(cards: List[TrackCard], out_dir: str, show_year_on_front: bool, qr_size_mm: float):
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
fronts_path = os.path.join(out_dir, "fronts.pdf")
|
|
backs_path = os.path.join(out_dir, "backs.pdf")
|
|
|
|
cf = canvas.Canvas(fronts_path, pagesize=A4)
|
|
def draw_front(c, x, y, card):
|
|
draw_card_front(c, x, y, card, qr_size_mm=qr_size_mm, show_year_on_front=show_year_on_front)
|
|
paginate_draw(cf, cards, draw_front)
|
|
cf.save()
|
|
|
|
cb = canvas.Canvas(backs_path, pagesize=A4)
|
|
def draw_back(c, x, y, card):
|
|
draw_card_back(c, x, y, card)
|
|
paginate_draw(cb, cards, draw_back)
|
|
cb.save()
|
|
|
|
def generate_pdf_interleaved(cards: List[TrackCard], out_dir: str, show_year_on_front: bool, qr_size_mm: float):
|
|
"""
|
|
Produce one PDF: odd pages = fronts, even pages = backs, for each 3x3 page of cards.
|
|
"""
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
out_path = os.path.join(out_dir, "cards.pdf")
|
|
c = canvas.Canvas(out_path, pagesize=A4)
|
|
per_page = COLS * ROWS
|
|
|
|
def draw_front_page(page_cards: List[TrackCard]):
|
|
for i, card in enumerate(page_cards):
|
|
col = i % COLS
|
|
row = i // COLS
|
|
x, y = card_origin(col, row)
|
|
draw_card_front(c, x, y, card, qr_size_mm=qr_size_mm, show_year_on_front=show_year_on_front)
|
|
|
|
def draw_back_page(page_cards: List[TrackCard]):
|
|
for i, card in enumerate(page_cards):
|
|
col = i % COLS
|
|
row = i // COLS
|
|
# Straight same-order backs; add mirroring later if needed.
|
|
x, y = card_origin(col, row)
|
|
draw_card_back(c, x, y, card)
|
|
|
|
for start in range(0, len(cards), per_page):
|
|
page_cards = cards[start:start + per_page]
|
|
draw_front_page(page_cards)
|
|
c.showPage()
|
|
draw_back_page(page_cards)
|
|
c.showPage()
|
|
|
|
c.save()
|
|
print(f"Generated interleaved PDF: {os.path.abspath(out_path)}")
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Generate DIN A4 Hitster-style cards from a Spotify playlist.")
|
|
parser.add_argument("--playlist", required=True, help="Spotify playlist URL (public).")
|
|
parser.add_argument("--client-id", required=True, help="Spotify CLIENT_ID.")
|
|
parser.add_argument("--client-secret", required=True, help="Spotify CLIENT_SECRET.")
|
|
parser.add_argument("--out", default="output_cards", help="Output directory for PDFs.")
|
|
parser.add_argument("--max-cards", type=int, default=54, help="Maximum number of tracks/cards to include.")
|
|
parser.add_argument("--qr-size-mm", type=float, default=35.0, help="QR code size on card in millimetres.")
|
|
parser.add_argument("--year-on-front", action="store_true", help="Also print the year small on the front.")
|
|
parser.add_argument("--one-pdf", action="store_true", help="Generate a single interleaved PDF (front page then matching back page).")
|
|
args = parser.parse_args()
|
|
|
|
playlist_id = extract_playlist_id(args.playlist)
|
|
sp = spotify_client(args.client_id, args.client_secret)
|
|
raw_tracks = fetch_tracks(sp, playlist_id, limit=args.max_cards)
|
|
if not raw_tracks:
|
|
raise SystemExit("No tracks fetched. Ensure the playlist is public and contains tracks.")
|
|
cards = build_cards(raw_tracks, qr_size_mm=args.qr_size_mm)
|
|
if not cards:
|
|
raise SystemExit("No valid cards could be built from the playlist.")
|
|
|
|
if args.one_pdf:
|
|
generate_pdf_interleaved(cards, args.out, show_year_on_front=args.year_on_front, qr_size_mm=args.qr_size_mm)
|
|
else:
|
|
generate_pdfs(cards, args.out, show_year_on_front=args.year_on_front, qr_size_mm=args.qr_size_mm)
|
|
|
|
print("Done.")
|
|
|
|
if __name__ == "__main__":
|
|
main() |