Files
music-cards/generate_hitster_cards.py
T
schihei d5d34554f6 Replace Hitster references with Music Cards branding
Updated all references from "Hitster-style" to generic "Music Cards"
throughout the codebase and documentation. This removes brand-specific
terminology and provides a more neutral description of the tool's purpose.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-10 21:11:50 +02:00

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 music 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()