Last active
March 28, 2026 11:45
-
-
Save scztt/8d0d3b31c3c237ac72d21a06ec060211 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); | |
| } | |
| // --- 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