Initial commit

This commit is contained in:
2025-08-10 20:44:24 +02:00
commit bea985066a
4 changed files with 420 additions and 0 deletions
Vendored
BIN
View File
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"access_token": "BQCttpd2UKZwMuq12Gb0jvhTigI5yLaFq76QMFp2jEOKJBfeakyhmk5WyKJPI3itgu2BmxTKo94FfmZEsLcNbxMm62KMjB4VSzzSOycQjA3zeTwDUzNlLegt4CZqEuPdzdwIYjvQjp4", "token_type": "Bearer", "expires_in": 3600, "expires_at": 1754836560}
+113
View File
@@ -0,0 +1,113 @@
# Guide for this repository
## Project overview
- Goal: Generate printable DIN A4 sheets with Hitster-style music cards from a Spotify playlist. Front shows a QR code plus the title and artist(s); back shows the release year and small text.
- Current core: generate_hitster_cards.py uses Spotifys Web API, generates QR codes and PDF pages with 3 × 3 cards per A4 (each 63 × 88 millimetres).
- Outputs:
- Separate files mode: fronts.pdf and backs.pdf.
- Interleaved mode: cards.pdf with alternating pages (odd fronts, even backs) via --one-pdf.
- Scope: Personal, non-commercial use; not affiliated with Hitster or Spotify.
## Key requirements and constraints
- Paper and layout:
- DIN A4 (210 × 297 millimetres), 3 × 3 grid, card size 63 × 88 millimetres, light grey cut borders.
- QR code prominently on the front; title and artist(s) centred below; optional year on front controlled by --year-on-front.
- Back shows the year large and the small label Title — Artist(s) near the bottom.
- Spotify data:
- Client credentials flow for public playlists; album.release_date is used to derive the year.
- For private or collaborative playlists, switch to authorisation code flow with scopes playlist-read-private and playlist-read-collaborative.
- British English:
- Use -ise spellings, metre, colour, etc., and metric units. Keep mm for dimensions.
- Security and privacy:
- Never log or hard-code Client Secret. Do not commit credentials. Suggest environment variables where possible.
- Treat user-provided playlist URLs as sensitive until published by the user.
## Repository structure (expected)
- generate_hitster_cards.py — main script with CLI.
- CLAUDE.md — this file.
- Optionally:
- README.md — quick start and printing notes.
- requirements.txt — minimal deps: spotipy, reportlab, qrcode[pil], pillow.
- .gitignore — exclude virtual environments, IDE files, and output folders (e.g. cards_out/).
## How to run locally
- Dependencies:
- pip install spotipy reportlab qrcode[pil]
- Run (separate files):
- python generate_hitster_cards.py --playlist "<playlist_url>" --client-id <CLIENT_ID> --client-secret <CLIENT_SECRET> --out ./cards_out --max-cards 54
- Run (single interleaved file):
- python generate_hitster_cards.py --playlist "<playlist_url>" --client-id <CLIENT_ID> --client-secret <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 reportlabs 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 Hitsters proprietary format.
- Album.release_date year may differ from an original singles 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 phones 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.
+306
View File
@@ -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/<id> or spotify:playlist:<id>")
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()