Add comprehensive documentation and README

- Added detailed docstrings to all functions in the main script
- Created comprehensive README.md with setup, usage, and troubleshooting
- Includes module-level documentation explaining the tool's purpose
- Added complete API reference and printing instructions

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-08-10 21:16:04 +02:00
parent d5d34554f6
commit d839dbe293
2 changed files with 396 additions and 5 deletions
+179
View File
@@ -0,0 +1,179 @@
# Music Cards Generator
Generate 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.
## Features
- **3×3 Card Layout**: Creates 9 cards per A4 page (63×88mm each)
- **QR Code Integration**: Each card contains a scannable QR code linking to the Spotify track
- **Dual Output Modes**:
- Separate PDFs for fronts and backs
- Single interleaved PDF for duplex printing
- **Professional Design**: Clean layout with cutting guides and proper typography
- **Customisable**: Adjustable QR code size and optional year display on front
## Installation
### Prerequisites
- Python 3.9 or higher
- Spotify Developer Account (for API credentials)
### Install Dependencies
```bash
pip install spotipy reportlab qrcode[pil] pillow
```
### Spotify API Setup
1. Go to [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
2. Create a new application
3. Note your **Client ID** and **Client Secret**
4. No redirect URI needed for public playlists
## Usage
### Basic Usage (Separate PDFs)
```bash
python generate_hitster_cards.py \
--playlist "https://open.spotify.com/playlist/YOUR_PLAYLIST_ID" \
--client-id YOUR_CLIENT_ID \
--client-secret YOUR_CLIENT_SECRET \
--out ./cards_out \
--max-cards 54
```
### Single Interleaved PDF (for Duplex Printing)
```bash
python generate_hitster_cards.py \
--playlist "https://open.spotify.com/playlist/YOUR_PLAYLIST_ID" \
--client-id YOUR_CLIENT_ID \
--client-secret YOUR_CLIENT_SECRET \
--out ./cards_out \
--max-cards 54 \
--one-pdf
```
### Command Line Options
| Option | Description | Default |
|--------|-------------|---------|
| `--playlist` | Spotify playlist URL (required) | - |
| `--client-id` | Spotify Client ID (required) | - |
| `--client-secret` | Spotify Client Secret (required) | - |
| `--out` | Output directory for PDF files | `output_cards` |
| `--max-cards` | Maximum number of cards to generate | `54` |
| `--qr-size-mm` | QR code size in millimetres | `35.0` |
| `--year-on-front` | Show year on card front | `false` |
| `--one-pdf` | Generate single interleaved PDF | `false` |
## Output Files
### Separate Mode (default)
- `fronts.pdf`: All card fronts
- `backs.pdf`: All card backs (same order)
### Interleaved Mode (`--one-pdf`)
- `cards.pdf`: Alternating front and back pages
- Page 1: Fronts of cards 1-9
- Page 2: Backs of cards 1-9
- Page 3: Fronts of cards 10-18
- Page 4: Backs of cards 10-18
- And so on...
## Printing Instructions
### For Best Results
- **Print Setting**: "Actual size" (no scaling)
- **Quality**: 300 DPI or higher
- **Paper**: Standard A4 (210×297mm)
- **Contrast**: High contrast for better QR code scanning
### Duplex Printing
When using `--one-pdf` mode:
1. Print all pages duplex (double-sided)
2. Use "Flip on Long Edge" setting
3. Page 1 (fronts) and Page 2 (backs) will align perfectly
### Manual Duplex
For separate PDFs:
1. Print `fronts.pdf` first
2. Reload paper (same orientation)
3. Print `backs.pdf` on the reverse side
### Cutting
- Cards include light grey cutting guides
- Each card is 63×88mm
- Use a paper cutter or craft knife with ruler
## Card Layout
### Front Side
- **QR Code**: Links to Spotify track (scannable by any QR reader)
- **Title**: Track name (bold, up to 3 lines)
- **Artist(s)**: Artist names (up to 2 lines)
- **Year** (optional): Small text in bottom-right corner
### Back Side
- **Year**: Large centered text
- **Track Info**: Small "Title — Artist(s)" at bottom
## Limitations
- **Public Playlists Only**: Client credentials flow cannot access private playlists
- **Standard QR Codes**: These link to open.spotify.com, not proprietary formats
- **Year Data**: Based on album release date, may differ from original single release
## Troubleshooting
### Common Issues
**"No tracks fetched" Error**
- Ensure playlist is public
- Verify playlist URL is correct
- Check Spotify API credentials
**"Private playlist" (404 Error)**
- Make playlist public in Spotify, or
- Use authorisation code flow for private playlists (not currently supported)
**QR Codes Don't Scan**
- Increase `--qr-size-mm` value (try 40-45mm)
- Ensure high-contrast printing
- Check printer DPI settings
**Poor Print Quality**
- Use 300+ DPI printer setting
- Select "Actual size" (no scaling)
- Use high-quality paper
### Getting Help
For issues or questions:
1. Check that your playlist URL and API credentials are correct
2. Verify the playlist is public and contains tracks
3. Try reducing `--max-cards` for testing
## Security Notes
- **Never commit API credentials** to version control
- Consider using environment variables for credentials:
```bash
export SPOTIFY_CLIENT_ID="your_client_id"
export SPOTIFY_CLIENT_SECRET="your_client_secret"
```
- Client secrets are sensitive - rotate if accidentally exposed
## Licence
For personal, non-commercial use only. Not affiliated with or endorsed by Spotify.
## Requirements File
Create a `requirements.txt` file:
```
spotipy>=2.22.1
reportlab>=4.0.4
qrcode[pil]>=7.4.2
pillow>=10.0.0
```
Install with: `pip install -r requirements.txt`
+217 -5
View File
@@ -1,4 +1,21 @@
#!/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 generate_hitster_cards.py --playlist <URL> --client-id <ID> --client-secret <SECRET>
Requires Spotify Web API credentials for accessing playlist data.
"""
import argparse
import math
import os
@@ -21,11 +38,20 @@ from spotipy.oauth2 import SpotifyClientCredentials
@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 # PIL image
qr_img: Image.Image
# ---------- Spotify helpers ----------
@@ -36,6 +62,19 @@ PLAYLIST_REGEXES = [
]
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:
@@ -43,10 +82,34 @@ def extract_playlist_id(url: str) -> str:
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
@@ -69,6 +132,19 @@ def fetch_tracks(sp: spotipy.Spotify, playlist_id: str, limit: Optional[int] = N
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:
@@ -81,6 +157,19 @@ def safe_year_from_release_date(release_date: Optional[str]) -> Optional[int]:
# ---------- 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,
@@ -98,6 +187,20 @@ def make_qr_pil(url: str, box_size: int = 8, border: int = 2) -> Image.Image:
# ---------- 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 = []
@@ -126,8 +229,16 @@ 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.
"""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
@@ -137,11 +248,31 @@ def grid_position(index: int) -> Tuple[int, int, int]:
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)
@@ -149,6 +280,22 @@ def draw_cut_border(c: canvas.Canvas, x: float, y: float, w: float, h: float):
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
@@ -181,6 +328,18 @@ def draw_card_front(c: canvas.Canvas, x: float, y: float, card: TrackCard, qr_si
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)
@@ -199,6 +358,18 @@ def draw_card_back(c: canvas.Canvas, x: float, y: float, card: TrackCard):
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()
@@ -212,6 +383,15 @@ def build_cards(tracks: List[dict], qr_size_mm: float) -> List[TrackCard]:
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):
@@ -225,6 +405,18 @@ def paginate_draw(c: canvas.Canvas, cards: List[TrackCard], draw_fn):
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")
@@ -242,8 +434,20 @@ def generate_pdfs(cards: List[TrackCard], out_dir: str, show_year_on_front: bool
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.
"""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")
@@ -276,6 +480,14 @@ def generate_pdf_interleaved(cards: List[TrackCard], out_dir: str, show_year_on_
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.")