Initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
{"access_token": "BQCttpd2UKZwMuq12Gb0jvhTigI5yLaFq76QMFp2jEOKJBfeakyhmk5WyKJPI3itgu2BmxTKo94FfmZEsLcNbxMm62KMjB4VSzzSOycQjA3zeTwDUzNlLegt4CZqEuPdzdwIYjvQjp4", "token_type": "Bearer", "expires_in": 3600, "expires_at": 1754836560}
|
||||||
@@ -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 Spotify’s 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 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.
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user