diff --git a/README.md b/README.md new file mode 100644 index 0000000..96f2281 --- /dev/null +++ b/README.md @@ -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` \ No newline at end of file diff --git a/generate_hitster_cards.py b/generate_hitster_cards.py index ea633ad..38b447c 100644 --- a/generate_hitster_cards.py +++ b/generate_hitster_cards.py @@ -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 --client-id --client-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/ 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 @@ -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.")