First commit. Again. Yo ho. Again

This commit is contained in:
2026-04-01 17:29:47 +02:00
commit 6271ea7576
8 changed files with 3671 additions and 0 deletions

119
README.md Normal file
View File

@@ -0,0 +1,119 @@
# ☠ PLAYLIST PIRATE
**Break free from the stream. Pay the artist. Own your music.**
---
## What is this?
Playlist Pirate is a free, open-source tool that takes your Spotify playlist exports and turns them into something actually useful: static HTML pages with embedded YouTube players, and optionally — MP3s on your own machine.
No subscription. No algorithm. No ads. No data harvest. Just your music, on your terms.
---
## Why does this exist?
Because Spotify is a bad deal. For everyone. But mostly for artists.
Here's the math: an independent artist earns somewhere between **$0.003 and $0.005 per stream** on Spotify. That means roughly **250500 streams to earn $1.00** — and if they're signed to a label, that label takes 8085% of even that, pushing the number closer to **1,5002,000 streams per dollar** that reaches the artist.
Put it this way: **you could hand an indie artist a dollar, download their album, listen to it 400 times, and still have treated them fairer than Spotify does.**
Meanwhile:
- **Bandcamp** gives artists ~85% of every sale. A $1 purchase puts ~$0.85 directly in the artist's pocket.
- **YouTube** pays less per view than Spotify does per stream (~$0.001$0.002), but at least you can link people directly to the artist's channel, which builds their audience.
- **Direct support** — buying from the artist's own site, Bandcamp, Patreon, a merch table — is the only model that actually works for working musicians.
Spotify's 2023 policy change made this worse: they raised the minimum stream threshold for royalty eligibility to **1,000 streams per year**, cutting the bottom tier of artists — the independent, the emerging, the weird and wonderful — out of any payment at all.
The streaming model concentrates money at the top. It always has. It's by design.
> **$1 direct to an artist = the same as ~250500 streams on Spotify for an indie act, or 1,5002,000+ streams if they're on a label deal.**
> *[Sources: Trichordist Streaming Price Bible; UK DCMS Committee 2021; Spotify's own disclosed rate ranges; RIAA data. Rates vary and shift — always look at current figures.]*
Playlist Pirate exists to help you walk away from that model. Keep your playlists. Ditch the platform.
---
## How it works
Four discrete, opt-in steps. Nothing runs automatically.
### Pipeline (CLI)
```
CSV → resolve → search → build → download
```
| Step | Command | What it does |
|------|---------|--------------|
| **resolve** | `playlist.py resolve *.csv` | Parses your Spotify CSV exports into `*-playlist.md` tracking files. Captures track title, artists, ISRC, Spotify ID. |
| **search** | `playlist.py search *-playlist.md` | Uses `yt-dlp` to find YouTube URLs for each track. No API key. Resumable — re-run it and it picks up where it left off. ~37 seconds per track. |
| **build** | `playlist.py build *-playlist.md --out <dir>` | Hits MusicBrainz (no API key, polite 1 req/sec) to fetch recording data and artist URLs — homepage, Bandcamp, SoundCloud, Patreon, in that priority order. Generates static HTML pages with embedded YouTube players, fire-spectrum colors, and links out to the artist directly. |
| **download** | `playlist.py download *-playlist.md --output <dir>` | Downloads MP3s via `yt-dlp` + `ffmpeg`. Embeds ID3 tags via `mutagen`. Marks tracks done in the `.md` file so you never double-download. |
### GUI
There's a browser-based GUI (`gui.py`) wrapping the same pipeline via Flask. It runs locally, opens in your browser, and gives you accordion-style controls for each step, a live log panel, and per-track download selection. There's also a PyInstaller-built single binary (`dist/playlist-gui`) for running without Python.
### Tracking file format
```
# Playlist Name
<!-- source: filename.csv | updated: 2026-... -->
- [ ] Track Title | Artist Name | ISRC:XXXXXX | SP:SpotifyID | https://youtu.be/XXXXX
- [x] Downloaded Track | Artist | ISRC:... | SP:... | https://...
- [-] Not Found | Artist | ISRC:- | SP:- | NOT_FOUND
```
`[ ]` = pending · `[x]` = done · `[-]` = not found on YouTube
---
## Dependencies
```
pip install yt-dlp rich mutagen
```
- **[yt-dlp](https://github.com/yt-dlp/yt-dlp)** — does the heavy lifting. YouTube search and download. No API key required.
- **[rich](https://github.com/Textualize/rich)** — terminal output that doesn't look like 1993.
- **[mutagen](https://github.com/quodlibet/mutagen)** — ID3 tag writing.
- **[MusicBrainz](https://musicbrainz.org/)** — open music encyclopedia. ISRC lookup → artist URLs. Free, no key, rate-limited politely.
- **ffmpeg** — for MP3 conversion (system install, not a Python package).
### The yt-dlp caveat
This tool depends on `yt-dlp`. If `yt-dlp` stops working — because YouTube changes its API, or because a court somewhere decides the sky is the ceiling — **this tool breaks in the download and search steps**. The build step (HTML generation) and resolve step (CSV parsing) are unaffected.
`yt-dlp` is a community-maintained FOSS project. Keep it updated. And if it ever goes away, something else will take its place. It always does.
---
## Ethos
Everything this tool does uses **publicly available tools and data, accessed respectfully**:
- YouTube search via `yt-dlp` with polite delays
- MusicBrainz at 1 request per second (their documented rate limit)
- No scraping. No API key abuse. No spoofing.
- No data sent anywhere except to the services being queried
- The generated HTML includes **links back to artists' own sites** — Bandcamp, SoundCloud, Patreon, their homepage. Backlinks improve search engine rankings. A link from a real page to an artist's real site is a small act of support that compounds over time.
This is not a tool for stealing from artists. It is a tool for **owning your own library** and **finding the artists you love** so you can support them directly.
---
## License
FOSS. Use it, fork it, improve it. If you make money off it, buy an artist's album.
---
## Find the artists. Pay the artists. Own your music.
> Bandcamp: [bandcamp.com](https://bandcamp.com)
> MusicBrainz: [musicbrainz.org](https://musicbrainz.org)
> yt-dlp: [github.com/yt-dlp/yt-dlp](https://github.com/yt-dlp/yt-dlp)

577
_playlist-template.html Normal file
View File

@@ -0,0 +1,577 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Eclectica Experimenti | PLAYLISTS</title>
<link href="https://fonts.googleapis.com/css2?family=Ribeye+Marrow&family=Rambla:wght@400;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<style>
: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;
}
/* Sticky Header */
header {
position: sticky;
top: 0;
z-index: 100;
background-color: 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;
margin: 0;
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);
}
/* Mobile Menu */
.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;
}
.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;
}
.menu-toggle {
display: block;
}
header h1 {
font-size: 1.5rem;
}
}
/* Track List */
.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);
background: transparent;
margin-bottom: 0.5rem;
transition: background 100ms ease, border-color 100ms ease;
position: relative;
}
.track-card:hover {
background: var(--ff-glow);
border-color: var(--ff-primary);
}
.track-card::after {
content: '';
position: absolute;
bottom: -0.25rem;
left: 0;
right: 0;
height: 1px;
background-color: var(--ff-deep);
opacity: 0.1;
}
.track-main-row {
display: flex;
padding: 1.5rem;
gap: 1.5rem;
align-items: flex-start;
}
/* Zones */
.zone-meta {
width: 80px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.75rem;
}
.track-num {
font-family: 'Share Tech Mono', monospace;
color: var(--text-muted);
font-size: 1.1rem;
}
.album-art {
width: 64px;
height: 64px;
background-color: #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 */
.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[data-available="false"] {
color: var(--text-muted);
cursor: not-allowed;
pointer-events: none;
}
/* Embed Area */
.embed-container {
display: none;
width: 100%;
background: var(--ff-glow);
border-top: 1px solid var(--ff-deep);
padding: 1rem;
}
.video-wrapper {
position: relative;
padding-bottom: 56.25%; /* 16:9 */
height: 0;
overflow: hidden;
}
.video-wrapper iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
}
.track-card.active {
border-color: var(--ff-primary);
}
.track-card.active .embed-container {
display: block;
}
@media (max-width: 480px) {
.track-main-row {
padding: 1rem;
gap: 1rem;
}
.zone-meta {
width: 64px;
}
.album-art {
width: 48px;
height: 48px;
}
}
</style>
</head>
<body>
<header>
<div class="header-main">
<h1>Eclectica Experimenti</h1>
<span class="header-meta">92 TRACKS &bull; 6:42:15</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">
<!-- Track 1: Full Embed -->
<article class="track-card">
<div class="track-main-row">
<div class="zone-meta">
<span class="track-num">01</span>
<img class="album-art"
src=""
data-src="https://i.scdn.co/image/ab67616d0000b273b40049962a93144df36ca024"
alt="Album Art"
loading="lazy">
</div>
<div class="zone-info">
<div class="track-name">Midnight City</div>
<div class="artist-name">M83</div>
<div class="album-meta">Hurry Up, We're Dreaming &bull; 2011</div>
<div class="links-row">
<a href="https://open.spotify.com/track/1eyzqe2QqGZUmfc2NWm1vI" class="track-link" target="_blank">[SPOTIFY]</a>
<a href="https://musicbrainz.org/recording/86c62c93-9c8e-4903-8874-9b5753909796" class="track-link" target="_blank">[MUSICBRAINZ]</a>
<button class="track-link youtube-toggle" data-video-id="dX3k_LHnn28">[YOUTUBE]</button>
<a href="https://ilovem83.com" class="track-link" target="_blank">[ARTIST]</a>
</div>
</div>
</div>
<div class="embed-container">
<div class="video-wrapper">
<iframe src=""
data-src="https://www.youtube.com/embed/dX3k_LHnn28"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen></iframe>
</div>
</div>
</article>
<!-- Track 2: No Artist Link -->
<article class="track-card">
<div class="track-main-row">
<div class="zone-meta">
<span class="track-num">02</span>
<img class="album-art"
src=""
data-src="https://i.scdn.co/image/ab67616d0000b2737604f56f34582f3a479a4055"
alt="Album Art"
loading="lazy">
</div>
<div class="zone-info">
<div class="track-name">Nightcall</div>
<div class="artist-name">Kavinsky</div>
<div class="album-meta">OutRun &bull; 2013</div>
<div class="links-row">
<a href="#" class="track-link" target="_blank">[SPOTIFY]</a>
<a href="#" class="track-link" target="_blank">[MUSICBRAINZ]</a>
<button class="track-link youtube-toggle" data-video-id="MV_3Dpw-BRY">[YOUTUBE]</button>
<span class="track-link" data-available="false">[ARTIST]</span>
</div>
</div>
</div>
<div class="embed-container">
<div class="video-wrapper">
<iframe src=""
data-src="https://www.youtube.com/embed/MV_3Dpw-BRY"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen></iframe>
</div>
</div>
</article>
<!-- Track 3: YouTube Search only -->
<article class="track-card">
<div class="track-main-row">
<div class="zone-meta">
<span class="track-num">03</span>
<img class="album-art"
src=""
data-src="https://i.scdn.co/image/ab67616d0000b273413554e19576189b884d5098"
alt="Album Art"
loading="lazy">
</div>
<div class="zone-info">
<div class="track-name">Genesis</div>
<div class="artist-name">Grimes</div>
<div class="album-meta">Visions &bull; 2012</div>
<div class="links-row">
<a href="#" class="track-link" target="_blank">[SPOTIFY]</a>
<a href="#" class="track-link" target="_blank">[MUSICBRAINZ]</a>
<a href="https://www.youtube.com/results?search_query=Grimes+Genesis" class="track-link" target="_blank">[YOUTUBE]</a>
<a href="#" class="track-link" target="_blank">[ARTIST]</a>
</div>
</div>
</div>
</article>
<!-- Track 4: Another example -->
<article class="track-card">
<div class="track-main-row">
<div class="zone-meta">
<span class="track-num">04</span>
<img class="album-art"
src=""
data-src="https://i.scdn.co/image/ab67616d0000b2731885440620ef0d18d45543c8"
alt="Album Art"
loading="lazy">
</div>
<div class="zone-info">
<div class="track-name">Loro</div>
<div class="artist-name">Pinback</div>
<div class="album-meta">Pinback &bull; 1999</div>
<div class="links-row">
<a href="#" class="track-link" target="_blank">[SPOTIFY]</a>
<a href="#" class="track-link" target="_blank">[MUSICBRAINZ]</a>
<button class="track-link youtube-toggle" data-video-id="46_l1n6_q60">[YOUTUBE]</button>
<a href="#" class="track-link" target="_blank">[ARTIST]</a>
</div>
</div>
</div>
<div class="embed-container">
<div class="video-wrapper">
<iframe src=""
data-src="https://www.youtube.com/embed/46_l1n6_q60"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen></iframe>
</div>
</div>
</article>
<!-- Track 5: Deep vibes -->
<article class="track-card">
<div class="track-main-row">
<div class="zone-meta">
<span class="track-num">05</span>
<img class="album-art"
src=""
data-src="https://i.scdn.co/image/ab67616d0000b273fc61f1c2b535d8da951a808f"
alt="Album Art"
loading="lazy">
</div>
<div class="zone-info">
<div class="track-name">Small Memory</div>
<div class="artist-name">Jon Hopkins</div>
<div class="album-meta">Insides &bull; 2009</div>
<div class="links-row">
<a href="#" class="track-link" target="_blank">[SPOTIFY]</a>
<a href="#" class="track-link" target="_blank">[MUSICBRAINZ]</a>
<button class="track-link youtube-toggle" data-video-id="xSls68pX-94">[YOUTUBE]</button>
<a href="#" class="track-link" target="_blank">[ARTIST]</a>
</div>
</div>
</div>
<div class="embed-container">
<div class="video-wrapper">
<iframe src=""
data-src="https://www.youtube.com/embed/xSls68pX-94"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen></iframe>
</div>
</div>
</article>
<!-- Track 6: Classics -->
<article class="track-card">
<div class="track-main-row">
<div class="zone-meta">
<span class="track-num">06</span>
<img class="album-art"
src=""
data-src="https://i.scdn.co/image/ab67616d0000b27376696706e57f12a233604f37"
alt="Album Art"
loading="lazy">
</div>
<div class="zone-info">
<div class="track-name">Teardrop</div>
<div class="artist-name">Massive Attack</div>
<div class="album-meta">Mezzanine &bull; 1998</div>
<div class="links-row">
<a href="#" class="track-link" target="_blank">[SPOTIFY]</a>
<a href="#" class="track-link" target="_blank">[MUSICBRAINZ]</a>
<button class="track-link youtube-toggle" data-video-id="u7K72X4eo_s">[YOUTUBE]</button>
<a href="#" class="track-link" target="_blank">[ARTIST]</a>
</div>
</div>
</div>
<div class="embed-container">
<div class="video-wrapper">
<iframe src=""
data-src="https://www.youtube.com/embed/u7K72X4eo_s"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen></iframe>
</div>
</div>
</article>
</main>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Mobile menu toggle
const menuToggle = document.getElementById('menuToggle');
const navLinks = document.getElementById('navLinks');
menuToggle.addEventListener('click', () => {
navLinks.classList.toggle('open');
menuToggle.textContent = navLinks.classList.contains('open') ? '✕' : '☰';
});
// Intersection Observer for lazy loading images
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img);
}
});
}, { rootMargin: '300px' });
document.querySelectorAll('.album-art').forEach(img => {
imageObserver.observe(img);
});
// YouTube Toggle and Lazy Loading
const youtubeToggles = document.querySelectorAll('.youtube-toggle');
const videoObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
const card = entry.target;
const iframe = card.querySelector('iframe');
if (entry.isIntersecting && card.classList.contains('active')) {
if (iframe && !iframe.src) {
iframe.src = iframe.dataset.src;
}
}
});
}, { rootMargin: '200px' });
youtubeToggles.forEach(toggle => {
toggle.addEventListener('click', () => {
const card = toggle.closest('.track-card');
const iframe = card.querySelector('iframe');
card.classList.toggle('active');
// If opening, check if we need to load the iframe
if (card.classList.contains('active')) {
videoObserver.observe(card);
// Force check immediate visibility
if (iframe && !iframe.src) {
iframe.src = iframe.dataset.src;
}
}
});
});
});
</script>
</body>
</html>

