#!/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 --client-id --client-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/ or spotify:playlist:") 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()