Files
music-cards/music_cards_generator.py
schihei ff42d5fddc Rename script to music_cards_generator.py and update references
- Renamed generate_hitster_cards.py to music_cards_generator.py
- Updated all documentation references to use the new filename
- Removed remaining Hitster references from the filename
- Script name now matches the Music Cards branding

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

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

519 lines
18 KiB
Python

#!/usr/bin/env python3
"""
Music Cards Generator
Generates printable DIN A4 sheets with music cards from Spotify playlists.
Each card features a QR code linking to the track, along with title, artist(s),
and release year information.
The tool creates 3x3 grids of cards (63x88mm each) on A4 pages, suitable for
home printing and cutting. Cards can be generated as separate front/back PDFs
or as a single interleaved PDF for duplex printing.
Usage:
python music_cards_generator.py --playlist <URL> --client-id <ID> --client-secret <SECRET>
Requires Spotify Web API credentials for accessing playlist data.
"""
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:
"""Represents a single music card with all necessary data.
Attributes:
title: The track title
artists: Comma-separated string of artist names
year: Release year of the track (None if unavailable)
url: Spotify URL for the track
qr_img: PIL Image object containing the QR code
"""
title: str
artists: str
year: Optional[int]
url: str
qr_img: Image.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:
"""Extract Spotify playlist ID from various URL formats.
Supports both web URLs (open.spotify.com) and Spotify URI format.
Args:
url: Spotify playlist URL or URI
Returns:
The playlist ID string
Raises:
ValueError: If the URL format is not recognized
"""
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:
"""Create authenticated Spotify client using client credentials flow.
This authentication method only works with public playlists.
Args:
client_id: Spotify application client ID
client_secret: Spotify application client secret
Returns:
Authenticated Spotify client instance
"""
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]:
"""Fetch track data from a Spotify playlist.
Retrieves all tracks from the playlist, handling pagination automatically.
Only returns tracks (not podcasts or other media types).
Args:
sp: Authenticated Spotify client
playlist_id: Spotify playlist ID
limit: Maximum number of tracks to fetch (None for all)
Returns:
List of track dictionaries from Spotify API
"""
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]:
"""Safely extract year from Spotify release date string.
Handles various date formats returned by Spotify API:
- 'yyyy' (year only)
- 'yyyy-mm' (year and month)
- 'yyyy-mm-dd' (full date)
Args:
release_date: Release date string from Spotify API
Returns:
Year as integer, or None if parsing fails
"""
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:
"""Generate QR code as PIL Image.
Creates a QR code linking to the provided URL with medium error correction.
The border parameter controls the quiet zone around the QR code.
Args:
url: URL to encode in the QR code
box_size: Size of each QR code box in pixels
border: Width of the border (quiet zone) in boxes
Returns:
PIL Image containing the QR code
"""
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]:
"""Wrap text to fit within specified width using greedy line-breaking.
Uses ReportLab's stringWidth to measure text accurately for the given font.
Args:
text: Text to wrap
max_width: Maximum line width in points
font_name: ReportLab font name (e.g., 'Helvetica', 'Helvetica-Bold')
font_size: Font size in points
canv: ReportLab Canvas for measuring text width
Returns:
List of text lines that fit within the specified width
"""
# 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]:
"""Calculate page and grid position for a card index.
Converts a linear card index into page number and grid coordinates
for the 3x3 card layout.
Args:
index: Zero-based card index
Returns:
Tuple of (page_index, col, row) where col and row are 0-based
"""
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]:
"""Calculate the bottom-left origin point for a card at grid position.
Uses ReportLab's coordinate system where (0,0) is bottom-left of page.
Args:
col: Column index (0-2)
row: Row index (0-2)
Returns:
Tuple of (x, y) coordinates in points
"""
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):
"""Draw a light grey border around a card for cutting guides.
Args:
c: ReportLab Canvas
x: X coordinate of bottom-left corner
y: Y coordinate of bottom-left corner
w: Width of the rectangle
h: Height of the rectangle
"""
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 the front side of a music card.
The front contains:
- QR code linking to the Spotify track
- Track title (bold, up to 3 lines)
- Artist names (up to 2 lines)
- Optional year in bottom-right corner
Args:
c: ReportLab Canvas
x: X coordinate of card bottom-left corner
y: Y coordinate of card bottom-left corner
card: TrackCard with all the card data
qr_size_mm: Size of QR code in millimetres
show_year_on_front: Whether to show year on front
"""
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 the back side of a music card.
The back contains:
- Large year text centered in the middle
- Small title and artist info at the bottom
Args:
c: ReportLab Canvas
x: X coordinate of card bottom-left corner
y: Y coordinate of card bottom-left corner
card: TrackCard with all the card data
"""
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]:
"""Convert Spotify track data into TrackCard objects.
Processes each track to extract title, artists, year, and URL,
then generates a QR code for each valid track.
Args:
tracks: List of track dictionaries from Spotify API
qr_size_mm: Size for QR codes (used for parameter consistency)
Returns:
List of TrackCard objects ready for PDF generation
"""
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):
"""Draw cards across multiple pages using the provided drawing function.
Handles pagination automatically, creating new pages as needed.
Args:
c: ReportLab Canvas
cards: List of TrackCard objects to draw
draw_fn: Function to draw individual cards (signature: fn(canvas, x, y, card))
"""
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):
"""Generate separate PDF files for card fronts and backs.
Creates two PDF files:
- fronts.pdf: All card fronts
- backs.pdf: All card backs (in same order)
Args:
cards: List of TrackCard objects
out_dir: Output directory path
show_year_on_front: Whether to show year on front of cards
qr_size_mm: QR code size in millimetres
"""
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):
"""Generate a single interleaved PDF for duplex printing.
Creates cards.pdf where:
- Odd pages contain card fronts (pages 1, 3, 5, ...)
- Even pages contain corresponding card backs (pages 2, 4, 6, ...)
This format is suitable for duplex printing where each sheet contains
9 complete cards (front and back).
Args:
cards: List of TrackCard objects
out_dir: Output directory path
show_year_on_front: Whether to show year on front of cards
qr_size_mm: QR code size in millimetres
"""
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():
"""Main entry point for the music cards generator.
Parses command-line arguments, fetches playlist data from Spotify,
and generates PDF files with music cards.
Raises:
SystemExit: If playlist is empty, inaccessible, or no valid cards can be created
"""
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()