840
build.py Normal file
View 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("&", "&amp;").replace("<", "&lt;")
.replace(">", "&gt;").replace('"', "&quot;"))
# ─── 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">&#8592; Space</a>
<header>
<h1>PLAYLISTS</h1>
<p class="subtitle">{n} playlists &bull; {total:,} tracks</p>
</header>
<main class="playlist-grid">
{cards}
</main>
<footer>&mdash; SINGULAR PARTICULAR SPACE &mdash;</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} &bull; {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 &bull; {total_time}</span>
</div>
<button class="menu-toggle" id="menuToggle">&#9776;</button>
<nav class="nav-links" id="navLinks">
<a href="playlists.html" class="back-to-hub">&#8592; 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>
&bull; Links via <a href="https://song.link" target="_blank" rel="noopener">Odesli</a>
&bull; &mdash; SINGULAR PARTICULAR SPACE &mdash;
</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()

43
playlistpirate/build.sh Normal file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Build standalone playlist binary with PyInstaller
# Run from the playlist/ directory
pip install pyinstaller yt-dlp rich mutagen
pyinstaller \
--onefile \
--name playlist \
--hidden-import yt_dlp \
--hidden-import mutagen.id3 \
--hidden-import mutagen.mp3 \
playlist.py
echo ""
echo "Binary at: dist/playlist"
echo "Usage:"
echo " ./dist/playlist resolve my-export.csv"
echo " ./dist/playlist search my-export-playlist.md"
echo " ./dist/playlist download my-export-playlist.md"
# GUI binary (Flask + system browser, no Qt/pywebview dependency)
pip install flask
pyinstaller \
--onefile \
--name playlist-gui \
--collect-all yt_dlp \
--hidden-import mutagen.id3 \
--hidden-import mutagen.mp3 \
--hidden-import mutagen.easyid3 \
--hidden-import flask \
--hidden-import rich \
--hidden-import rich.console \
--hidden-import rich.theme \
--hidden-import tkinter \
--hidden-import tkinter.filedialog \
gui.py
echo "GUI binary at: dist/playlist-gui"
echo "Usage: ./dist/playlist-gui"
echo " Opens in your default browser at http://localhost:<PORT>"
echo " Ctrl-C to quit."

1019
playlistpirate/gui.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['gui.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[
'mutagen.id3', 'mutagen.mp3', 'mutagen.easyid3',
'flask', 'werkzeug', 'jinja2', 'click', 'itsdangerous', 'markupsafe',
'rich', 'rich.console', 'rich.theme',
'tkinter', 'tkinter.filedialog',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
collect_all=['yt_dlp'],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='playlist-gui',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)

1027
playlistpirate/playlist.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
yt-dlp>=2024.1.0
rich>=13.0.0
mutagen>=1.47.0