From bea985066a0e5fd04da3327819d7c575dec47897 Mon Sep 17 00:00:00 2001 From: Heiko Joerg Schick Date: Sun, 10 Aug 2025 20:44:24 +0200 Subject: [PATCH] Initial commit --- .DS_Store | Bin 0 -> 6148 bytes .cache | 1 + CLAUDE.md | 113 ++++++++++++++ generate_hitster_cards.py | 306 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 420 insertions(+) create mode 100644 .DS_Store create mode 100644 .cache create mode 100644 CLAUDE.md create mode 100644 generate_hitster_cards.py diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5211af7406d27261946b61b5007cb2fe91cb89d1 GIT binary patch literal 6148 zcmeHKJ5EDE3>=dbK{P2T_X^x#6@?Sz0Dy=@gAyqa>R07l9F6fu5z$MUNHl0H*|Y2Q z?9-d#`5AyM_Ui{=4q!%i#KFVZ{M>zHH`zXq~=4+n!!XYu}%m" --client-id --client-secret --out ./cards_out --max-cards 54 +- Run (single interleaved file): + - python generate_hitster_cards.py --playlist "" --client-id --client-secret --out ./cards_out --max-cards 54 --one-pdf +- Printing: + - ‘Actual size’ (no scaling), high-contrast, 300 dpi or better. + - Duplex: odd pages are fronts, even pages are the backs of the same set of 9 cards. + +## Coding standards +- Language: Python 3.9+. +- Style: PEP 8, type hints for new/changed functions, small pure functions where practical. +- Names: descriptive, avoid abbreviations unless standard (e.g. url, id). +- Errors: raise SystemExit with clear messages for user-facing errors. Catch and describe common Spotify API errors (404 for private playlists, 429 for rate limiting). +- Logs/prints: keep output concise and user-focused; avoid printing secrets or full tokens. +- Units: represent page and card geometry in millimetres using reportlab’s mm unit. + +## Quality checks +- Run static checks (if configured): flake8, black, and mypy are welcome but optional. Keep the script runnable without extra tooling. +- Manual test: + - Use a known public playlist; verify fronts.pdf/backs.pdf or cards.pdf render 3 × 3 cards aligned. + - Test QR codes with the phone Camera app; they should open in Spotify via open.spotify.com. + +## Security guidance +- Do not expose CLIENT_SECRET (rotate if leaked). +- Consider reading credentials from environment variables (SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET) if adding features. +- Avoid copying full API payloads to logs; redact where necessary. + +## Known limitations and non-goals +- The Hitster iPhone app will not scan these QR codes; they are standard QR codes to open.spotify.com, not Hitster’s proprietary format. +- Album.release_date year may differ from an original single’s first release in some catalogues. +- Client credentials flow cannot read private playlists; that requires user login (authorisation code flow). + +## Common tasks for Claude +- Add duplex alignment options: + - --mirror-backs to mirror the horizontal order for long-edge flips. + - --rotate-backs to rotate back pages by 180 degrees if printers invert one side. +- Improve robustness: + - Detect and explain 404 (private playlist) and 429 (rate limited) with actionable suggestions. + - Fallback when album.release_date is partial; parse ‘yyyy-mm’ and ‘yyyy-mm-dd’ safely. +- Enhancements: + - --qr-size-mm default tuning; expose --qr-border (quiet zone) and document recommended values. + - Optional Spotify Codes rendering (secondary image) for scanning inside the Spotify app. + - Read CLIENT_ID/SECRET from environment variables if flags are not provided. + - CSV import/export of track metadata before PDF generation. +- Developer ergonomics: + - Factor drawing code for fronts/backs into reusable helpers with explicit geometry. + - Add minimal unit tests for helpers like wrap_text and safe_year_from_release_date. + +## Acceptance criteria for changes +- No secrets are logged or stored. +- The default 3 × 3 layout remains 63 × 88 millimetres per card on DIN A4. +- Existing flags continue to work; new flags have clear help text and sensible defaults. +- User-facing errors are clear, with next-steps guidance. +- Printing remains predictable; interleaved mode alternates fronts then backs. + +## Implementation notes +- QR code generation: + - Library: qrcode with ERROR_CORRECT_M; default border (quiet zone) 2; allow configuration via a CLI flag if modified. +- Spotify API: + - With client credentials, support only public playlists. For private playlists, use authorisation code flow with scopes playlist-read-private and playlist-read-collaborative and a Redirect URI (e.g. http://localhost:8080/callback). +- PDF layout: + - Use reportlab A4, mm units, consistent margins, and a light grey cut border. Keep text centred and wrapped. + +## Example prompts for Claude +- ‘Add a --mirror-backs flag that mirrors the column order on back pages for long-edge duplex printing. Update CLI help, implement, and explain how to use it.’ +- ‘Detect 404 from playlist_items and print a friendly message explaining that the playlist is likely private, with steps to make it public or switch to user authorisation.’ +- ‘Introduce --qr-border to control the QR quiet zone (default 2). Update make_qr_pil accordingly and document its impact on scan reliability.’ +- ‘Allow reading SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET from the environment when flags are omitted. Keep explicit flags taking precedence.’ +- ‘Add a minimal README.md with quick start, printing tips, and security notes (no secrets in logs).’ + +## Testing guidance +- Use a short public playlist for quick iteration. +- Generate one page (max-cards 9) and print on plain paper to validate duplex alignment. +- Scan QR codes with a phone’s Camera app to confirm correctness of links. + +## Licence and attribution +- Use for personal, non-commercial purposes only. +- Not affiliated with or endorsed by Hitster or Spotify. diff --git a/generate_hitster_cards.py b/generate_hitster_cards.py new file mode 100644 index 0000000..fa3a2f8 --- /dev/null +++ b/generate_hitster_cards.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +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: + title: str + artists: str + year: Optional[int] + url: str + qr_img: Image.Image # PIL 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: + 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: + 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]: + 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]: + 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: + 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]: + # 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]: + """ + Returns (page_index, col, row) for a zero-based card index. + """ + 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]: + 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): + 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_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_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) + base_y = y + pad + (len(lines) - 1) * 3.8 + for i, line in enumerate(lines[:3]): + c.drawCentredString(x + CARD_W / 2.0, base_y - i * 3.8, line) + +def build_cards(tracks: List[dict], qr_size_mm: float) -> List[TrackCard]: + 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): + 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): + 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): + """ + Produce one PDF: odd pages = fronts, even pages = backs, for each 3x3 page of cards. + """ + 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(): + parser = argparse.ArgumentParser(description="Generate DIN A4 Hitster-style 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() \ No newline at end of file