First commit. Again. Yo ho. Again
This commit is contained in:
840
build.py
Normal file
840
build.py
Normal file
@@ -0,0 +1,840 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Playlists build script — Singular Particular Space
|
||||
spaces.exopraxist.org
|
||||
|
||||
Data sources:
|
||||
MusicBrainz (no key) — recording link + artist URL (official site, Bandcamp, etc.)
|
||||
Odesli / song.link — YouTube video ID for embeds (10 req/min without key)
|
||||
|
||||
First run: ~3 hours (cached). Subsequent runs: seconds.
|
||||
Script resumes from cache if interrupted — safe to run overnight and re-run.
|
||||
|
||||
Usage:
|
||||
python3 build.py # full build
|
||||
python3 build.py --hub # regenerate hub only (instant)
|
||||
python3 build.py --playlist <slug> # single playlist test
|
||||
python3 build.py --force-odesli # re-fetch Odesli data only
|
||||
python3 build.py --force-mb # re-fetch MusicBrainz data only
|
||||
|
||||
Optional env vars (no keys required — just speeds things up):
|
||||
ODESLI_API_KEY — higher rate limit from Odesli (email developers@song.link)
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
# ─── Config ───────────────────────────────────────────────────────────────────
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
CACHE_FILE = SCRIPT_DIR / "cache.json"
|
||||
|
||||
ODESLI_KEY = os.environ.get("ODESLI_API_KEY", "")
|
||||
FORCE_ODESLI = "--force-odesli" in sys.argv
|
||||
FORCE_MB = "--force-mb" in sys.argv
|
||||
HUB_ONLY = "--hub" in sys.argv
|
||||
|
||||
_playlist_arg = None
|
||||
if "--playlist" in sys.argv:
|
||||
i = sys.argv.index("--playlist")
|
||||
if i + 1 < len(sys.argv):
|
||||
_playlist_arg = sys.argv[i + 1]
|
||||
|
||||
# ─── Rate limiters ────────────────────────────────────────────────────────────
|
||||
|
||||
_last_mb_call = 0.0
|
||||
_last_odesli_call = 0.0
|
||||
|
||||
MB_INTERVAL = 1.1 # 1 req/sec free tier
|
||||
ODESLI_INTERVAL = 8.0 # 10 req/min without key — 8s gives safe margin
|
||||
|
||||
# Sentinel: call failed with rate limit or error — do not cache
|
||||
FETCH_FAILED = object()
|
||||
|
||||
def _wait(last: float, interval: float) -> float:
|
||||
elapsed = time.time() - last
|
||||
if elapsed < interval:
|
||||
time.sleep(interval - elapsed)
|
||||
return time.time()
|
||||
|
||||
# ─── HTTP helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
MB_HEADERS = {"User-Agent": "SingularParticularSpace/1.0 (spaces.exopraxist.org)"}
|
||||
|
||||
def http_get(url: str, headers: dict = None):
|
||||
"""
|
||||
Returns parsed JSON dict on success or 404.
|
||||
Returns FETCH_FAILED sentinel on 429 / 5xx / network error (do not cache).
|
||||
"""
|
||||
try:
|
||||
req = urllib.request.Request(url, headers=headers or {})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
return {} # Not found — cache as empty, won't change
|
||||
if e.code == 429:
|
||||
print(f" 429 rate limit — backing off 30s", file=sys.stderr)
|
||||
time.sleep(30)
|
||||
return FETCH_FAILED
|
||||
print(f" HTTP {e.code}: {url}", file=sys.stderr)
|
||||
return FETCH_FAILED
|
||||
except Exception as e:
|
||||
print(f" error: {e}", file=sys.stderr)
|
||||
return FETCH_FAILED
|
||||
|
||||
def mb_get(url: str):
|
||||
global _last_mb_call
|
||||
_last_mb_call = _wait(_last_mb_call, MB_INTERVAL)
|
||||
return http_get(url, MB_HEADERS)
|
||||
|
||||
def odesli_get(url: str):
|
||||
global _last_odesli_call
|
||||
_last_odesli_call = _wait(_last_odesli_call, ODESLI_INTERVAL)
|
||||
return http_get(url)
|
||||
|
||||
# ─── Cache ────────────────────────────────────────────────────────────────────
|
||||
# Flat dict with namespaced keys:
|
||||
# "mb:isrc:{ISRC}" → { mb_recording_url, mb_artist_id }
|
||||
# "mb:artist:{MB_ID}" → { artist_url, artist_url_type }
|
||||
# "odesli:{SPOTIFY_ID}" → { youtube_video_id, odesli_page_url }
|
||||
|
||||
def load_cache() -> dict:
|
||||
if CACHE_FILE.exists():
|
||||
try:
|
||||
return json.loads(CACHE_FILE.read_text("utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
def save_cache(cache: dict):
|
||||
CACHE_FILE.write_text(json.dumps(cache, indent=2, ensure_ascii=False), "utf-8")
|
||||
|
||||
# ─── MusicBrainz ──────────────────────────────────────────────────────────────
|
||||
|
||||
def mb_isrc_lookup(isrc: str):
|
||||
"""ISRC → { mb_recording_url, mb_artist_id } or FETCH_FAILED"""
|
||||
url = f"https://musicbrainz.org/ws/2/isrc/{isrc}?inc=artist-credits&fmt=json"
|
||||
data = mb_get(url)
|
||||
if data is FETCH_FAILED:
|
||||
return FETCH_FAILED
|
||||
result = {"mb_recording_url": "", "mb_artist_id": ""}
|
||||
recs = data.get("recordings", [])
|
||||
if not recs:
|
||||
return result
|
||||
rec = recs[0]
|
||||
rec_id = rec.get("id", "")
|
||||
if rec_id:
|
||||
result["mb_recording_url"] = f"https://musicbrainz.org/recording/{rec_id}"
|
||||
credits = rec.get("artist-credit", [])
|
||||
for credit in credits:
|
||||
if isinstance(credit, dict) and "artist" in credit:
|
||||
result["mb_artist_id"] = credit["artist"].get("id", "")
|
||||
break
|
||||
return result
|
||||
|
||||
# Artist URL type priority — ordered best to worst
|
||||
ARTIST_URL_PRIORITY = [
|
||||
"official homepage",
|
||||
"bandcamp",
|
||||
"soundcloud",
|
||||
"patreon",
|
||||
"linktree",
|
||||
"youtube",
|
||||
"myspace",
|
||||
"instagram",
|
||||
"twitter",
|
||||
"facebook",
|
||||
"last.fm",
|
||||
"discogs",
|
||||
"wikidata",
|
||||
"wikipedia",
|
||||
]
|
||||
|
||||
def mb_artist_url_lookup(mb_artist_id: str):
|
||||
"""MB artist ID → { artist_url, artist_url_type } or FETCH_FAILED"""
|
||||
url = f"https://musicbrainz.org/ws/2/artist/{mb_artist_id}?inc=url-rels&fmt=json"
|
||||
data = mb_get(url)
|
||||
if data is FETCH_FAILED:
|
||||
return FETCH_FAILED
|
||||
result = {"artist_url": "", "artist_url_type": ""}
|
||||
best_rank = len(ARTIST_URL_PRIORITY) + 1
|
||||
for rel in data.get("relations", []):
|
||||
rel_type = rel.get("type", "").lower()
|
||||
href = rel.get("url", {}).get("resource", "")
|
||||
if not href:
|
||||
continue
|
||||
for i, ptype in enumerate(ARTIST_URL_PRIORITY):
|
||||
if ptype in rel_type or ptype in href:
|
||||
if i < best_rank:
|
||||
best_rank = i
|
||||
result["artist_url"] = href
|
||||
result["artist_url_type"] = rel_type
|
||||
break
|
||||
return result
|
||||
|
||||
# ─── Odesli ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def odesli_lookup(spotify_track_id: str):
|
||||
"""Spotify track ID → { youtube_video_id, odesli_page_url } or FETCH_FAILED"""
|
||||
spotify_uri = f"spotify:track:{spotify_track_id}"
|
||||
params = f"url={urllib.parse.quote(spotify_uri)}&platform=spotify&type=song"
|
||||
if ODESLI_KEY:
|
||||
params += f"&key={ODESLI_KEY}"
|
||||
url = f"https://api.song.link/v1-alpha.1/links?{params}"
|
||||
data = odesli_get(url)
|
||||
if data is FETCH_FAILED:
|
||||
return FETCH_FAILED
|
||||
result = {"youtube_video_id": "", "odesli_page_url": ""}
|
||||
if not data:
|
||||
return result
|
||||
result["odesli_page_url"] = data.get("pageUrl", "")
|
||||
yt_url = data.get("linksByPlatform", {}).get("youtube", {}).get("url", "")
|
||||
if yt_url:
|
||||
result["youtube_video_id"] = extract_youtube_id(yt_url)
|
||||
return result
|
||||
|
||||
def extract_youtube_id(url: str) -> str:
|
||||
m = re.search(r"youtu\.be/([A-Za-z0-9_\-]{11})", url)
|
||||
if m:
|
||||
return m.group(1)
|
||||
m = re.search(r"[?&]v=([A-Za-z0-9_\-]{11})", url)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return ""
|
||||
|
||||
# ─── CSV / slug helpers ───────────────────────────────────────────────────────
|
||||
|
||||
def parse_csv(path: Path) -> list:
|
||||
with open(path, newline="", encoding="utf-8") as f:
|
||||
return list(csv.DictReader(f))
|
||||
|
||||
def make_slug(csv_filename: str) -> str:
|
||||
name = Path(csv_filename).stem
|
||||
name = name.replace("_", "-").lower()
|
||||
name = re.sub(r"[^a-z0-9\-]", "", name)
|
||||
name = re.sub(r"-{2,}", "-", name)
|
||||
return name.strip("-")
|
||||
|
||||
def make_display_name(csv_filename: str) -> str:
|
||||
name = Path(csv_filename).stem.strip("_").replace("_", " ")
|
||||
return name.title()
|
||||
|
||||
def spotify_track_id(uri: str) -> str:
|
||||
parts = uri.split(":")
|
||||
return parts[2] if len(parts) == 3 and parts[1] == "track" else ""
|
||||
|
||||
def ms_to_mmss(ms) -> str:
|
||||
try:
|
||||
s = int(ms) // 1000
|
||||
return f"{s // 60}:{s % 60:02d}"
|
||||
except Exception:
|
||||
return "—"
|
||||
|
||||
def ms_to_hhmmss(ms: int) -> str:
|
||||
s = ms // 1000
|
||||
h, m, s = s // 3600, (s % 3600) // 60, s % 60
|
||||
return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
|
||||
|
||||
def get_year(date: str) -> str:
|
||||
return date[:4] if date else ""
|
||||
|
||||
def esc(s: str) -> str:
|
||||
return (str(s)
|
||||
.replace("&", "&").replace("<", "<")
|
||||
.replace(">", ">").replace('"', """))
|
||||
|
||||
# ─── Fetch pipeline ───────────────────────────────────────────────────────────
|
||||
|
||||
def fetch_all(playlists: list, cache: dict):
|
||||
"""
|
||||
playlists: list of (slug, display_name, tracks, csv_path)
|
||||
Fills cache in-place. Saves to disk every 50 calls.
|
||||
"""
|
||||
# Collect unique ISRCs and track IDs
|
||||
isrc_map = {} # isrc (upper) → (artist_name, track_name)
|
||||
trackid_map = {} # spotify_track_id → True
|
||||
|
||||
for slug, display, tracks, _ in playlists:
|
||||
for t in tracks:
|
||||
isrc = t.get("ISRC", "").strip().upper()
|
||||
tid = spotify_track_id(t.get("Track URI", ""))
|
||||
if isrc and isrc not in isrc_map:
|
||||
artist = t.get("Artist Name(s)", "").split(",")[0].strip()
|
||||
title = t.get("Track Name", "").strip()
|
||||
isrc_map[isrc] = (artist, title)
|
||||
if tid:
|
||||
trackid_map[tid] = True
|
||||
|
||||
# ── MusicBrainz ISRC lookups ──────────────────────────────────────────────
|
||||
mb_key = lambda isrc: f"mb:isrc:{isrc}"
|
||||
uncached_isrcs = [
|
||||
i for i in isrc_map
|
||||
if FORCE_MB or mb_key(i) not in cache
|
||||
]
|
||||
total = len(uncached_isrcs)
|
||||
print(f"MusicBrainz: {len(isrc_map)} ISRCs total, {total} to fetch")
|
||||
|
||||
for n, isrc in enumerate(uncached_isrcs, 1):
|
||||
result = mb_isrc_lookup(isrc)
|
||||
if result is not FETCH_FAILED:
|
||||
cache[mb_key(isrc)] = result
|
||||
if n % 50 == 0:
|
||||
save_cache(cache)
|
||||
print(f" MB ISRC {n}/{total}")
|
||||
save_cache(cache)
|
||||
|
||||
# ── MusicBrainz artist URL lookups ────────────────────────────────────────
|
||||
# Collect unique MB artist IDs from ISRC results
|
||||
artist_ids = set()
|
||||
for isrc in isrc_map:
|
||||
mb_data = cache.get(mb_key(isrc), {})
|
||||
aid = mb_data.get("mb_artist_id", "")
|
||||
if aid:
|
||||
artist_ids.add(aid)
|
||||
|
||||
art_key = lambda aid: f"mb:artist:{aid}"
|
||||
uncached_artists = [
|
||||
a for a in artist_ids
|
||||
if FORCE_MB or art_key(a) not in cache
|
||||
]
|
||||
total = len(uncached_artists)
|
||||
print(f"MusicBrainz: {len(artist_ids)} artists total, {total} to fetch")
|
||||
|
||||
for n, aid in enumerate(uncached_artists, 1):
|
||||
result = mb_artist_url_lookup(aid)
|
||||
if result is not FETCH_FAILED:
|
||||
cache[art_key(aid)] = result
|
||||
if n % 50 == 0:
|
||||
save_cache(cache)
|
||||
print(f" MB artist {n}/{total}")
|
||||
save_cache(cache)
|
||||
|
||||
# ── Odesli track lookups ──────────────────────────────────────────────────
|
||||
od_key = lambda tid: f"odesli:{tid}"
|
||||
uncached_tracks = [
|
||||
tid for tid in trackid_map
|
||||
if FORCE_ODESLI or od_key(tid) not in cache
|
||||
]
|
||||
total = len(uncached_tracks)
|
||||
mins = round(total * ODESLI_INTERVAL / 60)
|
||||
print(f"Odesli: {len(trackid_map)} tracks total, {total} to fetch (~{mins} min)")
|
||||
|
||||
for n, tid in enumerate(uncached_tracks, 1):
|
||||
result = odesli_lookup(tid)
|
||||
if result is not FETCH_FAILED:
|
||||
cache[od_key(tid)] = result
|
||||
if n % 20 == 0:
|
||||
save_cache(cache)
|
||||
print(f" Odesli {n}/{total}")
|
||||
save_cache(cache)
|
||||
print("Fetch complete.")
|
||||
|
||||
# ─── HTML ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
GOOGLE_FONTS = '<link href="https://fonts.googleapis.com/css2?family=Ribeye+Marrow&family=Rambla:wght@400;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">'
|
||||
|
||||
SHARED_CSS = """
|
||||
:root {
|
||||
--bg-void: #04060b;
|
||||
--text-warm: #e8d5b8;
|
||||
--text-muted: #7a6f5e;
|
||||
--ff-primary: #a855f7;
|
||||
--ff-bright: #c084fc;
|
||||
--ff-deep: #6d28d9;
|
||||
--ff-glow: rgba(168, 85, 247, 0.18);
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
background-color: var(--bg-void);
|
||||
color: var(--text-warm);
|
||||
font-family: 'Rambla', sans-serif;
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def build_hub(playlists: list) -> str:
|
||||
"""playlists: sorted list of {slug, display_name, track_count}"""
|
||||
total = sum(p["track_count"] for p in playlists)
|
||||
n = len(playlists)
|
||||
cards = "\n".join(
|
||||
f' <a href="{p["slug"]}.html" class="playlist-card">\n'
|
||||
f' <div class="playlist-name">{esc(p["display_name"])}</div>\n'
|
||||
f' <div class="track-count">{p["track_count"]} tracks</div>\n'
|
||||
f' </a>'
|
||||
for p in playlists
|
||||
)
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PLAYLISTS | Singular Particular</title>
|
||||
{GOOGLE_FONTS}
|
||||
<style>
|
||||
{SHARED_CSS}
|
||||
body {{ padding: 2rem; }}
|
||||
h1 {{
|
||||
font-family: 'Ribeye Marrow', cursive;
|
||||
color: var(--ff-primary);
|
||||
font-weight: normal;
|
||||
font-size: clamp(3rem, 10vw, 6rem);
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 0.5rem;
|
||||
}}
|
||||
.subtitle {{
|
||||
font-family: 'Rambla', sans-serif;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 3rem;
|
||||
}}
|
||||
.back-link {{
|
||||
display: inline-block;
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
color: var(--ff-primary);
|
||||
text-decoration: none;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 1.1rem;
|
||||
transition: color 100ms ease;
|
||||
}}
|
||||
.back-link:hover {{ color: var(--ff-bright); }}
|
||||
.playlist-grid {{
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
max-width: 1400px;
|
||||
}}
|
||||
@media (min-width: 640px) {{ .playlist-grid {{ grid-template-columns: repeat(2, 1fr); }} }}
|
||||
@media (min-width: 1024px) {{ .playlist-grid {{ grid-template-columns: repeat(3, 1fr); }} }}
|
||||
.playlist-card {{
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
padding: 1.5rem;
|
||||
border-left: 2px solid var(--ff-deep);
|
||||
transition: background 100ms ease, border-color 100ms ease;
|
||||
}}
|
||||
.playlist-card:hover {{
|
||||
background: var(--ff-glow);
|
||||
border-color: var(--ff-primary);
|
||||
}}
|
||||
.playlist-name {{
|
||||
font-family: 'Ribeye Marrow', cursive;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-warm);
|
||||
line-height: 1.2;
|
||||
margin-bottom: 0.5rem;
|
||||
}}
|
||||
.track-count {{
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
}}
|
||||
footer {{
|
||||
margin-top: 5rem;
|
||||
border-top: 1px solid var(--ff-deep);
|
||||
padding-top: 2rem;
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a href="../index.html" class="back-link">← Space</a>
|
||||
<header>
|
||||
<h1>PLAYLISTS</h1>
|
||||
<p class="subtitle">{n} playlists • {total:,} tracks</p>
|
||||
</header>
|
||||
<main class="playlist-grid">
|
||||
{cards}
|
||||
</main>
|
||||
<footer>— SINGULAR PARTICULAR SPACE —</footer>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def build_track_card(track: dict, idx: int, cache: dict) -> str:
|
||||
num = f"{idx:02d}"
|
||||
name = esc(track.get("Track Name", ""))
|
||||
artists_raw = track.get("Artist Name(s)", "")
|
||||
artists = esc(artists_raw)
|
||||
album = esc(track.get("Album Name", ""))
|
||||
year = esc(get_year(track.get("Album Release Date", "")))
|
||||
duration = ms_to_mmss(track.get("Track Duration (ms)", 0))
|
||||
art_url = esc(track.get("Album Image URL", ""))
|
||||
isrc = track.get("ISRC", "").strip().upper()
|
||||
tid = spotify_track_id(track.get("Track URI", ""))
|
||||
|
||||
# Pull cached data
|
||||
mb_data = cache.get(f"mb:isrc:{isrc}", {})
|
||||
mb_rec_url = esc(mb_data.get("mb_recording_url", ""))
|
||||
mb_art_id = mb_data.get("mb_artist_id", "")
|
||||
art_data = cache.get(f"mb:artist:{mb_art_id}", {}) if mb_art_id else {}
|
||||
artist_url = esc(art_data.get("artist_url", ""))
|
||||
od_data = cache.get(f"odesli:{tid}", {}) if tid else {}
|
||||
yt_id = od_data.get("youtube_video_id", "")
|
||||
spotify_url = esc(f"https://open.spotify.com/track/{tid}") if tid else ""
|
||||
|
||||
# Spotify link
|
||||
spotify_link = (
|
||||
f'<a href="{spotify_url}" class="track-link" target="_blank" rel="noopener">[SPOTIFY]</a>'
|
||||
if spotify_url else
|
||||
'<span class="track-link unavailable">[SPOTIFY]</span>'
|
||||
)
|
||||
|
||||
# MusicBrainz link
|
||||
mb_link = (
|
||||
f'<a href="{mb_rec_url}" class="track-link" target="_blank" rel="noopener">[MUSICBRAINZ]</a>'
|
||||
if mb_rec_url else
|
||||
'<span class="track-link unavailable">[MUSICBRAINZ]</span>'
|
||||
)
|
||||
|
||||
# YouTube — embed toggle or search link
|
||||
yt_search = f"https://www.youtube.com/results?search_query={urllib.parse.quote(artists_raw + ' ' + track.get('Track Name', ''))}"
|
||||
if yt_id:
|
||||
yt_link = f'<button class="track-link youtube-toggle" data-video-id="{esc(yt_id)}">[YOUTUBE]</button>'
|
||||
embed_html = f"""
|
||||
<div class="embed-container">
|
||||
<div class="video-wrapper">
|
||||
<iframe src=""
|
||||
data-src="https://www.youtube.com/embed/{esc(yt_id)}"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen></iframe>
|
||||
</div>
|
||||
</div>"""
|
||||
else:
|
||||
yt_link = f'<a href="{esc(yt_search)}" class="track-link" target="_blank" rel="noopener">[YOUTUBE]</a>'
|
||||
embed_html = ""
|
||||
|
||||
# Artist link
|
||||
artist_link = (
|
||||
f'<a href="{artist_url}" class="track-link" target="_blank" rel="noopener">[ARTIST]</a>'
|
||||
if artist_url else
|
||||
'<span class="track-link unavailable">[ARTIST]</span>'
|
||||
)
|
||||
|
||||
return f"""
|
||||
<article class="track-card">
|
||||
<div class="track-main-row">
|
||||
<div class="zone-meta">
|
||||
<span class="track-num">{num}</span>
|
||||
<img class="album-art" src="" data-src="{art_url}" alt="" loading="lazy">
|
||||
<span class="track-dur">{duration}</span>
|
||||
</div>
|
||||
<div class="zone-info">
|
||||
<div class="track-name">{name}</div>
|
||||
<div class="artist-name">{artists}</div>
|
||||
<div class="album-meta">{album} • {year}</div>
|
||||
<div class="links-row">
|
||||
{spotify_link}
|
||||
{mb_link}
|
||||
{yt_link}
|
||||
{artist_link}
|
||||
</div>
|
||||
</div>
|
||||
</div>{embed_html}
|
||||
</article>"""
|
||||
|
||||
|
||||
def build_playlist_page(display: str, slug: str, tracks: list, cache: dict) -> str:
|
||||
total_ms = sum(int(t.get("Track Duration (ms)", 0) or 0) for t in tracks)
|
||||
total_time = ms_to_hhmmss(total_ms)
|
||||
n = len(tracks)
|
||||
cards = "".join(build_track_card(t, i + 1, cache) for i, t in enumerate(tracks))
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{esc(display)} | PLAYLISTS</title>
|
||||
{GOOGLE_FONTS}
|
||||
<style>
|
||||
{SHARED_CSS}
|
||||
header {{
|
||||
position: sticky; top: 0; z-index: 100;
|
||||
background: var(--bg-void);
|
||||
border-bottom: 1px solid var(--ff-deep);
|
||||
padding: 1rem 2rem;
|
||||
display: flex; align-items: center;
|
||||
justify-content: space-between; gap: 2rem;
|
||||
}}
|
||||
.header-main {{
|
||||
display: flex; align-items: baseline;
|
||||
gap: 1.5rem; flex-grow: 1; min-width: 0;
|
||||
}}
|
||||
header h1 {{
|
||||
font-family: 'Ribeye Marrow', cursive;
|
||||
color: var(--ff-primary);
|
||||
font-size: 2rem; font-weight: normal;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}}
|
||||
.header-meta {{
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
color: var(--text-muted); font-size: 0.9rem;
|
||||
text-transform: uppercase; white-space: nowrap;
|
||||
}}
|
||||
.nav-links {{ display: flex; gap: 1.5rem; }}
|
||||
.back-to-hub {{
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
color: var(--ff-primary); text-decoration: none;
|
||||
font-size: 1rem; white-space: nowrap;
|
||||
transition: color 100ms ease;
|
||||
}}
|
||||
.back-to-hub:hover {{ color: var(--ff-bright); }}
|
||||
.menu-toggle {{
|
||||
display: none; background: none;
|
||||
border: 1px solid var(--ff-primary);
|
||||
color: var(--ff-primary); padding: 0.5rem;
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
cursor: pointer; font-size: 1.2rem;
|
||||
}}
|
||||
@media (max-width: 640px) {{
|
||||
header {{ padding: 1rem; }}
|
||||
header h1 {{ font-size: 1.5rem; }}
|
||||
.menu-toggle {{ display: block; }}
|
||||
.nav-links {{
|
||||
display: none; position: absolute;
|
||||
top: 100%; left: 0; right: 0;
|
||||
background: var(--bg-void);
|
||||
flex-direction: column; padding: 1rem;
|
||||
border-bottom: 1px solid var(--ff-deep);
|
||||
}}
|
||||
.nav-links.open {{ display: flex; }}
|
||||
}}
|
||||
.track-list {{
|
||||
max-width: 1000px; margin: 2rem auto;
|
||||
padding: 0 1rem 5rem;
|
||||
}}
|
||||
.track-card {{
|
||||
display: flex; flex-direction: column;
|
||||
border-left: 1px solid var(--ff-deep);
|
||||
margin-bottom: 0.5rem; position: relative;
|
||||
transition: background 100ms ease, border-color 100ms ease;
|
||||
}}
|
||||
.track-card::after {{
|
||||
content: ''; position: absolute;
|
||||
bottom: -0.25rem; left: 0; right: 0;
|
||||
height: 1px; background: var(--ff-deep); opacity: 0.1;
|
||||
}}
|
||||
.track-card:hover {{ background: var(--ff-glow); border-color: var(--ff-primary); }}
|
||||
.track-card.active {{ border-color: var(--ff-primary); }}
|
||||
.track-main-row {{
|
||||
display: flex; padding: 1.5rem;
|
||||
gap: 1.5rem; align-items: flex-start;
|
||||
}}
|
||||
.zone-meta {{
|
||||
width: 80px; flex-shrink: 0;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: flex-end; gap: 0.5rem;
|
||||
}}
|
||||
.track-num {{
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
color: var(--text-muted); font-size: 1.1rem;
|
||||
}}
|
||||
.track-dur {{
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
color: var(--text-muted); font-size: 0.75rem;
|
||||
}}
|
||||
.album-art {{
|
||||
width: 64px; height: 64px;
|
||||
background: #111; object-fit: cover;
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
}}
|
||||
.zone-info {{ flex-grow: 1; min-width: 0; }}
|
||||
.track-name {{
|
||||
font-family: 'Rambla', sans-serif; font-weight: 700;
|
||||
font-size: 1.1rem; color: var(--text-warm); margin-bottom: 0.25rem;
|
||||
}}
|
||||
.artist-name {{
|
||||
font-family: 'Rambla', sans-serif; font-size: 0.95rem;
|
||||
color: var(--ff-bright); margin-bottom: 0.5rem;
|
||||
}}
|
||||
.album-meta {{
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 0.75rem; color: var(--text-muted);
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
}}
|
||||
.links-row {{
|
||||
margin-top: 1rem; display: flex;
|
||||
flex-wrap: wrap; gap: 1rem;
|
||||
}}
|
||||
.track-link {{
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 0.75rem; color: var(--ff-primary);
|
||||
text-decoration: none; transition: color 100ms ease;
|
||||
cursor: pointer; background: none; border: none; padding: 0;
|
||||
}}
|
||||
.track-link:hover {{ color: var(--ff-bright); }}
|
||||
.track-link.unavailable {{
|
||||
color: var(--text-muted); cursor: default; pointer-events: none;
|
||||
}}
|
||||
.embed-container {{
|
||||
display: none; width: 100%;
|
||||
background: var(--ff-glow);
|
||||
border-top: 1px solid var(--ff-deep);
|
||||
padding: 1rem;
|
||||
}}
|
||||
.track-card.active .embed-container {{ display: block; }}
|
||||
.video-wrapper {{
|
||||
position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;
|
||||
}}
|
||||
.video-wrapper iframe {{
|
||||
position: absolute; top: 0; left: 0;
|
||||
width: 100%; height: 100%; border: 0;
|
||||
}}
|
||||
@media (max-width: 480px) {{
|
||||
.track-main-row {{ padding: 1rem; gap: 1rem; }}
|
||||
.zone-meta {{ width: 64px; }}
|
||||
.album-art {{ width: 48px; height: 48px; }}
|
||||
}}
|
||||
footer {{
|
||||
margin-top: 5rem; padding: 2rem;
|
||||
border-top: 1px solid var(--ff-deep);
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
color: var(--text-muted); font-size: 0.8rem; text-align: center;
|
||||
}}
|
||||
footer a {{ color: var(--ff-primary); text-decoration: none; }}
|
||||
footer a:hover {{ color: var(--ff-bright); }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-main">
|
||||
<h1>{esc(display)}</h1>
|
||||
<span class="header-meta">{n} TRACKS • {total_time}</span>
|
||||
</div>
|
||||
<button class="menu-toggle" id="menuToggle">☰</button>
|
||||
<nav class="nav-links" id="navLinks">
|
||||
<a href="playlists.html" class="back-to-hub">← Playlists</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="track-list">
|
||||
{cards}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
Music data via <a href="https://musicbrainz.org" target="_blank" rel="noopener">MusicBrainz</a>
|
||||
• Links via <a href="https://song.link" target="_blank" rel="noopener">Odesli</a>
|
||||
• — SINGULAR PARTICULAR SPACE —
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {{
|
||||
const toggle = document.getElementById('menuToggle');
|
||||
const nav = document.getElementById('navLinks');
|
||||
toggle.addEventListener('click', () => {{
|
||||
nav.classList.toggle('open');
|
||||
toggle.textContent = nav.classList.contains('open') ? '\\u2715' : '\\u2630';
|
||||
}});
|
||||
|
||||
// Lazy-load album art
|
||||
const imgObserver = new IntersectionObserver((entries, obs) => {{
|
||||
entries.forEach(e => {{
|
||||
if (e.isIntersecting) {{
|
||||
const img = e.target;
|
||||
if (img.dataset.src) img.src = img.dataset.src;
|
||||
obs.unobserve(img);
|
||||
}}
|
||||
}});
|
||||
}}, {{ rootMargin: '300px' }});
|
||||
document.querySelectorAll('.album-art').forEach(img => imgObserver.observe(img));
|
||||
|
||||
// YouTube toggle — load iframe src on open
|
||||
document.querySelectorAll('.youtube-toggle').forEach(btn => {{
|
||||
btn.addEventListener('click', () => {{
|
||||
const card = btn.closest('.track-card');
|
||||
const iframe = card.querySelector('iframe');
|
||||
card.classList.toggle('active');
|
||||
if (card.classList.contains('active') && iframe) {{
|
||||
const src = iframe.getAttribute('data-src');
|
||||
if (src && iframe.getAttribute('src') !== src)
|
||||
iframe.setAttribute('src', src);
|
||||
}}
|
||||
}});
|
||||
}});
|
||||
}});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
csv_files = sorted(SCRIPT_DIR.glob("*.csv"))
|
||||
if not csv_files:
|
||||
print("No CSV files found.", file=sys.stderr); sys.exit(1)
|
||||
|
||||
# Parse all CSVs
|
||||
all_playlists = []
|
||||
for csv_path in csv_files:
|
||||
slug = make_slug(csv_path.name)
|
||||
display = make_display_name(csv_path.name)
|
||||
tracks = parse_csv(csv_path)
|
||||
all_playlists.append((slug, display, tracks, csv_path))
|
||||
|
||||
playlists_meta = sorted([
|
||||
{"slug": slug, "display_name": display, "track_count": len(tracks)}
|
||||
for slug, display, tracks, _ in all_playlists
|
||||
], key=lambda p: p["display_name"].lower())
|
||||
|
||||
# Single playlist test mode
|
||||
if _playlist_arg:
|
||||
match = next(
|
||||
((s, d, t, p) for s, d, t, p in all_playlists
|
||||
if s == _playlist_arg or p.stem == _playlist_arg),
|
||||
None
|
||||
)
|
||||
if not match:
|
||||
slugs = ", ".join(s for s, *_ in all_playlists)
|
||||
print(f"Not found: '{_playlist_arg}'\nAvailable: {slugs}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
slug, display, tracks, _ = match
|
||||
print(f"Test: '{display}' ({len(tracks)} tracks)")
|
||||
cache = load_cache()
|
||||
fetch_all([(slug, display, tracks, None)], cache)
|
||||
out = SCRIPT_DIR / f"{slug}.html"
|
||||
out.write_text(build_playlist_page(display, slug, tracks, cache), "utf-8")
|
||||
print(f"Written → {slug}.html")
|
||||
return
|
||||
|
||||
# Hub only
|
||||
if HUB_ONLY:
|
||||
hub = build_hub(playlists_meta)
|
||||
(SCRIPT_DIR / "playlists.html").write_text(hub, "utf-8")
|
||||
print("Hub written → playlists.html")
|
||||
return
|
||||
|
||||
# Full build
|
||||
cache = load_cache()
|
||||
fetch_all(all_playlists, cache)
|
||||
|
||||
(SCRIPT_DIR / "playlists.html").write_text(build_hub(playlists_meta), "utf-8")
|
||||
print("Hub written → playlists.html")
|
||||
|
||||
for slug, display, tracks, _ in all_playlists:
|
||||
out = SCRIPT_DIR / f"{slug}.html"
|
||||
out.write_text(build_playlist_page(display, slug, tracks, cache), "utf-8")
|
||||
print(f" → {slug}.html ({len(tracks)} tracks)")
|
||||
|
||||
total = sum(p["track_count"] for p in playlists_meta)
|
||||
print(f"\nDone. {len(playlists_meta)} playlists, {total:,} tracks.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user