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>
This commit is contained in:
@@ -0,0 +1,519 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user