All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
- Sidebar: dynamic brand-accent colors, brainstorm section restyled - AI chat general: popup panel with expand/collapse, hides when contextual AI open - AI chat contextual: tabs reordered (Actions first), X close button, height fix - Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.) - Global color cleanup: emerald/orange hardcoded → brand-accent dynamic - Brainstorm page: orange → brand-accent throughout - PageEntry animation component added to key pages - Floating AI button: bg-brand-accent instead of hardcoded black - i18n: all 15 locales updated with new AI/billing keys - Billing: freemium quota tracking, BYOK, stripe subscription scaffolding - Admin: integrated into new design - AGENTS.md + CLAUDE.md project rules added
215 lines
7.7 KiB
Python
215 lines
7.7 KiB
Python
#!/usr/bin/env python3
|
|
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = ["pyyaml>=6.0"]
|
|
# ///
|
|
"""Scaffold a per-band playlist YAML.
|
|
|
|
Each band in the project owns exactly one canonical
|
|
`docs/{band-slug}-playlist.yaml` that lists the tracks in their playlist
|
|
order with a name → audio-file mapping. This file is the authoritative
|
|
input to `playlist-sequencing-data.py` and the single source of truth for
|
|
sequencing decisions.
|
|
|
|
This script bootstraps that YAML for a band that doesn't yet have one. It
|
|
runs in two modes:
|
|
|
|
--empty (default)
|
|
Write a template with no tracks listed. The user fills in the order.
|
|
|
|
--from-songbook
|
|
Scan `docs/songbook/{band-slug}/` for published songbook entries and
|
|
pre-populate the tracks list with their titles. Audio file fields
|
|
are left as TODO comments — the user must fill in actual filenames
|
|
from `docs/audio/` because songbook frontmatter does not reliably
|
|
track the audio filename.
|
|
|
|
Usage:
|
|
scaffold-playlist.py <band-slug> [--from-songbook] [--project-root PATH]
|
|
|
|
Exit codes:
|
|
0 = playlist YAML written (or already existed and --force not passed)
|
|
1 = error (band-slug invalid, project root missing, etc.)
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
def _band_name_from_slug(slug: str) -> str:
|
|
"""Convert kebab-case slug to a Title-Cased album name as a default.
|
|
Users typically edit this after scaffolding."""
|
|
parts = slug.replace("_", "-").split("-")
|
|
return " ".join(p.capitalize() for p in parts if p)
|
|
|
|
|
|
def _extract_title_from_songbook(md_path: Path) -> str | None:
|
|
"""Read a songbook .md file's frontmatter and return its `title` field.
|
|
Returns None if the file lacks a frontmatter title."""
|
|
try:
|
|
with open(md_path, "r") as f:
|
|
content = f.read()
|
|
except OSError:
|
|
return None
|
|
if not content.startswith("---"):
|
|
return None
|
|
end = content.find("\n---", 3)
|
|
if end < 0:
|
|
return None
|
|
fm = content[3:end]
|
|
for line in fm.splitlines():
|
|
m = re.match(r'^\s*title\s*:\s*"?(.*?)"?\s*$', line)
|
|
if m:
|
|
return m.group(1).strip()
|
|
return None
|
|
|
|
|
|
def _is_published(md_path: Path) -> bool:
|
|
"""Heuristic: check frontmatter for `status: published` or similar."""
|
|
try:
|
|
with open(md_path, "r") as f:
|
|
content = f.read()
|
|
except OSError:
|
|
return False
|
|
if not content.startswith("---"):
|
|
return False
|
|
end = content.find("\n---", 3)
|
|
if end < 0:
|
|
return False
|
|
fm = content[3:end].lower()
|
|
return "status: published" in fm or "status: \"published\"" in fm
|
|
|
|
|
|
def discover_songbook_tracks(project_root: Path, band_slug: str) -> list[dict]:
|
|
"""Find published songbook entries for the band and return their titles."""
|
|
band_dir = project_root / "docs" / "songbook" / band_slug
|
|
if not band_dir.is_dir():
|
|
return []
|
|
tracks = []
|
|
for md_path in sorted(band_dir.glob("*.md")):
|
|
if not _is_published(md_path):
|
|
continue
|
|
title = _extract_title_from_songbook(md_path)
|
|
if title:
|
|
tracks.append({"name": title, "songbook_path": str(md_path.relative_to(project_root))})
|
|
return tracks
|
|
|
|
|
|
def render_playlist_yaml(album_name: str, tracks: list[dict], from_songbook: bool) -> str:
|
|
"""Render the playlist YAML content as a string."""
|
|
lines = []
|
|
lines.append(f"# Playlist order for {album_name} — authoritative source.")
|
|
lines.append("# This file is the SINGLE source of truth for the band's track sequence.")
|
|
lines.append("# Do NOT duplicate this list in other files (band profile YAML, ordering doc,")
|
|
lines.append("# voice context). Those files derive from or reference this YAML.")
|
|
lines.append("#")
|
|
lines.append("# When a song is published, add it to this file in the same write batch as")
|
|
lines.append("# the songbook entry. When the order changes, update this file first; the")
|
|
lines.append("# sequencing script's per-album companion .md is auto-refreshed from this.")
|
|
lines.append(f'album: "{album_name}"')
|
|
lines.append("tracks:")
|
|
if not tracks:
|
|
lines.append(" # Add tracks below as they are published. Each track needs:")
|
|
lines.append(' # - name: "<song title as it appears in the songbook>"')
|
|
lines.append(' # file: "<exact filename in docs/audio/, e.g. My Song.mp3>"')
|
|
lines.append(" # Order in this list = playlist order.")
|
|
else:
|
|
for t in tracks:
|
|
lines.append(f' - name: "{t["name"]}"')
|
|
if from_songbook:
|
|
# We discovered the song from songbook but don't know the audio filename.
|
|
# User must fill this in.
|
|
lines.append(" file: \"\" # TODO: set to the actual filename in docs/audio/")
|
|
if t.get("songbook_path"):
|
|
lines.append(f" # songbook: {t['songbook_path']}")
|
|
else:
|
|
lines.append(' file: ""')
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Scaffold a per-band playlist YAML at docs/{band-slug}-playlist.yaml.",
|
|
)
|
|
parser.add_argument(
|
|
"band_slug",
|
|
help="The band's filename slug (kebab-case). Matches the band profile filename: docs/band-profiles/{band-slug}.yaml.",
|
|
)
|
|
parser.add_argument(
|
|
"--from-songbook",
|
|
action="store_true",
|
|
help="Pre-populate tracks from existing songbook entries at docs/songbook/{band-slug}/.",
|
|
)
|
|
parser.add_argument(
|
|
"--project-root",
|
|
default=".",
|
|
help="Project root (default: current directory).",
|
|
)
|
|
parser.add_argument(
|
|
"--album-name",
|
|
help="Album/band name to use in the YAML (default: derived from slug).",
|
|
)
|
|
parser.add_argument(
|
|
"--force",
|
|
action="store_true",
|
|
help="Overwrite existing playlist YAML if present.",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
project_root = Path(args.project_root).resolve()
|
|
if not project_root.is_dir():
|
|
print(json.dumps({"status": "error", "message": f"Project root not found: {project_root}"}))
|
|
sys.exit(1)
|
|
|
|
slug = args.band_slug.strip()
|
|
if not re.match(r"^[a-z0-9][a-z0-9_-]*$", slug):
|
|
print(json.dumps({
|
|
"status": "error",
|
|
"message": (
|
|
f"Invalid band slug {slug!r}. Use lowercase kebab-case "
|
|
f"(letters, digits, hyphens, underscores; must start with letter/digit)."
|
|
),
|
|
}))
|
|
sys.exit(1)
|
|
|
|
target = project_root / "docs" / f"{slug}-playlist.yaml"
|
|
if target.exists() and not args.force:
|
|
print(json.dumps({
|
|
"status": "exists",
|
|
"message": f"Playlist YAML already exists at {target}. Use --force to overwrite.",
|
|
"path": str(target.relative_to(project_root)),
|
|
}))
|
|
sys.exit(0)
|
|
|
|
album_name = args.album_name or _band_name_from_slug(slug)
|
|
tracks: list[dict] = []
|
|
if args.from_songbook:
|
|
tracks = discover_songbook_tracks(project_root, slug)
|
|
|
|
body = render_playlist_yaml(album_name, tracks, from_songbook=args.from_songbook)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(target, "w") as f:
|
|
f.write(body)
|
|
|
|
print(json.dumps({
|
|
"status": "created" if not args.force else "overwritten",
|
|
"path": str(target.relative_to(project_root)),
|
|
"album": album_name,
|
|
"tracks_seeded": len(tracks),
|
|
"from_songbook": args.from_songbook,
|
|
"note": (
|
|
"Audio filenames left as empty strings — fill in from docs/audio/ before "
|
|
"running the sequencing script."
|
|
) if tracks else (
|
|
"Empty template written. Add tracks as you publish them."
|
|
),
|
|
}))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|