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:
@@ -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
@@ -1,4 +1,21 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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 argparse
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
@@ -21,11 +38,20 @@ from spotipy.oauth2 import SpotifyClientCredentials
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TrackCard:
|
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
|
title: str
|
||||||
artists: str
|
artists: str
|
||||||
year: Optional[int]
|
year: Optional[int]
|
||||||
url: str
|
url: str
|
||||||
qr_img: Image.Image # PIL image
|
qr_img: Image.Image
|
||||||
|
|
||||||
|
|
||||||
# ---------- Spotify helpers ----------
|
# ---------- Spotify helpers ----------
|
||||||
@@ -36,6 +62,19 @@ PLAYLIST_REGEXES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
def extract_playlist_id(url: str) -> str:
|
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:
|
for rx in PLAYLIST_REGEXES:
|
||||||
m = rx.search(url)
|
m = rx.search(url)
|
||||||
if m:
|
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>")
|
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:
|
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)
|
auth = SpotifyClientCredentials(client_id=client_id, client_secret=client_secret)
|
||||||
return spotipy.Spotify(auth_manager=auth)
|
return spotipy.Spotify(auth_manager=auth)
|
||||||
|
|
||||||
def fetch_tracks(sp: spotipy.Spotify, playlist_id: str, limit: Optional[int] = None) -> List[dict]:
|
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 = []
|
items = []
|
||||||
offset = 0
|
offset = 0
|
||||||
page_size = 100
|
page_size = 100
|
||||||
@@ -69,6 +132,19 @@ def fetch_tracks(sp: spotipy.Spotify, playlist_id: str, limit: Optional[int] = N
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
def safe_year_from_release_date(release_date: Optional[str]) -> Optional[int]:
|
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:
|
if not release_date:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -81,6 +157,19 @@ def safe_year_from_release_date(release_date: Optional[str]) -> Optional[int]:
|
|||||||
# ---------- QR code helper ----------
|
# ---------- QR code helper ----------
|
||||||
|
|
||||||
def make_qr_pil(url: str, box_size: int = 8, border: int = 2) -> Image.Image:
|
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(
|
qr = qrcode.QRCode(
|
||||||
version=None, # automatic
|
version=None, # automatic
|
||||||
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
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 ----------
|
# ---------- Typography ----------
|
||||||
|
|
||||||
def wrap_text(text: str, max_width: float, font_name: str, font_size: float, canv: canvas.Canvas) -> List[str]:
|
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
|
# Simple greedy line-wrapping
|
||||||
words = text.split()
|
words = text.split()
|
||||||
lines = []
|
lines = []
|
||||||
@@ -126,8 +229,16 @@ MARGIN_LR = (210 * mm - COLS * CARD_W) / 2.0
|
|||||||
MARGIN_TB = (297 * mm - ROWS * CARD_H) / 2.0
|
MARGIN_TB = (297 * mm - ROWS * CARD_H) / 2.0
|
||||||
|
|
||||||
def grid_position(index: int) -> Tuple[int, int, int]:
|
def grid_position(index: int) -> Tuple[int, int, int]:
|
||||||
"""
|
"""Calculate page and grid position for a card index.
|
||||||
Returns (page_index, col, row) for a zero-based 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
|
per_page = COLS * ROWS
|
||||||
page = index // per_page
|
page = index // per_page
|
||||||
@@ -137,11 +248,31 @@ def grid_position(index: int) -> Tuple[int, int, int]:
|
|||||||
return page, col, row
|
return page, col, row
|
||||||
|
|
||||||
def card_origin(col: int, row: int) -> Tuple[float, float]:
|
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
|
x = MARGIN_LR + col * CARD_W
|
||||||
y = PAGE_H - MARGIN_TB - (row + 1) * CARD_H
|
y = PAGE_H - MARGIN_TB - (row + 1) * CARD_H
|
||||||
return x, y
|
return x, y
|
||||||
|
|
||||||
def draw_cut_border(c: canvas.Canvas, x: float, y: float, w: float, h: float):
|
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.setLineWidth(0.3)
|
||||||
c.setStrokeColor(colors.lightgrey)
|
c.setStrokeColor(colors.lightgrey)
|
||||||
c.rect(x, y, w, h, stroke=1, fill=0)
|
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)
|
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):
|
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)
|
draw_cut_border(c, x, y, CARD_W, CARD_H)
|
||||||
pad = 4 * mm
|
pad = 4 * mm
|
||||||
qr_size = qr_size_mm * 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))
|
c.drawRightString(x + CARD_W - pad, y + pad, str(card.year))
|
||||||
|
|
||||||
def draw_card_back(c: canvas.Canvas, x: float, y: float, card: TrackCard):
|
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)
|
draw_cut_border(c, x, y, CARD_W, CARD_H)
|
||||||
pad = 6 * mm
|
pad = 6 * mm
|
||||||
c.setFillColor(colors.black)
|
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)
|
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]:
|
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] = []
|
cards: List[TrackCard] = []
|
||||||
for t in tracks:
|
for t in tracks:
|
||||||
title = (t.get("name") or "").strip()
|
title = (t.get("name") or "").strip()
|
||||||
@@ -212,6 +383,15 @@ def build_cards(tracks: List[dict], qr_size_mm: float) -> List[TrackCard]:
|
|||||||
return cards
|
return cards
|
||||||
|
|
||||||
def paginate_draw(c: canvas.Canvas, cards: List[TrackCard], draw_fn):
|
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
|
per_page = COLS * ROWS
|
||||||
total_pages = math.ceil(len(cards) / per_page) or 1
|
total_pages = math.ceil(len(cards) / per_page) or 1
|
||||||
for page in range(total_pages):
|
for page in range(total_pages):
|
||||||
@@ -225,6 +405,18 @@ def paginate_draw(c: canvas.Canvas, cards: List[TrackCard], draw_fn):
|
|||||||
c.showPage()
|
c.showPage()
|
||||||
|
|
||||||
def generate_pdfs(cards: List[TrackCard], out_dir: str, show_year_on_front: bool, qr_size_mm: float):
|
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)
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
fronts_path = os.path.join(out_dir, "fronts.pdf")
|
fronts_path = os.path.join(out_dir, "fronts.pdf")
|
||||||
backs_path = os.path.join(out_dir, "backs.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()
|
cb.save()
|
||||||
|
|
||||||
def generate_pdf_interleaved(cards: List[TrackCard], out_dir: str, show_year_on_front: bool, qr_size_mm: float):
|
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.
|
||||||
Produce one PDF: odd pages = fronts, even pages = backs, for each 3x3 page of cards.
|
|
||||||
|
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)
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
out_path = os.path.join(out_dir, "cards.pdf")
|
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)}")
|
print(f"Generated interleaved PDF: {os.path.abspath(out_path)}")
|
||||||
|
|
||||||
def main():
|
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 = 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("--playlist", required=True, help="Spotify playlist URL (public).")
|
||||||
parser.add_argument("--client-id", required=True, help="Spotify CLIENT_ID.")
|
parser.add_argument("--client-id", required=True, help="Spotify CLIENT_ID.")
|
||||||
|
|||||||
Reference in New Issue
Block a user