Add BunnyCDN integration, on-air website badge, publish script fixes

- On-air toggle uploads status.json to BunnyCDN + purges cache, website
  polls it every 15s to show live ON AIR / OFF AIR badge
- Publish script downloads Castopod's copy of audio for CDN upload
  (byte-exact match), removes broken slug fallback, syncs all episode
  media to CDN after publishing
- Fix f-string syntax error in publish_episode.py (Python <3.12)
- Enable CORS on BunnyCDN pull zone for json files
- CDN URLs for website OG images, stem recorder bug fixes, LLM token
  budget tweaks, session context in CLAUDE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 17:34:18 -07:00
parent 7d88c76f90
commit 7b7f9b8208
11 changed files with 454 additions and 61 deletions

View File

@@ -15,6 +15,7 @@ import os
import re
import subprocess
import sys
import tempfile
import base64
from pathlib import Path
@@ -59,6 +60,11 @@ OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
WHISPER_MODEL = "base" # Options: tiny, base, small, medium, large
# NAS Configuration for chapters upload
# BunnyCDN Storage
BUNNY_STORAGE_ZONE = "lukeattheroost"
BUNNY_STORAGE_KEY = "92749cd3-85df-4cff-938fe35eb994-30f8-4cf2"
BUNNY_STORAGE_REGION = "la" # Los Angeles
NAS_HOST = "mmgnas-10g"
NAS_USER = "luke"
NAS_SSH_PORT = 8001
@@ -268,7 +274,7 @@ def save_chapters(metadata: dict, output_path: str):
print(f" Chapters saved to: {output_path}")
def run_ssh_command(command: str) -> tuple[bool, str]:
def run_ssh_command(command: str, timeout: int = 30) -> tuple[bool, str]:
"""Run a command on the NAS via SSH."""
ssh_cmd = [
"ssh", "-p", str(NAS_SSH_PORT),
@@ -276,7 +282,7 @@ def run_ssh_command(command: str) -> tuple[bool, str]:
command
]
try:
result = subprocess.run(ssh_cmd, capture_output=True, text=True, timeout=30)
result = subprocess.run(ssh_cmd, capture_output=True, text=True, timeout=timeout)
return result.returncode == 0, result.stdout.strip() or result.stderr.strip()
except subprocess.TimeoutExpired:
return False, "SSH command timed out"
@@ -341,6 +347,86 @@ def upload_chapters_to_castopod(episode_slug: str, episode_id: int, chapters_pat
return True
def upload_to_bunny(local_path: str, remote_path: str, content_type: str = None) -> bool:
"""Upload a file to BunnyCDN Storage."""
if not content_type:
ext = Path(local_path).suffix.lower()
content_type = {
".mp3": "audio/mpeg", ".png": "image/png", ".jpg": "image/jpeg",
".json": "application/json", ".srt": "application/x-subrip",
}.get(ext, "application/octet-stream")
url = f"https://{BUNNY_STORAGE_REGION}.storage.bunnycdn.com/{BUNNY_STORAGE_ZONE}/{remote_path}"
with open(local_path, "rb") as f:
resp = requests.put(url, data=f, headers={
"AccessKey": BUNNY_STORAGE_KEY,
"Content-Type": content_type,
})
if resp.status_code == 201:
return True
print(f" Warning: BunnyCDN upload failed ({resp.status_code}): {resp.text[:200]}")
return False
def download_from_castopod(file_key: str, local_path: str) -> bool:
"""Download a file from Castopod's container storage to local filesystem."""
remote_filename = Path(file_key).name
remote_tmp = f"/tmp/castopod_{remote_filename}"
cp_cmd = f'{DOCKER_PATH} cp {CASTOPOD_CONTAINER}:/var/www/castopod/public/media/{file_key} {remote_tmp}'
success, _ = run_ssh_command(cp_cmd, timeout=120)
if not success:
return False
scp_cmd = [
"scp", "-P", str(NAS_SSH_PORT),
f"{NAS_USER}@{NAS_HOST}:{remote_tmp}",
local_path
]
try:
result = subprocess.run(scp_cmd, capture_output=True, text=True, timeout=300)
ok = result.returncode == 0
except (subprocess.TimeoutExpired, Exception):
ok = False
run_ssh_command(f"rm -f {remote_tmp}")
return ok
def sync_episode_media_to_bunny(episode_id: int, already_uploaded: set):
"""Ensure all media linked to an episode exists on BunnyCDN."""
ep_id = episode_id
query = (
"SELECT DISTINCT m.file_key FROM cp_media m WHERE m.id IN ("
f"SELECT audio_id FROM cp_episodes WHERE id = {ep_id} "
f"UNION ALL SELECT cover_id FROM cp_episodes WHERE id = {ep_id} AND cover_id IS NOT NULL "
f"UNION ALL SELECT transcript_id FROM cp_episodes WHERE id = {ep_id} AND transcript_id IS NOT NULL "
f"UNION ALL SELECT chapters_id FROM cp_episodes WHERE id = {ep_id} AND chapters_id IS NOT NULL)"
)
cmd = f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} {DB_NAME} -N -e "{query};"'
success, output = run_ssh_command(cmd)
if not success or not output:
return
file_keys = [line.strip() for line in output.strip().split('\n') if line.strip()]
for file_key in file_keys:
if file_key in already_uploaded:
continue
cdn_url = f"https://cdn.lukeattheroost.com/media/{file_key}"
try:
resp = requests.head(cdn_url, timeout=10)
if resp.status_code == 200:
continue
except Exception:
pass
with tempfile.NamedTemporaryFile(suffix=Path(file_key).suffix, delete=False) as tmp:
tmp_path = tmp.name
try:
if download_from_castopod(file_key, tmp_path):
print(f" Syncing to CDN: {file_key}")
upload_to_bunny(tmp_path, f"media/{file_key}")
else:
print(f" Warning: Could not sync {file_key} to CDN")
finally:
Path(tmp_path).unlink(missing_ok=True)
def get_next_episode_number() -> int:
"""Get the next episode number from Castopod."""
headers = get_auth_header()
@@ -438,6 +524,39 @@ def main():
# Step 3: Create episode
episode = create_episode(str(audio_path), metadata, episode_number)
# Step 3.5: Upload to BunnyCDN
print("[3.5/5] Uploading to BunnyCDN...")
uploaded_keys = set()
# Audio: download Castopod's copy (ensures byte-exact match with RSS metadata)
ep_id = episode["id"]
audio_media_cmd = f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} {DB_NAME} -N -e "SELECT m.file_key FROM cp_media m JOIN cp_episodes e ON e.audio_id = m.id WHERE e.id = {ep_id};"'
success, audio_file_key = run_ssh_command(audio_media_cmd)
if success and audio_file_key:
audio_file_key = audio_file_key.strip()
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp:
tmp_audio = tmp.name
try:
print(f" Downloading from Castopod: {audio_file_key}")
if download_from_castopod(audio_file_key, tmp_audio):
print(f" Uploading audio to BunnyCDN")
upload_to_bunny(tmp_audio, f"media/{audio_file_key}", "audio/mpeg")
else:
print(f" Castopod download failed, uploading original file")
upload_to_bunny(str(audio_path), f"media/{audio_file_key}", "audio/mpeg")
finally:
Path(tmp_audio).unlink(missing_ok=True)
uploaded_keys.add(audio_file_key)
else:
print(f" Error: Could not determine audio file_key from Castopod DB")
print(f" Audio will be served from Castopod directly (not CDN)")
# Chapters
chapters_key = f"podcasts/{PODCAST_HANDLE}/{episode['slug']}-chapters.json"
print(f" Uploading chapters to BunnyCDN")
upload_to_bunny(str(chapters_path), f"media/{chapters_key}")
uploaded_keys.add(chapters_key)
# Step 4: Publish
episode = publish_episode(episode["id"])
@@ -448,6 +567,10 @@ def main():
str(chapters_path)
)
# Sync any remaining episode media to BunnyCDN (cover art, transcripts, etc.)
print(" Syncing episode media to CDN...")
sync_episode_media_to_bunny(episode["id"], uploaded_keys)
# Step 5: Summary
print("\n[5/5] Done!")
print("=" * 50)