#!/usr/bin/env python3
"""
Music build script.
For each *-music.md in this folder:
- Parse title, optional first-load lightbox, and track entries
- Two entry types detected by URL:
YouTube → youtube.com/watch?v=VIDEO_ID → video embed card
Audio → files.exopraxist.org/share/TOKEN → audio card + floating player
- Generate a self-contained HTML page
Then update music-hub.html subtitle with current totals.
Output filenames: strip '-music' suffix, lowercase the stem.
OldMusicandMVs-music.md → oldmusicandmvs.html
Run after adding or editing any *-music.md file.
"""
import re
from pathlib import Path
from html import escape
HERE = Path(__file__).parent
HUB = HERE / "music-hub.html"
ALLOW = (
"accelerometer; autoplay; clipboard-write; "
"encrypted-media; gyroscope; picture-in-picture; web-share"
)
# ── MD parsing ────────────────────────────────────────────────────────────────
def slug_from_path(path: Path) -> str:
stem = path.stem
stem = re.sub(r'-music$', '', stem, flags=re.IGNORECASE)
return stem.lower().replace('-', '').replace('_', '').replace(' ', '')
def parse_md(path: Path) -> dict:
text = path.read_text(encoding='utf-8')
m = re.search(r'^#\s+(.+)$', text, re.MULTILINE)
title = m.group(1).strip() if m else path.stem
m = re.search(r'>>on-first-load\s+-\s+lightbox\s+-\s+"([^"]+)"', text)
lightbox = m.group(1).strip() if m else None
entries = []
for m in re.finditer(
r'-\s+\*\*(.+?)\*\*\s*\n\s+-\s+URL:\s+(https://[^\s]+)',
text
):
name = m.group(1).strip()
# Strip trailing \- Source label from name (e.g. "Title \- YouTube")
name = re.sub(r'\s*\\-\s*.+$', '', name).strip()
url = m.group(2).strip()
if 'youtube.com/watch' in url:
vid = re.search(r'[?&]v=([A-Za-z0-9_-]+)', url)
if vid:
entries.append({'type': 'youtube', 'name': name,
'video_id': vid.group(1)})
elif 'files.exopraxist.org/share/' in url:
token = url.rstrip('/').split('/')[-1]
entries.append({'type': 'audio', 'name': name,
'share_url': url,
'direct_url': f"https://files.exopraxist.org/api/public/dl/{token}"})
return {'title': title, 'lightbox': lightbox, 'entries': entries}
# ── HTML blocks ───────────────────────────────────────────────────────────────
def build_lightbox_html(text: str, slug: str) -> tuple[str, str]:
if not text:
return '', ''
# Preserve — JL attribution if present at the end
body = text
attribution = ''
m = re.search(r'\s*-\s*JL\s*$', text)
if m:
body = text[:m.start()].strip()
attribution = '
— JL
'
safe_body = escape(body)
div = f"""
×
{safe_body}
{attribution}
CLICK ANYWHERE TO DISMISS
"""
js = f"""
const lightbox = document.getElementById('lightbox');
lightbox.style.display = 'flex';
const closeLightbox = () => {{ lightbox.style.display = 'none'; }};
lightbox.addEventListener('click', (e) => {{
if (e.target === lightbox) closeLightbox();
}});
document.querySelector('.lightbox-close').addEventListener('click', closeLightbox);
document.addEventListener('keydown', (e) => {{
if (e.key === 'Escape') closeLightbox();
}});
"""
return div, js
def build_video_card(entry: dict) -> str:
embed_url = f"https://www.youtube.com/embed/{entry['video_id']}"
title_esc = escape(entry['name'])
return f"""
"""
def build_audio_card(entry: dict, audio_index: int) -> str:
title_esc = escape(entry['name'])
direct_url = entry['direct_url']
share_url = entry['share_url']
return f"""
"""
def build_entries_html(entries: list) -> str:
parts = []
audio_idx = 1
for e in entries:
if e['type'] == 'youtube':
parts.append(build_video_card(e))
else:
parts.append(build_audio_card(e, audio_idx))
audio_idx += 1
return '\n'.join(parts)
def build_stagger_css(count: int) -> str:
lines = []
for i in range(1, min(count, 12) + 1):
lines.append(
f" .card:nth-child({i}) {{ animation-delay: {(i-1)*50}ms; }}"
)
return '\n'.join(lines)
# ── Full page generation ──────────────────────────────────────────────────────
def generate_html(data: dict, slug: str) -> str:
title = data['title']
entries = data['entries']
count = len(entries)
has_audio = any(e['type'] == 'audio' for e in entries)
title_upper = title.upper()
count_label = f"{count} TRACK{'S' if count != 1 else ''}"
lightbox_div, lightbox_js = build_lightbox_html(data['lightbox'], slug)
entries_html = build_entries_html(entries)
stagger_css = build_stagger_css(count)
lightbox_css = """
/* --- Lightbox --- */
#lightbox {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: rgba(4, 6, 11, 0.9);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 2rem;
cursor: pointer;
}
.lightbox-content {
background: #0c0a08;
border: 2px solid var(--mu-red);
border-top: 8px solid var(--mu-orange);
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
padding: 2.5rem;
position: relative;
cursor: default;
}
.lightbox-close {
position: absolute;
top: 0.5rem; right: 0.75rem;
font-size: 2rem;
color: var(--mu-orange);
cursor: pointer;
line-height: 1;
}
.lightbox-quote {
font-style: italic;
font-size: 1.1rem;
color: var(--text-warm);
margin-bottom: 1.5rem;
padding-right: 1.5rem;
}
.lightbox-attribution {
font-weight: 700;
text-transform: uppercase;
color: var(--mu-orange);
text-align: right;
}
.lightbox-hint {
display: block;
text-align: center;
margin-top: 2rem;
font-weight: 700;
text-transform: uppercase;
font-size: 0.65rem;
letter-spacing: 0.1em;
color: rgba(232, 213, 184, 0.4);
}
""" if data['lightbox'] else ''
audio_css = """
/* --- Audio Card --- */
.audio-card {
padding: 1.5rem;
}
.audio-controls {
display: flex;
align-items: center;
gap: 1.5rem;
}
.play-btn {
background: none;
border: 2px solid var(--mu-orange);
color: var(--mu-orange);
width: 48px;
height: 48px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
cursor: pointer;
transition: var(--transition);
font-family: inherit;
}
.play-btn:hover {
background: var(--mu-orange);
color: var(--bg-void);
}
.audio-info { flex-grow: 1; }
.download-link {
font-weight: 700;
text-transform: uppercase;
font-size: 0.75rem;
color: var(--mu-orange);
text-decoration: none;
display: inline-block;
margin-bottom: 0.5rem;
transition: var(--transition);
}
.download-link:hover { text-decoration: underline; }
.progress-container {
height: 4px;
background: var(--mu-red);
width: 100%;
cursor: pointer;
}
.progress-bar {
height: 100%;
background: var(--mu-orange);
width: 0%;
transition: width 100ms linear;
}
.duration-meta {
font-weight: 700;
text-transform: uppercase;
font-size: 0.7rem;
color: rgba(232, 213, 184, 0.5);
margin-top: 0.25rem;
display: flex;
justify-content: space-between;
}
/* --- Floating Player --- */
.floating-player {
position: fixed;
bottom: 0; left: 0;
width: 100%;
height: 64px;
background: var(--bg-void);
border-top: 2px solid var(--mu-red);
z-index: 500;
display: none;
align-items: center;
padding: 0 1rem;
gap: 1rem;
}
.floating-player.active { display: flex; }
.fp-info { flex: 1; min-width: 0; }
.fp-title {
font-family: 'Rancho', cursive;
font-size: 1.2rem;
color: var(--mu-orange);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fp-controls {
display: flex;
align-items: center;
gap: 1rem;
}
.fp-btn {
background: none;
border: none;
color: var(--mu-orange);
font-family: 'Rambla', sans-serif;
font-weight: 700;
font-size: 1rem;
cursor: pointer;
transition: var(--transition);
}
.fp-btn:hover { color: var(--text-warm); }
.fp-progress-wrap {
position: absolute;
top: -2px; left: 0;
width: 100%;
height: 2px;
background: var(--mu-red);
}
.fp-progress-fill {
height: 100%;
background: var(--mu-orange);
width: 0%;
transition: width 100ms linear;
}
""" if has_audio else ''
floating_player_html = """
""" if has_audio else ''
audio_js = """
// --- Audio Logic ---
const audio = document.getElementById('main-audio');
const floatingPlayer = document.querySelector('.floating-player');
const fpPlayBtn = document.getElementById('fp-play');
const fpTitle = document.querySelector('.fp-title');
const fpFill = document.querySelector('.fp-progress-fill');
let currentTrackBtn = null;
const formatTime = (t) => {
const m = Math.floor(t / 60);
const s = Math.floor(t % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
};
const updateUI = () => {
const playing = !audio.paused;
const icon = playing ? '▐▐' : '▶';
if (currentTrackBtn) currentTrackBtn.textContent = icon;
fpPlayBtn.textContent = icon;
if (playing) {
floatingPlayer.classList.add('active');
document.body.classList.add('player-active');
}
};
document.querySelectorAll('.play-btn').forEach(btn => {
btn.addEventListener('click', () => {
const src = btn.dataset.src;
const title = btn.dataset.title;
if (audio.getAttribute('data-loaded') === src) {
if (audio.paused) audio.play(); else audio.pause();
} else {
if (currentTrackBtn) currentTrackBtn.textContent = '▶';
audio.src = src;
audio.setAttribute('data-loaded', src);
fpTitle.textContent = title;
currentTrackBtn = btn;
audio.play();
}
});
});
fpPlayBtn.addEventListener('click', () => {
if (audio.paused) audio.play(); else audio.pause();
});
audio.addEventListener('play', updateUI);
audio.addEventListener('pause', updateUI);
audio.addEventListener('timeupdate', () => {
if (!audio.duration) return;
const pct = (audio.currentTime / audio.duration) * 100;
if (fpFill) fpFill.style.width = pct + '%';
if (currentTrackBtn) {
const card = currentTrackBtn.closest('.audio-card');
const bar = card.querySelector('.progress-bar');
const cur = card.querySelector('.current-time');
if (bar) bar.style.width = pct + '%';
if (cur) cur.textContent = formatTime(audio.currentTime);
}
});
audio.addEventListener('loadedmetadata', () => {
if (currentTrackBtn) {
const card = currentTrackBtn.closest('.audio-card');
const tot = card.querySelector('.total-time');
if (tot) tot.textContent = formatTime(audio.duration);
}
});
audio.addEventListener('ended', () => {
const allBtns = [...document.querySelectorAll('.play-btn')];
const idx = allBtns.indexOf(currentTrackBtn);
const next = allBtns[idx + 1];
if (next) {
next.click();
} else {
audio.pause();
if (currentTrackBtn) currentTrackBtn.textContent = '▶';
fpPlayBtn.textContent = '▶';
floatingPlayer.classList.remove('active');
document.body.classList.remove('player-active');
}
});
document.getElementById('fp-prev').addEventListener('click', () => {
const all = [...document.querySelectorAll('.play-btn')];
const prev = all[all.indexOf(currentTrackBtn) - 1];
if (prev) prev.click();
});
document.getElementById('fp-next').addEventListener('click', () => {
const all = [...document.querySelectorAll('.play-btn')];
const next = all[all.indexOf(currentTrackBtn) + 1];
if (next) next.click();
});
document.querySelectorAll('.progress-container').forEach(bar => {
bar.addEventListener('click', (e) => {
const rect = bar.getBoundingClientRect();
audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration;
});
});
""" if has_audio else ''
return f"""
{escape(title)} | MUSIC
{lightbox_div}
{escape(title.upper())}
{entries_html}
{floating_player_html}
"""
# ── Hub subtitle update ───────────────────────────────────────────────────────
def update_hub_subtitle(collection_count: int, entry_total: int):
hub_text = HUB.read_text(encoding='utf-8')
subtitle = (
f''
f'{collection_count} COLLECTION{"S" if collection_count != 1 else ""}'
f' · PERSONAL ARCHIVE'
f' · {entry_total} TRACK{"S" if entry_total != 1 else ""}'
f' '
)
hub_text = re.sub(
r'.*?',
f'{subtitle}',
hub_text,
flags=re.DOTALL
)
HUB.write_text(hub_text, encoding='utf-8')
# ── Main ──────────────────────────────────────────────────────────────────────
def main():
md_files = sorted(HERE.glob('*-music.md'))
if not md_files:
print("No *-music.md files found.")
return
total_entries = 0
for md_path in md_files:
slug = slug_from_path(md_path)
out = HERE / f"{slug}.html"
data = parse_md(md_path)
count = len(data['entries'])
if count == 0:
print(f" SKIP {md_path.name} — no entries found")
continue
audio_count = sum(1 for e in data['entries'] if e['type'] == 'audio')
video_count = count - audio_count
html = generate_html(data, slug)
out.write_text(html, encoding='utf-8')
total_entries += count
print(f" BUILD {out.name} — {data['title']} ({video_count} videos, {audio_count} audio)")
update_hub_subtitle(len(md_files), total_entries)
print(f"\nUpdated hub — {len(md_files)} collections, {total_entries} tracks total")
if __name__ == '__main__':
main()