Skip to content

Instantly share code, notes, and snippets.

@scztt
Last active March 28, 2026 11:45
Show Gist options
  • Select an option

  • Save scztt/8d0d3b31c3c237ac72d21a06ec060211 to your computer and use it in GitHub Desktop.

Select an option

Save scztt/8d0d3b31c3c237ac72d21a06ec060211 to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "click>=8.0",
# "aiohttp>=3.9",
# ]
# ///
"""
Bandcamp meta-player: resolve a markdown playlist into a streamable HTML player.
Input: markdown file with bandcamp URLs and/or "Artist — Album" lines.
Output: self-contained HTML file with a keyboard-driven player.
Accepted input formats (one per line):
https://artist.bandcamp.com/album/slug (full album)
https://artist.bandcamp.com/track/slug (single track from parent album)
https://artist.bandcamp.com (artist page — all albums, needs --include-artist-pages)
https://bandcamp.com/EmbeddedPlayer/album=12345/...
Artist — Album
Artist - Album
Priority when multiple BC URLs exist in one entry: album > embed > track > artist.
Usage:
uv run bcplayer.py playlist.md
uv run bcplayer.py playlist.md -o player.html
uv run bcplayer.py playlist.md --include-artist-pages
uv run bcplayer.py playlist.md --resolve-only # just dump JSON, no HTML
"""
import re
import sys
import json
import random
import asyncio
import html as htmlmod
from pathlib import Path
import click
import aiohttp
HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0",
}
MAX_CONCURRENT = 5 # parallel requests to bandcamp
BASE_DELAY = 0.5 # courtesy delay between requests
MAX_RETRIES = 4 # retry on 429
BACKOFF_BASE = 5 # initial backoff seconds on 429
BACKOFF_MAX = 120 # max backoff (2 min)
# -- HTTP with rate limiting + retry -------------------------------------------
# Global cooldown: when a 429 hits, ALL requests pause until this time
_global_cooldown = 0.0 # asyncio loop time
async def _fetch_text(
session: aiohttp.ClientSession, sem: asyncio.Semaphore,
url: str, method: str = "GET", **kwargs,
) -> tuple[int, str]:
"""Rate-limited fetch with exponential backoff on 429.
Returns (status_code, response_text). On total failure returns (0, "").
"""
global _global_cooldown
for attempt in range(MAX_RETRIES + 1):
async with sem:
# Respect global cooldown from any 429
now = asyncio.get_event_loop().time()
if _global_cooldown > now:
await asyncio.sleep(_global_cooldown - now)
# Polite delay before every request
await asyncio.sleep(BASE_DELAY + random.uniform(0, 0.5))
try:
async with session.request(
method, url,
timeout=aiohttp.ClientTimeout(total=20),
allow_redirects=True,
**kwargs,
) as resp:
if resp.status == 429:
# Use our backoff, ignore tiny Retry-After values
our_wait = min(BACKOFF_BASE * (2 ** attempt), BACKOFF_MAX)
retry_after = resp.headers.get("Retry-After")
if retry_after and retry_after.isdigit():
wait = max(int(retry_after), our_wait)
else:
wait = our_wait
jitter = random.uniform(0, wait * 0.3)
total_wait = wait + jitter
# Set global cooldown so ALL in-flight requests pause
loop_now = asyncio.get_event_loop().time()
_global_cooldown = max(_global_cooldown, loop_now + total_wait)
click.echo(f" 429 rate limited, waiting {total_wait:.0f}s (attempt {attempt + 1}/{MAX_RETRIES + 1})")
await asyncio.sleep(total_wait)
continue
text = await resp.text()
return (resp.status, text)
except Exception as exc:
if attempt < MAX_RETRIES:
click.echo(f" request error ({exc}), retrying...")
await asyncio.sleep(BACKOFF_BASE * (2 ** attempt))
continue
return (0, "")
return (0, "")
# -- Resolve album IDs (async) ------------------------------------------------
def extract_album_id_from_embed_url(url: str) -> str | None:
"""Pull album ID from an EmbeddedPlayer URL."""
m = re.search(r"album=(\d+)", url)
return m.group(1) if m else None
def _parse_tralbum_id(text: str) -> tuple[str, str] | None:
"""Extract album or track ID from page HTML. Returns (type, id) or None."""
# Try album first
m = re.search(r'album[_-]id["\s:=]+(\d{5,})', text)
if m:
return ("album", m.group(1))
m = re.search(r'album=(\d{5,})', text)
if m:
return ("album", m.group(1))
# Fall back to track
m = re.search(r'track[_-]id["\s:=]+(\d{5,})', text)
if m:
return ("track", m.group(1))
m = re.search(r'track=(\d{5,})', text)
if m:
return ("track", m.group(1))
return None
async def fetch_tralbum_id(session: aiohttp.ClientSession, sem: asyncio.Semaphore, url: str) -> tuple[str, str] | None:
"""Fetch a bandcamp page, extract album or track ID. Returns (type, id) or None."""
status, text = await _fetch_text(session, sem, url)
if status != 200:
return None
return _parse_tralbum_id(text)
async def search_bandcamp(session: aiohttp.ClientSession, sem: asyncio.Semaphore, query: str) -> str | None:
"""Search bandcamp.com for an album, return the first album URL found."""
query = query.replace("\u2014", " ").replace("\u2013", " ").replace(" - ", " ")
status, text = await _fetch_text(
session, sem, "https://bandcamp.com/search",
params={"q": query, "item_type": "a"},
)
if status != 200:
return None
m = re.search(r'class="artcont"\s+href="(https?://[^"]+/album/[^"]+)"', text)
if m:
return htmlmod.unescape(m.group(1)).split("?")[0]
m = re.search(r'href="(https?://[^"]+\.bandcamp\.com/album/[^"]+)"', text)
if m:
return htmlmod.unescape(m.group(1)).split("?")[0]
return None
async def fetch_player_data(
session: aiohttp.ClientSession, sem: asyncio.Semaphore,
tralbum_type: str, tralbum_id: str,
) -> dict | None:
"""Fetch the embed page and extract the player data JSON."""
embed_url = (
f"https://bandcamp.com/EmbeddedPlayer/{tralbum_type}={tralbum_id}"
f"/size=large/bgcol=ffffff/linkcol=0687f5/tracklist=false"
f"/artwork=small/transparent=true/"
)
status, text = await _fetch_text(session, sem, embed_url)
if status != 200:
return None
m = re.search(r'data-player-data="([^"]+)"', text)
if not m:
return None
try:
return json.loads(htmlmod.unescape(m.group(1)))
except (json.JSONDecodeError, ValueError):
return None
def _find_bc_url(text: str) -> str | None:
"""Extract first bandcamp URL from text."""
m = re.search(r"https?://[^\s\)\]]+bandcamp\.com[^\s\)\]]*", text)
if m:
return m.group(0)
if "EmbeddedPlayer" in text:
m = re.search(r"https?://bandcamp\.com/EmbeddedPlayer[^\s\)\]]+", text)
if m:
return m.group(0)
return None
def _classify_bc_url(url: str) -> str:
"""Classify a Bandcamp URL into: embed, album, track, or artist."""
if "EmbeddedPlayer" in url:
return "embed"
if re.search(r"bandcamp\.com/album/", url):
return "album"
if re.search(r"bandcamp\.com/track/", url):
return "track"
# Bare artist/label page: artist.bandcamp.com or .../music
if re.match(r"https?://[^/]+\.bandcamp\.com(/music)?/?(\?.*)?$", url):
return "artist"
# Fallback: treat unknown paths as album
return "album"
def _extract_album_urls_from_artist_page(html_text: str, base_url: str) -> list[str]:
"""Extract all album URLs from a Bandcamp artist/label page.
Uses the data-client-items JSON blob on #music-grid which contains the
full discography (the HTML <a> tags only have the first ~16).
Falls back to scraping <a href="/album/..."> if the JSON isn't found.
"""
base = base_url.rstrip("/")
# Try the JSON blob first (contains full discography)
m = re.search(r'data-client-items="([^"]+)"', html_text)
if m:
try:
items = json.loads(htmlmod.unescape(m.group(1)))
urls = []
seen: set[str] = set()
for item in items:
page_url = item.get("page_url", "")
if page_url and page_url not in seen:
seen.add(page_url)
urls.append(base + page_url)
if urls:
return urls
except (json.JSONDecodeError, ValueError):
pass
# Fallback: scrape <a> tags (only gets first page of results)
slugs = re.findall(r'href="(/album/[^"]+)"', html_text)
seen_slugs: set[str] = set()
urls = []
for slug in slugs:
slug = slug.split("?")[0]
if slug not in seen_slugs:
seen_slugs.add(slug)
urls.append(base + slug)
return urls
def _parse_scrape_entries(text: str) -> list[dict]:
"""Parse ---separated scrape format (bandcamp.md / SoundProjector.md).
Each entry block may contain:
- A bandcamp URL anywhere → use it
- *artist*: and *album*: metadata fields → use as search query
- A heading like "# Artist — Album" → fallback display name
Returns list of {display: str, bc_url: str|None}
"""
# Strip leading HTML comment (<!-- last scraped ... -->)
text = re.sub(r"<!--.*?-->\s*\n*", "", text, count=1, flags=re.DOTALL)
chunks = re.split(r"\n---\s*\n", text)
entries = []
for chunk in chunks:
chunk = chunk.strip()
if not chunk:
continue
# Find all BC URLs in the chunk
# Pattern 1: artist.bandcamp.com/... (direct album/track/artist links)
bc_urls = re.findall(
r"https?://[^\s\)\]>\"]+bandcamp\.com[^\s\)\]>\"]*", chunk
)
# Pattern 2: bandcamp.com/EmbeddedPlayer/... (iframe embed URLs)
embed_urls = re.findall(
r"https?://bandcamp\.com/EmbeddedPlayer[^\s\)\]>\"]*", chunk
)
bc_urls.extend(embed_urls)
# Classify and pick best URL: album > embed > track > artist
by_type: dict[str, str] = {}
for u in bc_urls:
t = _classify_bc_url(u)
if t not in by_type:
by_type[t] = u
bc_url = (
by_type.get("album")
or by_type.get("embed")
or by_type.get("track")
or by_type.get("artist")
)
# Extract artist / album from metadata fields
artist = ""
album = ""
am = re.search(r"^\*artist\*:\s*(.+)", chunk, re.MULTILINE)
if am:
artist = am.group(1).strip()
alm = re.search(r"^\*album\*:\s*(.+)", chunk, re.MULTILINE)
if alm:
album = alm.group(1).strip()
# Fallback: heading
if not artist and not album:
hm = re.search(r"^#\s+(.+)", chunk, re.MULTILINE)
if hm:
heading = hm.group(1).strip()
# Try to split "Artist — Album"
for sep in (" — ", " - ", " – "):
if sep in heading:
parts = heading.split(sep, 1)
artist = parts[0].strip()
album = parts[1].strip()
break
else:
artist = heading
if artist and album:
display = f"{artist}{album}"
elif artist:
display = artist
else:
continue # no usable info
# Unescape HTML entities that the scrapers may have added
display = htmlmod.unescape(display)
url_type = _classify_bc_url(bc_url) if bc_url else None
entries.append({"display": display, "bc_url": bc_url, "url_type": url_type})
return entries
def _parse_entries(lines: list[str]) -> list[dict]:
"""Parse markdown into playlist entries.
Handles three formats:
1. Scrape format: entries separated by --- with metadata fields
2. Triage format: top-level list item = display name, sub-items = BC link + backlink
3. Flat format: one item per line (URL or artist — album)
Returns list of {display: str, bc_url: str|None, url_type: str|None}
"""
# Detect scrape format: has --- separators and *artist*: or *album*: fields
full_text = "\n".join(lines)
has_separators = bool(re.search(r"\n---\s*\n", full_text))
has_metadata = bool(re.search(r"^\*(?:artist|album)\*:", full_text, re.MULTILINE))
if has_separators and has_metadata:
return _parse_scrape_entries(full_text)
entries = []
i = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()
# Skip blanks, headings, comments
if not stripped or stripped.startswith("#") or stripped.startswith("<!--"):
i += 1
continue
# Check if this is a top-level list item (- or * at column 0 or very little indent)
top_match = re.match(r"^[-*]\s+(.+)", line)
if top_match:
display = top_match.group(1).strip()
# Clean markdown formatting from display
display = re.sub(r"\*\*([^*]+)\*\*", r"\1", display)
display = re.sub(r"\*([^*]+)\*", r"\1", display)
display = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", display)
display = display.strip()
# Check if the top-level item itself is/contains a BC URL
bc_url = _find_bc_url(stripped)
# Collect sub-items (indented list items)
children_text = ""
i += 1
while i < len(lines):
child = lines[i]
# Sub-item: starts with spaces + - or spaces + *
if re.match(r"^ +[-*]\s+", child):
children_text += " " + child.strip()
i += 1
elif child.strip() == "":
i += 1 # skip blank lines within a group
# But stop if the next non-blank line is a new top-level item
if i < len(lines) and re.match(r"^[-*]\s+", lines[i]):
break
else:
break
# If we didn't find a BC URL in the top item, check children
if not bc_url and children_text:
bc_url = _find_bc_url(children_text)
if display:
url_type = _classify_bc_url(bc_url) if bc_url else None
entries.append({"display": display, "bc_url": bc_url, "url_type": url_type})
else:
# Non-list line — treat as raw entry (URL or text)
display = stripped
bc_url = _find_bc_url(stripped)
if not bc_url:
display = re.sub(r"\*\*([^*]+)\*\*", r"\1", display)
display = re.sub(r"\*([^*]+)\*", r"\1", display)
display = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", display)
display = display.strip()
if display:
url_type = _classify_bc_url(bc_url) if bc_url else None
entries.append({"display": display, "bc_url": bc_url, "url_type": url_type})
i += 1
return entries
async def resolve_entry(
session: aiohttp.ClientSession, sem: asyncio.Semaphore, entry: dict
) -> dict | None:
"""Resolve a parsed entry into album data with stream URLs."""
bc_url = entry["bc_url"]
display = entry["display"]
url_type = entry.get("url_type")
tralbum: tuple[str, str] | None = None # (type, id)
# For track URLs, remember the slug so we can filter later
track_filter_slug: str | None = None
if url_type == "track" and bc_url:
m = re.search(r"/track/([^?/]+)", bc_url)
if m:
track_filter_slug = m.group(1)
if bc_url:
if "EmbeddedPlayer" in bc_url:
# Could be album= or track=
for kind in ("album", "track"):
m = re.search(rf"{kind}=(\d+)", bc_url)
if m:
tralbum = (kind, m.group(1))
break
else:
click.echo(f" fetching: {bc_url}")
tralbum = await fetch_tralbum_id(session, sem, bc_url)
if not tralbum:
click.echo(f" searching: {display}")
found_url = await search_bandcamp(session, sem, display)
if found_url:
click.echo(f" found: {found_url}")
tralbum = await fetch_tralbum_id(session, sem, found_url)
else:
click.echo(f" NOT FOUND: {display}")
return None
if not tralbum:
return None
tralbum_type, tralbum_id = tralbum
click.echo(f" streams: {tralbum_type}={tralbum_id}")
data = await fetch_player_data(session, sem, tralbum_type, tralbum_id)
if not data:
click.echo(f" failed to fetch player data")
return None
# Extract base URL for building track page links
linkback = data.get("linkback", "")
base_url = ""
if linkback:
m = re.match(r"(https?://[^/]+)", linkback)
if m:
base_url = m.group(1)
tracks = []
for t in data.get("tracks", []):
file_info = t.get("file") or {}
mp3_url = file_info.get("mp3-128", "") if isinstance(file_info, dict) else ""
if not mp3_url:
continue
# Build track page URL from title_link (e.g. "/track/slug")
track_link = ""
title_link = t.get("title_link", "")
if title_link and base_url:
track_link = base_url + title_link
tracks.append({
"title": t.get("title", "Untitled"),
"artist": t.get("artist", data.get("artist", "")),
"duration": t.get("duration", 0),
"stream_url": mp3_url,
"link": track_link,
})
if not tracks:
click.echo(f" no streamable tracks")
return None
# If this was a /track/ URL, filter to just that track
if track_filter_slug:
filtered = [t for t in tracks if track_filter_slug in (t.get("link") or "")]
if filtered:
tracks = filtered
result = {
"artist": data.get("artist", "Unknown"),
"album": data.get("album_title") or data.get("title") or "Unknown",
"art": data.get("album_art", "") or data.get("album_art_lg", ""),
"link": linkback,
"tralbum_key": f"{tralbum_type[0]}{tralbum_id}",
"tracks": tracks,
}
click.echo(f" ✓ {result['artist']}{result['album']} ({len(tracks)} tracks)")
return result
async def resolve_artist_entry(
session: aiohttp.ClientSession, sem: asyncio.Semaphore, entry: dict,
) -> list[dict]:
"""Resolve an artist/label page into a list of album results."""
artist_url = entry["bc_url"]
click.echo(f" fetching artist page: {artist_url}")
m = re.match(r"(https?://[^/]+)", artist_url)
base_url = m.group(1) if m else artist_url
status, text = await _fetch_text(session, sem, artist_url)
if status != 200:
click.echo(f" failed to fetch artist page")
return []
album_urls = _extract_album_urls_from_artist_page(text, base_url)
click.echo(f" found {len(album_urls)} albums on artist page")
results = []
for url in album_urls:
sub_entry = {"display": entry["display"], "bc_url": url, "url_type": "album"}
result = await resolve_entry(session, sem, sub_entry)
if result:
results.append(result)
return results
async def resolve_playlist(
lines: list[str], include_artist_pages: bool = False,
) -> tuple[list[dict], list[str]]:
"""Resolve all playlist entries concurrently. Returns (albums, failures)."""
entries = _parse_entries(lines)
sem = asyncio.Semaphore(MAX_CONCURRENT)
# Split artist-page entries from regular ones
regular: list[dict] = []
artist_entries: list[dict] = []
for e in entries:
if e.get("url_type") == "artist":
if include_artist_pages:
artist_entries.append(e)
else:
click.echo(f" skipping artist page (use --include-artist-pages): {e['bc_url']}")
else:
regular.append(e)
albums: list[dict] = []
failures: list[str] = []
seen_ids: set[str] = set()
async with aiohttp.ClientSession(headers=HEADERS) as session:
# Resolve regular entries concurrently
tasks = [resolve_entry(session, sem, e) for e in regular]
results = await asyncio.gather(*tasks)
for entry, result in zip(regular, results):
if result is None:
fail_str = entry["display"]
if entry.get("bc_url"):
fail_str += f" ({entry['bc_url']})"
failures.append(fail_str)
continue
if result["tralbum_key"] in seen_ids:
continue
seen_ids.add(result["tralbum_key"])
albums.append(result)
# Resolve artist pages (each expands into multiple albums)
for ae in artist_entries:
artist_results = await resolve_artist_entry(session, sem, ae)
for result in artist_results:
if result["tralbum_key"] not in seen_ids:
seen_ids.add(result["tralbum_key"])
albums.append(result)
return albums, failures
# -- HTML player ---------------------------------------------------------------
PLAYER_HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>BC Player</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #111; color: #eee;
height: 100vh; display: flex; flex-direction: column;
user-select: none; overflow: hidden;
}
/* --- Top bar: album art + info + progress --- */
#player-bar {
display: flex; align-items: center; gap: 16px;
padding: 12px 16px; background: #1a1a1a;
border-bottom: 1px solid #333; flex-shrink: 0;
}
#art { width: 64px; height: 64px; border-radius: 4px; object-fit: cover; background: #333; }
#info { flex: 1; min-width: 0; }
#info-artist { font-size: 13px; color: #999; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#info-track { font-size: 15px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#info-album { font-size: 12px; color: #666; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
#times { font-size: 12px; color: #666; white-space: nowrap; }
#progress-wrap {
position: relative; height: 4px; background: #333; border-radius: 2px;
cursor: pointer; margin: 8px 16px 0;
}
#progress-bar { height: 100%; background: #1da0c3; border-radius: 2px; width: 0%; transition: width 0.3s linear; }
/* --- Main area: tracklist + sidebar --- */
#main { display: flex; flex: 1; overflow: hidden; }
#tracklist {
flex: 1; overflow-y: auto; padding: 8px 0;
}
.album-group { margin-bottom: 8px; }
.album-header {
padding: 8px 16px; font-size: 13px; font-weight: 600; color: #1da0c3;
background: #1a1a1a; position: sticky; top: 0; z-index: 1;
cursor: pointer; display: flex; justify-content: space-between;
}
.album-header:hover { background: #222; }
.album-header .album-link { color: #666; font-weight: 400; font-size: 11px; text-decoration: none; }
.album-header .album-link:hover { color: #999; }
.track-row {
padding: 4px 16px 4px 32px; font-size: 13px; cursor: pointer;
display: flex; justify-content: space-between; color: #888;
}
.track-row:hover { background: #1a1a1a; color: #ccc; }
.track-row.active { color: #fff; background: #1a2a30; }
.track-row .dur { color: #555; font-size: 11px; }
/* --- Sidebar: marked items --- */
#sidebar {
width: 280px; background: #151515; border-left: 1px solid #333;
display: flex; flex-direction: column; flex-shrink: 0;
}
#sidebar-header {
padding: 12px 16px; font-size: 13px; font-weight: 600; color: #999;
border-bottom: 1px solid #272727;
}
#marked-list { flex: 1; overflow-y: auto; padding: 8px 0; }
.marked-item {
padding: 4px 16px; font-size: 13px;
}
.marked-item a { color: #1da0c3; text-decoration: none; }
.marked-item a:hover { text-decoration: underline; }
#sidebar-actions {
display: flex; gap: 8px; padding: 10px 16px;
border-top: 1px solid #272727; flex-shrink: 0;
}
#sidebar-actions button {
flex: 1; padding: 6px 0; border: 1px solid #444; border-radius: 4px;
background: #222; color: #ccc; font-size: 12px; cursor: pointer;
display: flex; align-items: center; justify-content: center; gap: 5px;
}
#sidebar-actions button:hover { background: #333; border-color: #666; }
#sidebar-actions button.flash { background: #1a3a2a; border-color: #3a7a5a; transition: none; }
/* --- Bottom bar: controls + help --- */
#controls {
display: flex; align-items: center; justify-content: center; gap: 24px;
padding: 8px 16px; background: #1a1a1a; border-top: 1px solid #333;
font-size: 11px; color: #555; flex-shrink: 0;
}
#controls kbd {
background: #333; color: #aaa; padding: 1px 5px; border-radius: 3px;
font-family: inherit; font-size: 11px;
}
#play-state { color: #1da0c3; font-weight: 600; min-width: 16px; text-align: center; font-size: 14px; }
#shuffle-btn {
cursor: pointer; padding: 2px 8px; border-radius: 4px;
border: 1px solid transparent; user-select: none;
}
#shuffle-btn.active { color: #1da0c3; border-color: #1da0c3; }
#shuffle-btn:not(.active) { color: #555; }
/* --- Failures banner --- */
#failures {
padding: 8px 16px; background: #2a1a1a; color: #c66; font-size: 12px;
border-bottom: 1px solid #3a2222; display: none;
max-height: 5.5em; overflow-y: auto; white-space: pre-line; line-height: 1.3;
}
#failures.visible { display: block; }
</style>
</head>
<body>
<div id="player-bar">
<img id="art" src="" alt="">
<div id="info">
<div id="info-artist">—</div>
<div id="info-track">Load a playlist</div>
<div id="info-album"></div>
</div>
<div id="times"><span id="time-cur">0:00</span> / <span id="time-dur">0:00</span></div>
</div>
<div id="progress-wrap" onclick="seekClick(event)">
<div id="progress-bar"></div>
</div>
<div id="failures"></div>
<div id="main">
<div id="tracklist"></div>
<div id="sidebar">
<div id="sidebar-header">Marked (<span id="marked-count">0</span>)</div>
<div id="marked-list"></div>
<div id="sidebar-actions">
<button onclick="copyMarked()" id="btn-copy" title="Copy list to clipboard">📋 Copy</button>
<button onclick="openAllMarked()" id="btn-open" title="Open all in new tabs">🔗 Open All</button>
</div>
</div>
</div>
<div id="controls">
<span id="play-state">⏸</span>
<span><kbd>Space</kbd> play/pause</span>
<span><kbd>←</kbd><kbd>→</kbd> prev/next track</span>
<span><kbd>↑</kbd><kbd>↓</kbd> prev/next album</span>
<span><kbd>Shift←</kbd><kbd>Shift→</kbd> scrub 30s</span>
<span><kbd>Scroll</kbd> scrub</span>
<span><kbd>A</kbd><kbd>D</kbd> album · <kbd>Q</kbd><kbd>E</kbd> track</span>
<span><kbd>S</kbd> <span id="shuffle-btn" onclick="toggleShuffle()">shuffle</span></span>
</div>
<audio id="audio" preload="auto"></audio>
<script>
// --- DATA (injected by Python) ---
const PLAYLIST = __PLAYLIST_JSON__;
const FAILURES = __FAILURES_JSON__;
// --- State ---
let albumIdx = 0;
let trackIdx = 0;
let playing = false;
const audio = document.getElementById('audio');
const marked = new Set(); // album indices
const markedTracks = new Set(); // flat track indices
// Shuffle state
let shuffleOn = false;
let shufflePlayedTracks = new Set(); // flat indices never repeated
let shufflePlayedAlbums = new Set(); // album-fairness cycle
let shuffleHistory = []; // ordered list of flat indices we've visited
let shuffleHistoryPos = -1; // current position in history
// --- Build flat index ---
const flatTracks = []; // [{albumIdx, trackIdx, stream_url, title, artist, album, art, albumLink, trackLink}]
PLAYLIST.forEach((album, ai) => {
album.tracks.forEach((track, ti) => {
flatTracks.push({
albumIdx: ai, trackIdx: ti,
stream_url: track.stream_url,
title: track.title,
artist: track.artist || album.artist,
album: album.album,
art: album.art,
albumLink: album.link,
trackLink: track.link || '',
duration: track.duration,
});
});
});
let flatIdx = 0;
// --- Render tracklist ---
function renderTracklist() {
const el = document.getElementById('tracklist');
let html = '';
PLAYLIST.forEach((album, ai) => {
const markedCls = marked.has(ai) ? ' style="color:#6c6"' : '';
html += `<div class="album-group" data-album="${ai}">`;
html += `<div class="album-header" onclick="jumpAlbum(${ai})"${markedCls}>`;
html += `<span>${esc(album.artist)} — ${esc(album.album)}</span>`;
if (album.link) {
html += ` <a class="album-link" href="${esc(album.link)}" target="_blank" onclick="event.stopPropagation()">↗ bandcamp</a>`;
}
html += `</div>`;
album.tracks.forEach((track, ti) => {
const fi = flatTracks.findIndex(f => f.albumIdx === ai && f.trackIdx === ti);
const dur = fmtTime(track.duration);
const trkMark = markedTracks.has(fi) ? ' style="color:#6c6"' : '';
html += `<div class="track-row" data-flat="${fi}" onclick="jumpFlat(${fi})"${trkMark}>`;
html += `<span>${esc(track.title)}</span><span class="dur">${dur}</span>`;
html += `</div>`;
});
html += `</div>`;
});
el.innerHTML = html;
}
function renderFailures() {
if (!FAILURES.length) return;
const el = document.getElementById('failures');
el.textContent = `Could not resolve (${FAILURES.length}):\n${FAILURES.join('\n')}`;
el.classList.add('visible');
}
function renderMarked() {
const el = document.getElementById('marked-list');
let html = '';
// Album-level marks
marked.forEach(ai => {
const album = PLAYLIST[ai];
html += `<div class="marked-item">`;
if (album.link) {
html += `<a href="${esc(album.link)}" target="_blank">${esc(album.artist)} — ${esc(album.album)}</a>`;
} else {
html += `${esc(album.artist)} — ${esc(album.album)}`;
}
html += `</div>`;
});
// Track-level marks, grouped by album (skip albums already fully marked)
const tracksByAlbum = new Map();
markedTracks.forEach(fi => {
const t = flatTracks[fi];
if (marked.has(t.albumIdx)) return; // album already marked, skip
if (!tracksByAlbum.has(t.albumIdx)) tracksByAlbum.set(t.albumIdx, []);
tracksByAlbum.get(t.albumIdx).push(t);
});
tracksByAlbum.forEach((tracks, ai) => {
const album = PLAYLIST[ai];
html += `<div class="marked-item" style="color:#999">`;
if (album.link) {
html += `<a href="${esc(album.link)}" target="_blank" style="color:#88b">${esc(album.artist)} — ${esc(album.album)}</a>`;
} else {
html += `${esc(album.artist)} — ${esc(album.album)}`;
}
html += `</div>`;
tracks.forEach(t => {
html += `<div class="marked-item" style="padding-left:28px;color:#6c6;font-size:12px">${esc(t.title)}</div>`;
});
});
el.innerHTML = html;
const totalCount = marked.size + tracksByAlbum.size;
document.getElementById('marked-count').textContent = totalCount;
}
function highlightActive() {
document.querySelectorAll('.track-row').forEach(el => {
el.classList.toggle('active', parseInt(el.dataset.flat) === flatIdx);
});
// Scroll active into view
const active = document.querySelector('.track-row.active');
if (active) active.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
// --- Playback ---
function loadTrack(fi) {
if (fi < 0 || fi >= flatTracks.length) return;
flatIdx = fi;
const t = flatTracks[fi];
albumIdx = t.albumIdx;
trackIdx = t.trackIdx;
audio.src = t.stream_url;
document.getElementById('art').src = t.art || '';
document.getElementById('info-artist').textContent = t.artist;
document.getElementById('info-track').textContent = t.title;
document.getElementById('info-album').textContent = t.album;
document.getElementById('time-dur').textContent = fmtTime(t.duration);
highlightActive();
if (playing) {
audio.play().catch(() => {});
}
}
function togglePlay() {
if (!flatTracks.length) return;
if (playing) {
audio.pause();
playing = false;
} else {
audio.play().catch(() => {});
playing = true;
}
document.getElementById('play-state').textContent = playing ? '▶' : '⏸';
}
function nextTrack() {
if (flatIdx < flatTracks.length - 1) loadTrack(flatIdx + 1);
}
function prevTrack() {
// If more than 3s in, restart current track; otherwise go back
if (audio.currentTime > 3) {
audio.currentTime = 0;
} else if (flatIdx > 0) {
loadTrack(flatIdx - 1);
}
}
function nextAlbum() {
const curAlbum = flatTracks[flatIdx]?.albumIdx;
const next = flatTracks.findIndex(f => f.albumIdx > curAlbum);
if (next >= 0) loadTrack(next);
}
function prevAlbum() {
const curAlbum = flatTracks[flatIdx]?.albumIdx;
// Find first track of previous album
let prevAi = -1;
for (let i = flatIdx - 1; i >= 0; i--) {
if (flatTracks[i].albumIdx < curAlbum) { prevAi = flatTracks[i].albumIdx; break; }
}
if (prevAi >= 0) {
const first = flatTracks.findIndex(f => f.albumIdx === prevAi);
if (first >= 0) loadTrack(first);
}
}
function scrub(seconds) {
if (!audio.duration) return;
audio.currentTime = Math.max(0, Math.min(audio.duration, audio.currentTime + seconds));
}
function seekClick(e) {
if (!audio.duration) return;
const rect = e.currentTarget.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
audio.currentTime = pct * audio.duration;
}
function jumpFlat(fi) { loadTrack(fi); if (!playing) togglePlay(); }
function jumpAlbum(ai) {
const fi = flatTracks.findIndex(f => f.albumIdx === ai);
if (fi >= 0) { loadTrack(fi); if (!playing) togglePlay(); }
}
function addMark() {
const ai = flatTracks[flatIdx]?.albumIdx;
if (ai === undefined || marked.has(ai)) return;
marked.add(ai);
renderMarked(); renderTracklist(); highlightActive();
}
function removeMark() {
const ai = flatTracks[flatIdx]?.albumIdx;
if (ai === undefined || !marked.has(ai)) return;
marked.delete(ai);
renderMarked(); renderTracklist(); highlightActive();
}
function addTrackMark() {
if (flatIdx === undefined || markedTracks.has(flatIdx)) return;
markedTracks.add(flatIdx);
renderMarked(); renderTracklist(); highlightActive();
}
function removeTrackMark() {
if (flatIdx === undefined || !markedTracks.has(flatIdx)) return;
markedTracks.delete(flatIdx);
renderMarked(); renderTracklist(); highlightActive();
}
function copyMarked() {
if (!marked.size && !markedTracks.size) return;
const lines = [];
// Album-level marks
marked.forEach(ai => {
const a = PLAYLIST[ai];
if (a) lines.push('- ' + a.artist + ' \u2014 ' + a.album);
});
// Track-level marks, grouped by album (skip albums already fully marked)
const tracksByAlbum = new Map();
markedTracks.forEach(fi => {
const t = flatTracks[fi];
if (marked.has(t.albumIdx)) return;
if (!tracksByAlbum.has(t.albumIdx)) tracksByAlbum.set(t.albumIdx, []);
tracksByAlbum.get(t.albumIdx).push(t);
});
tracksByAlbum.forEach((tracks, ai) => {
const a = PLAYLIST[ai];
lines.push('- ' + a.artist + ' \u2014 ' + a.album);
tracks.forEach(t => { lines.push(' - ' + t.title); });
});
navigator.clipboard.writeText(lines.join('\n')).then(() => {
const btn = document.getElementById('btn-copy');
btn.classList.add('flash');
setTimeout(() => btn.classList.remove('flash'), 600);
});
}
function openAllMarked() {
if (!marked.size && !markedTracks.size) return;
// Collect all unique album indices to open
const albumsToOpen = new Set(marked);
markedTracks.forEach(fi => { albumsToOpen.add(flatTracks[fi].albumIdx); });
// Use <a> clicks instead of window.open to avoid popup blocker
albumsToOpen.forEach(ai => {
const a = PLAYLIST[ai];
if (a && a.link) {
const el = document.createElement('a');
el.href = a.link;
el.target = '_blank';
el.rel = 'noopener';
document.body.appendChild(el);
el.click();
document.body.removeChild(el);
}
});
}
// --- Shuffle ---
function toggleShuffle() {
shuffleOn = !shuffleOn;
// Reset all history on every toggle
shufflePlayedTracks = new Set();
shufflePlayedAlbums = new Set();
shuffleHistory = [];
shuffleHistoryPos = -1;
document.getElementById('shuffle-btn').classList.toggle('active', shuffleOn);
}
function shuffleNext() {
// If we've gone back and are mid-history, just step forward
if (shuffleHistoryPos < shuffleHistory.length - 1) {
shuffleHistoryPos++;
loadTrack(shuffleHistory[shuffleHistoryPos]);
return;
}
// Pick a new random track
const allAlbums = new Set(flatTracks.map(f => f.albumIdx));
let freshAlbums = [...allAlbums].filter(ai => !shufflePlayedAlbums.has(ai));
if (freshAlbums.length === 0) {
shufflePlayedAlbums = new Set();
freshAlbums = [...allAlbums];
}
let candidates = flatTracks
.map((f, fi) => ({ fi, ai: f.albumIdx }))
.filter(t => freshAlbums.includes(t.ai) && !shufflePlayedTracks.has(t.fi));
if (candidates.length === 0) return; // exhausted every track
const pick = candidates[Math.floor(Math.random() * candidates.length)];
shufflePlayedTracks.add(pick.fi);
shufflePlayedAlbums.add(pick.ai);
// Trim any future history beyond current position, then append
shuffleHistory.length = shuffleHistoryPos + 1;
shuffleHistory.push(pick.fi);
shuffleHistoryPos = shuffleHistory.length - 1;
loadTrack(pick.fi);
}
function shufflePrev() {
if (shuffleHistoryPos > 0) {
shuffleHistoryPos--;
loadTrack(shuffleHistory[shuffleHistoryPos]);
}
}
// --- Audio events ---
audio.addEventListener('timeupdate', () => {
if (!audio.duration) return;
const pct = (audio.currentTime / audio.duration) * 100;
document.getElementById('progress-bar').style.width = pct + '%';
document.getElementById('time-cur').textContent = fmtTime(audio.currentTime);
});
let scrubbing = false; // true while shift-arrow scrub is active
audio.addEventListener('ended', () => {
if (scrubbing) { scrubbing = false; nextTrack(); }
else if (shuffleOn) shuffleNext();
else nextTrack();
});
// --- Keyboard ---
document.addEventListener('keydown', e => {
// Don't intercept if user is in an input
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
switch (e.code) {
case 'Space':
e.preventDefault();
togglePlay();
break;
case 'ArrowRight':
e.preventDefault();
if (e.shiftKey) { scrubbing = true; scrub(30); }
else if (shuffleOn) shuffleNext();
else nextTrack();
break;
case 'ArrowLeft':
e.preventDefault();
if (e.shiftKey) { scrubbing = true; scrub(-30); }
else if (shuffleOn) shufflePrev();
else prevTrack();
break;
case 'ArrowDown':
e.preventDefault();
if (shuffleOn) shuffleNext();
else nextAlbum();
break;
case 'ArrowUp':
e.preventDefault();
if (shuffleOn) shufflePrev();
else prevAlbum();
break;
case 'KeyA':
e.preventDefault();
addMark();
break;
case 'KeyD':
e.preventDefault();
removeMark();
break;
case 'KeyQ':
e.preventDefault();
addTrackMark();
break;
case 'KeyE':
e.preventDefault();
removeTrackMark();
break;
case 'KeyS':
e.preventDefault();
toggleShuffle();
break;
}
});
// --- Mouse wheel scrub ---
document.getElementById('progress-wrap').addEventListener('wheel', e => {
e.preventDefault();
// deltaX for horizontal scroll, deltaY as fallback
const delta = e.deltaX || e.deltaY;
scrub(delta > 0 ? 5 : -5);
}, { passive: false });
// Also allow wheel on the whole player bar area
document.getElementById('player-bar').addEventListener('wheel', e => {
e.preventDefault();
const delta = e.deltaX || e.deltaY;
scrub(delta > 0 ? 5 : -5);
}, { passive: false });
// --- Utils ---
function fmtTime(s) {
s = Math.floor(s);
const m = Math.floor(s / 60);
return m + ':' + String(s % 60).padStart(2, '0');
}
function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// --- Init ---
renderTracklist();
renderFailures();
if (flatTracks.length) loadTrack(0);
</script>
</body>
</html>"""
def build_html(albums: list[dict], failures: list[str]) -> str:
"""Inject playlist data into the HTML template."""
playlist_json = json.dumps(albums, ensure_ascii=False)
failures_json = json.dumps(failures, ensure_ascii=False)
html = PLAYER_HTML.replace("__PLAYLIST_JSON__", playlist_json)
html = html.replace("__FAILURES_JSON__", failures_json)
return html
# -- CLI -----------------------------------------------------------------------
@click.command()
@click.argument("playlist", type=click.Path(exists=True))
@click.option(
"-o", "--output",
default=None,
type=click.Path(),
help="Output HTML file (default: playlist name + .html)",
)
@click.option("--resolve-only", is_flag=True, help="Just dump resolved JSON, no HTML")
@click.option("--no-open", "no_open", is_flag=True, help="Don't open in browser when done")
@click.option(
"--include-artist-pages", is_flag=True,
help="Resolve artist/label page URLs into all their albums",
)
def cli(playlist, output, resolve_only, no_open, include_artist_pages):
"""Build a keyboard-driven Bandcamp player from a markdown playlist.
PLAYLIST is a markdown file with bandcamp URLs or "Artist — Album" lines.
Also handles /track/ URLs (resolves parent album, plays only that track)
and artist/label pages (--include-artist-pages: resolves all albums).
"""
playlist_path = Path(playlist)
lines = playlist_path.read_text(encoding="utf-8").splitlines()
click.echo(f"Resolving {len([l for l in lines if l.strip()])} lines...")
albums, failures = asyncio.run(resolve_playlist(lines, include_artist_pages))
click.echo(f"\nResolved {len(albums)} albums, {len(failures)} failures")
if failures:
for f in failures:
click.echo(f" ✗ {f}")
if resolve_only:
click.echo(json.dumps(albums, indent=2, ensure_ascii=False))
return
if not albums:
raise click.ClickException("No albums resolved — nothing to play!")
total_tracks = sum(len(a["tracks"]) for a in albums)
click.echo(f"Total: {total_tracks} tracks across {len(albums)} albums")
html = build_html(albums, failures)
if output is None:
output = playlist_path.stem + "-player.html"
out_path = Path(output)
out_path.write_text(html, encoding="utf-8")
click.echo(f"→ {out_path}")
if failures:
fail_path = out_path.with_name(out_path.stem + "-notfound.txt")
fail_path.write_text("\n".join(failures) + "\n", encoding="utf-8")
click.echo(f"→ {fail_path} ({len(failures)} entries)")
if not no_open:
import webbrowser
webbrowser.open(f"file://{out_path.resolve()}")
if __name__ == "__main__":
cli()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment