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

@@ -42,3 +42,24 @@ Required in `.env`:
- OPENROUTER_API_KEY - OPENROUTER_API_KEY
- ELEVENLABS_API_KEY (optional) - ELEVENLABS_API_KEY (optional)
- INWORLD_API_KEY (for Inworld TTS) - INWORLD_API_KEY (for Inworld TTS)
## Post-Production Pipeline (added Feb 2026)
- **Branch**: `feature/real-callers` — all current work is here, pushed to gitea
- **Stem Recorder** (`backend/services/stem_recorder.py`): Records 5 WAV stems (host, caller, music, sfx, ads) during live shows. Uses lock-free deque architecture — audio callbacks just append to deques, a background writer thread drains to disk. `write()` for continuous streams (host mic, music, ads), `write_sporadic()` for burst sources (caller TTS, SFX) with time-aligned silence padding.
- **Audio hooks** in `backend/services/audio.py`: 7 tap points guarded by `if self.stem_recorder:`. Persistent mic stream (`start_stem_mic`/`stop_stem_mic`) runs during recording to capture host voice continuously, not just during push-to-talk.
- **API endpoints**: `POST /api/recording/start`, `POST /api/recording/stop` (auto-runs postprod in background thread), `POST /api/recording/process`
- **Frontend**: REC button in header with red pulse animation when recording
- **Post-prod script** (`postprod.py`): 6-step pipeline — load stems → gap removal → voice compression (ffmpeg acompressor) → music ducking → stereo mix → EBU R128 loudness normalization to -16 LUFS. All steps skippable via CLI flags.
- **Known issues resolved**: Lock-free recorder (old version used threading.Lock in audio callbacks causing crashes), scipy.signal.resample replaced with nearest-neighbor (was producing artifacts on small chunks), sys import bug in auto-postprod, host mic not captured without persistent stream
## LLM Settings
- `_pick_response_budget()` in main.py controls caller dialog token limits (150-450 tokens). MiniMax respects limits strictly — if responses seem short, check these values.
- Default max_tokens in llm.py is 300 (for non-caller uses)
- Grok (`x-ai/grok-4-fast`) works well for natural dialog; MiniMax tends toward terse responses
## Website
- **Domain**: lukeattheroost.com (behind Cloudflare)
- **Analytics**: Cloudflare Web Analytics (enable in Cloudflare dashboard, no code changes needed)
## Episodes Published
- Episode 6 published 2026-02-08 (podcast6.mp3, ~31 min)

View File

@@ -2258,6 +2258,7 @@ def _build_news_context() -> tuple[str, str]:
async def startup(): async def startup():
"""Pre-generate caller backgrounds on server start""" """Pre-generate caller backgrounds on server start"""
asyncio.create_task(_pregenerate_backgrounds()) asyncio.create_task(_pregenerate_backgrounds())
threading.Thread(target=_update_on_air_cdn, args=(False,), daemon=True).start()
@app.on_event("shutdown") @app.on_event("shutdown")
@@ -2265,6 +2266,7 @@ async def shutdown():
"""Clean up resources on server shutdown""" """Clean up resources on server shutdown"""
global _host_audio_task global _host_audio_task
print("[Server] Shutting down — cleaning up resources...") print("[Server] Shutting down — cleaning up resources...")
_update_on_air_cdn(False)
# Stop host mic streaming # Stop host mic streaming
audio_service.stop_host_stream() audio_service.stop_host_stream()
# Cancel host audio sender task # Cancel host audio sender task
@@ -2296,12 +2298,48 @@ async def index():
# --- On-Air Toggle --- # --- On-Air Toggle ---
# BunnyCDN config for public on-air status
_BUNNY_STORAGE_ZONE = "lukeattheroost"
_BUNNY_STORAGE_KEY = "92749cd3-85df-4cff-938fe35eb994-30f8-4cf2"
_BUNNY_STORAGE_REGION = "la"
_BUNNY_ACCOUNT_KEY = "2865f279-297b-431a-ad18-0ccf1f8e4fa8cf636cea-3222-415a-84ed-56ee195c0530"
def _update_on_air_cdn(on_air: bool):
"""Upload on-air status to BunnyCDN so the public website can poll it."""
from datetime import datetime, timezone
data = {"on_air": on_air}
if on_air:
data["since"] = datetime.now(timezone.utc).isoformat()
url = f"https://{_BUNNY_STORAGE_REGION}.storage.bunnycdn.com/{_BUNNY_STORAGE_ZONE}/status.json"
try:
resp = httpx.put(url, content=json.dumps(data), headers={
"AccessKey": _BUNNY_STORAGE_KEY,
"Content-Type": "application/json",
}, timeout=5)
if resp.status_code == 201:
print(f"[CDN] On-air status updated: {on_air}")
else:
print(f"[CDN] Failed to update on-air status: {resp.status_code}")
return
httpx.get(
"https://api.bunny.net/purge",
params={"url": "https://cdn.lukeattheroost.com/status.json", "async": "false"},
headers={"AccessKey": _BUNNY_ACCOUNT_KEY},
timeout=10,
)
print(f"[CDN] Cache purged")
except Exception as e:
print(f"[CDN] Error updating on-air status: {e}")
@app.post("/api/on-air") @app.post("/api/on-air")
async def set_on_air(state: dict): async def set_on_air(state: dict):
"""Toggle whether the show is on air (accepting phone calls)""" """Toggle whether the show is on air (accepting phone calls)"""
global _show_on_air global _show_on_air
_show_on_air = bool(state.get("on_air", False)) _show_on_air = bool(state.get("on_air", False))
print(f"[Show] On-air: {_show_on_air}") print(f"[Show] On-air: {_show_on_air}")
threading.Thread(target=_update_on_air_cdn, args=(_show_on_air,), daemon=True).start()
return {"on_air": _show_on_air} return {"on_air": _show_on_air}
@app.get("/api/on-air") @app.get("/api/on-air")
@@ -2627,13 +2665,13 @@ def _pick_response_budget() -> tuple[int, int]:
Keeps responses conversational but gives room for real answers.""" Keeps responses conversational but gives room for real answers."""
roll = random.random() roll = random.random()
if roll < 0.20: if roll < 0.20:
return 80, 2 # 20% — short and direct return 150, 2 # 20% — short and direct
elif roll < 0.55: elif roll < 0.55:
return 120, 3 # 35% — normal conversation return 250, 3 # 35% — normal conversation
elif roll < 0.80: elif roll < 0.80:
return 150, 4 # 25% — explaining something return 350, 4 # 25% — explaining something
else: else:
return 200, 5 # 20% — telling a story or going deep return 450, 5 # 20% — telling a story or going deep
def _trim_to_sentences(text: str, max_sentences: int) -> str: def _trim_to_sentences(text: str, max_sentences: int) -> str:
@@ -3862,6 +3900,7 @@ async def start_stem_recording():
recorder = StemRecorder(recordings_dir, sample_rate=sr) recorder = StemRecorder(recordings_dir, sample_rate=sr)
recorder.start() recorder.start()
audio_service.stem_recorder = recorder audio_service.stem_recorder = recorder
audio_service.start_stem_mic()
add_log(f"Stem recording started -> {recordings_dir}") add_log(f"Stem recording started -> {recordings_dir}")
return {"status": "recording", "dir": str(recordings_dir)} return {"status": "recording", "dir": str(recordings_dir)}
@@ -3870,10 +3909,31 @@ async def start_stem_recording():
async def stop_stem_recording(): async def stop_stem_recording():
if audio_service.stem_recorder is None: if audio_service.stem_recorder is None:
raise HTTPException(400, "No recording in progress") raise HTTPException(400, "No recording in progress")
audio_service.stop_stem_mic()
stems_dir = audio_service.stem_recorder.output_dir
paths = audio_service.stem_recorder.stop() paths = audio_service.stem_recorder.stop()
audio_service.stem_recorder = None audio_service.stem_recorder = None
add_log(f"Stem recording stopped. Files: {list(paths.keys())}") add_log(f"Stem recording stopped. Running post-production...")
return {"status": "stopped", "stems": paths}
# Auto-run postprod in background
import subprocess, sys
python = sys.executable
output_file = stems_dir / "episode.mp3"
def _run_postprod():
try:
result = subprocess.run(
[python, "postprod.py", str(stems_dir), "-o", str(output_file)],
capture_output=True, text=True, timeout=300,
)
if result.returncode == 0:
add_log(f"Post-production complete -> {output_file}")
else:
add_log(f"Post-production failed: {result.stderr[:300]}")
except Exception as e:
add_log(f"Post-production error: {e}")
threading.Thread(target=_run_postprod, daemon=True).start()
return {"status": "stopped", "stems": paths, "processing": str(output_file)}
@app.post("/api/recording/process") @app.post("/api/recording/process")

View File

@@ -80,6 +80,7 @@ class AudioService:
# Stem recording (opt-in, attached via API) # Stem recording (opt-in, attached via API)
self.stem_recorder = None self.stem_recorder = None
self._stem_mic_stream: Optional[sd.InputStream] = None
# Load saved settings # Load saved settings
self._load_settings() self._load_settings()
@@ -282,6 +283,8 @@ class AudioService:
stream_ready.set() stream_ready.set()
if self._recording: if self._recording:
self._recorded_audio.append(indata[:, record_channel].copy()) self._recorded_audio.append(indata[:, record_channel].copy())
if self.stem_recorder:
self.stem_recorder.write("host", indata[:, record_channel].copy(), device_sr)
print(f"Recording: opening stream on device {self.input_device} ch {self.input_channel} @ {device_sr}Hz ({max_channels} ch)") print(f"Recording: opening stream on device {self.input_device} ch {self.input_channel} @ {device_sr}Hz ({max_channels} ch)")
@@ -360,7 +363,7 @@ class AudioService:
# Stem recording: caller TTS # Stem recording: caller TTS
if self.stem_recorder: if self.stem_recorder:
self.stem_recorder.write("caller", audio.copy(), device_sr) self.stem_recorder.write_sporadic("caller", audio.copy(), device_sr)
# Create multi-channel output with audio only on target channel # Create multi-channel output with audio only on target channel
multi_ch = np.zeros((len(audio), num_channels), dtype=np.float32) multi_ch = np.zeros((len(audio), num_channels), dtype=np.float32)
@@ -500,7 +503,7 @@ class AudioService:
# Stem recording: live caller # Stem recording: live caller
if self.stem_recorder: if self.stem_recorder:
self.stem_recorder.write("caller", audio.copy(), device_sr) self.stem_recorder.write_sporadic("caller", audio.copy(), device_sr)
if self._live_caller_write: if self._live_caller_write:
self._live_caller_write(audio) self._live_caller_write(audio)
@@ -930,7 +933,7 @@ class AudioService:
# Stem recording: sfx # Stem recording: sfx
if self.stem_recorder: if self.stem_recorder:
self.stem_recorder.write("sfx", audio.copy(), device_sr) self.stem_recorder.write_sporadic("sfx", audio.copy(), device_sr)
multi_ch = np.zeros((len(audio), num_channels), dtype=np.float32) multi_ch = np.zeros((len(audio), num_channels), dtype=np.float32)
multi_ch[:, channel_idx] = audio multi_ch[:, channel_idx] = audio
@@ -950,6 +953,45 @@ class AudioService:
except Exception as e: except Exception as e:
print(f"SFX playback error: {e}") print(f"SFX playback error: {e}")
# --- Stem Mic Capture ---
def start_stem_mic(self):
"""Start a persistent mic capture stream for stem recording.
Runs independently of push-to-talk and host streaming."""
if self._stem_mic_stream is not None:
return
if self.input_device is None:
print("[StemRecorder] No input device configured, skipping host mic capture")
return
device_info = sd.query_devices(self.input_device)
max_channels = device_info['max_input_channels']
device_sr = int(device_info['default_samplerate'])
record_channel = min(self.input_channel, max_channels) - 1
def callback(indata, frames, time_info, status):
if self.stem_recorder:
self.stem_recorder.write("host", indata[:, record_channel].copy(), device_sr)
self._stem_mic_stream = sd.InputStream(
device=self.input_device,
channels=max_channels,
samplerate=device_sr,
dtype=np.float32,
blocksize=1024,
callback=callback,
)
self._stem_mic_stream.start()
print(f"[StemRecorder] Host mic capture started (device {self.input_device} ch {self.input_channel} @ {device_sr}Hz)")
def stop_stem_mic(self):
"""Stop the persistent stem mic capture."""
if self._stem_mic_stream:
self._stem_mic_stream.stop()
self._stem_mic_stream.close()
self._stem_mic_stream = None
print("[StemRecorder] Host mic capture stopped")
# Global instance # Global instance
audio_service = AudioService() audio_service = AudioService()

View File

@@ -154,7 +154,7 @@ class LLMService:
json={ json={
"model": model, "model": model,
"messages": messages, "messages": messages,
"max_tokens": max_tokens or 150, "max_tokens": max_tokens or 300,
"temperature": 0.8, "temperature": 0.8,
"top_p": 0.92, "top_p": 0.92,
"frequency_penalty": 0.5, "frequency_penalty": 0.5,

View File

@@ -1,10 +1,11 @@
"""Records separate audio stems during a live show for post-production""" """Records separate audio stems during a live show for post-production"""
import time import time
import threading
import numpy as np import numpy as np
import soundfile as sf import soundfile as sf
from pathlib import Path from pathlib import Path
from scipy import signal as scipy_signal from collections import deque
STEM_NAMES = ["host", "caller", "music", "sfx", "ads"] STEM_NAMES = ["host", "caller", "music", "sfx", "ads"]
@@ -14,73 +15,104 @@ class StemRecorder:
self.output_dir = Path(output_dir) self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True) self.output_dir.mkdir(parents=True, exist_ok=True)
self.sample_rate = sample_rate self.sample_rate = sample_rate
self._files: dict[str, sf.SoundFile] = {}
self._write_positions: dict[str, int] = {}
self._start_time: float = 0.0
self._running = False self._running = False
self._queues: dict[str, deque] = {}
self._writer_thread: threading.Thread | None = None
self._start_time: float = 0.0
def start(self): def start(self):
self._start_time = time.time() self._start_time = time.time()
self._running = True self._running = True
for name in STEM_NAMES:
self._queues[name] = deque()
self._writer_thread = threading.Thread(target=self._writer_loop, daemon=True)
self._writer_thread.start()
print(f"[StemRecorder] Recording started -> {self.output_dir}")
def write(self, stem_name: str, audio_data: np.ndarray, source_sr: int):
"""Non-blocking write for continuous streams (host mic, music, ads).
Safe to call from audio callbacks."""
if not self._running or stem_name not in self._queues:
return
self._queues[stem_name].append(("audio", audio_data.copy(), source_sr))
def write_sporadic(self, stem_name: str, audio_data: np.ndarray, source_sr: int):
"""Write for burst sources (caller TTS, SFX). Pads silence to current time."""
if not self._running or stem_name not in self._queues:
return
self._queues[stem_name].append(("sporadic", audio_data.copy(), source_sr))
def _resample(self, audio_data: np.ndarray, source_sr: int) -> np.ndarray:
if source_sr == self.sample_rate:
return audio_data.astype(np.float32)
ratio = self.sample_rate / source_sr
num_samples = int(len(audio_data) * ratio)
if num_samples <= 0:
return np.array([], dtype=np.float32)
indices = (np.arange(num_samples) / ratio).astype(int)
indices = np.clip(indices, 0, len(audio_data) - 1)
return audio_data[indices].astype(np.float32)
def _writer_loop(self):
"""Background thread that drains queues and writes to WAV files."""
files: dict[str, sf.SoundFile] = {}
positions: dict[str, int] = {}
for name in STEM_NAMES: for name in STEM_NAMES:
path = self.output_dir / f"{name}.wav" path = self.output_dir / f"{name}.wav"
f = sf.SoundFile( files[name] = sf.SoundFile(
str(path), mode="w", str(path), mode="w",
samplerate=self.sample_rate, samplerate=self.sample_rate,
channels=1, subtype="FLOAT", channels=1, subtype="FLOAT",
) )
self._files[name] = f positions[name] = 0
self._write_positions[name] = 0
print(f"[StemRecorder] Recording started -> {self.output_dir}")
def write(self, stem_name: str, audio_data: np.ndarray, source_sr: int): while self._running or any(len(q) > 0 for q in self._queues.values()):
if not self._running or stem_name not in self._files: did_work = False
return for name in STEM_NAMES:
q = self._queues[name]
while q:
did_work = True
msg_type, audio_data, source_sr = q.popleft()
resampled = self._resample(audio_data, source_sr)
if len(resampled) == 0:
continue
# Resample to target rate if needed if msg_type == "sporadic":
if source_sr != self.sample_rate: elapsed = time.time() - self._start_time
num_samples = int(len(audio_data) * self.sample_rate / source_sr) expected_pos = int(elapsed * self.sample_rate)
if num_samples > 0: if expected_pos > positions[name]:
audio_data = scipy_signal.resample(audio_data, num_samples).astype(np.float32) gap = expected_pos - positions[name]
else: files[name].write(np.zeros(gap, dtype=np.float32))
return positions[name] = expected_pos
# Fill silence gap based on elapsed time files[name].write(resampled)
elapsed = time.time() - self._start_time positions[name] += len(resampled)
expected_pos = int(elapsed * self.sample_rate)
current_pos = self._write_positions[stem_name]
if expected_pos > current_pos: if not did_work:
gap = expected_pos - current_pos time.sleep(0.02)
silence = np.zeros(gap, dtype=np.float32)
self._files[stem_name].write(silence)
self._write_positions[stem_name] = expected_pos
self._files[stem_name].write(audio_data.astype(np.float32)) # Pad all stems to same length
self._write_positions[stem_name] += len(audio_data) max_pos = max(positions.values()) if positions else 0
for name in STEM_NAMES:
if positions[name] < max_pos:
files[name].write(np.zeros(max_pos - positions[name], dtype=np.float32))
files[name].close()
print(f"[StemRecorder] Writer done. {max_pos} samples ({max_pos / self.sample_rate:.1f}s)")
def stop(self) -> dict[str, str]: def stop(self) -> dict[str, str]:
if not self._running: if not self._running:
return {} return {}
self._running = False self._running = False
if self._writer_thread:
self._writer_thread.join(timeout=10.0)
self._writer_thread = None
# Pad all stems to the same length
max_pos = max(self._write_positions.values()) if self._write_positions else 0
for name in STEM_NAMES:
pos = self._write_positions[name]
if pos < max_pos:
silence = np.zeros(max_pos - pos, dtype=np.float32)
self._files[name].write(silence)
# Close all files
paths = {} paths = {}
for name in STEM_NAMES: for name in STEM_NAMES:
self._files[name].close()
paths[name] = str(self.output_dir / f"{name}.wav") paths[name] = str(self.output_dir / f"{name}.wav")
self._files.clear() self._queues.clear()
self._write_positions.clear()
print(f"[StemRecorder] Recording stopped. {max_pos} samples ({max_pos/self.sample_rate:.1f}s)")
return paths return paths

View File

@@ -58,9 +58,13 @@
{ {
"summary": "Carla dismissed celebrity science theories like Terrence Howard's after watching Neil deGrasse Tyson's critique, then marveled at JWST's exoplanet discoveries before sharing her relief at finally cutting off her toxic in-laws amid her ongoing divorce. She expressed deep heartbreak over actor James Ransone's suicide at 46, reflecting on life's fragility, her late father's death, and the need to eliminate family drama, leaving her contemplative and planning a solo desert drive for clarity.", "summary": "Carla dismissed celebrity science theories like Terrence Howard's after watching Neil deGrasse Tyson's critique, then marveled at JWST's exoplanet discoveries before sharing her relief at finally cutting off her toxic in-laws amid her ongoing divorce. She expressed deep heartbreak over actor James Ransone's suicide at 46, reflecting on life's fragility, her late father's death, and the need to eliminate family drama, leaving her contemplative and planning a solo desert drive for clarity.",
"timestamp": 1770526316.004708 "timestamp": 1770526316.004708
},
{
"summary": "In this call, Carla discovered some explicit photos of her ex-husband and his old girlfriend in a box of his old ham radio equipment. She is feeling very uncomfortable about the situation and is seeking advice from the radio host, Luke, on how to best handle and dispose of the photos.",
"timestamp": 1770602323.234795
} }
], ],
"last_call": 1770526316.004709, "last_call": 1770602323.234796,
"created_at": 1770522530.855426 "created_at": 1770522530.855426
}, },
{ {
@@ -138,6 +142,23 @@
], ],
"last_call": 1770573956.570584, "last_call": 1770573956.570584,
"created_at": 1770573956.570584 "created_at": 1770573956.570584
},
{
"id": "d4bdda2e",
"name": "Bobby",
"gender": "male",
"age": 32,
"job": "a 61-year-old repo man, sits in his truck",
"location": "unknown",
"personality_traits": [],
"call_history": [
{
"summary": "In summary, the caller learned he has been diagnosed with multiple sclerosis, which he is worried will make it difficult for him to continue his job as a self-employed repo man. He is trying to process the news and figure out how to adapt and keep working, despite the uncertainty about how the condition will progress. The host provides some encouragement, suggesting the caller focus on learning about MS and finding ways to adapt, rather than getting too worked up about the future.",
"timestamp": 1770602129.500858
}
],
"last_call": 1770602129.5008588,
"created_at": 1770602129.5008588
} }
] ]
} }

View File

@@ -15,6 +15,7 @@ import os
import re import re
import subprocess import subprocess
import sys import sys
import tempfile
import base64 import base64
from pathlib import Path 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 WHISPER_MODEL = "base" # Options: tiny, base, small, medium, large
# NAS Configuration for chapters upload # 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_HOST = "mmgnas-10g"
NAS_USER = "luke" NAS_USER = "luke"
NAS_SSH_PORT = 8001 NAS_SSH_PORT = 8001
@@ -268,7 +274,7 @@ def save_chapters(metadata: dict, output_path: str):
print(f" Chapters saved to: {output_path}") 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.""" """Run a command on the NAS via SSH."""
ssh_cmd = [ ssh_cmd = [
"ssh", "-p", str(NAS_SSH_PORT), "ssh", "-p", str(NAS_SSH_PORT),
@@ -276,7 +282,7 @@ def run_ssh_command(command: str) -> tuple[bool, str]:
command command
] ]
try: 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() return result.returncode == 0, result.stdout.strip() or result.stderr.strip()
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return False, "SSH command timed out" 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 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: def get_next_episode_number() -> int:
"""Get the next episode number from Castopod.""" """Get the next episode number from Castopod."""
headers = get_auth_header() headers = get_auth_header()
@@ -438,6 +524,39 @@ def main():
# Step 3: Create episode # Step 3: Create episode
episode = create_episode(str(audio_path), metadata, episode_number) 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 # Step 4: Publish
episode = publish_episode(episode["id"]) episode = publish_episode(episode["id"])
@@ -448,6 +567,10 @@ def main():
str(chapters_path) 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 # Step 5: Summary
print("\n[5/5] Done!") print("\n[5/5] Done!")
print("=" * 50) print("=" * 50)

View File

@@ -112,6 +112,75 @@ a:hover {
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
/* On-Air Badge */
.on-air-badge {
display: none;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: var(--accent-red);
color: #fff;
padding: 0.4rem 1.2rem;
border-radius: 50px;
font-size: 0.85rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
animation: on-air-glow 2s ease-in-out infinite;
margin-bottom: 0.5rem;
}
.on-air-badge.visible {
display: inline-flex;
}
.on-air-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #fff;
animation: on-air-blink 1s step-end infinite;
}
@keyframes on-air-glow {
0%, 100% { box-shadow: 0 0 8px rgba(204, 34, 34, 0.5); }
50% { box-shadow: 0 0 20px rgba(204, 34, 34, 0.8); }
}
@keyframes on-air-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* Off-Air Badge */
.off-air-badge {
display: inline-flex;
align-items: center;
justify-content: center;
background: #444;
color: var(--text-muted);
padding: 0.35rem 1.1rem;
border-radius: 50px;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
margin-bottom: 0.5rem;
}
.off-air-badge.hidden {
display: none;
}
.phone.live .phone-number {
color: var(--accent-red);
text-shadow: 0 0 16px rgba(204, 34, 34, 0.35);
}
.phone.live .phone-label {
color: var(--text);
}
/* Subscribe buttons */ /* Subscribe buttons */
.subscribe-row { .subscribe-row {
display: flex; display: flex;

View File

@@ -9,7 +9,7 @@
<meta property="og:title" content="How It Works — Luke at the Roost"> <meta property="og:title" content="How It Works — Luke at the Roost">
<meta property="og:description" content="The tech behind a one-of-a-kind AI radio show. Real callers, AI callers, voice synthesis, and a live control room."> <meta property="og:description" content="The tech behind a one-of-a-kind AI radio show. Real callers, AI callers, voice synthesis, and a live control room.">
<meta property="og:image" content="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3"> <meta property="og:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
<meta property="og:url" content="https://lukeattheroost.com/how-it-works"> <meta property="og:url" content="https://lukeattheroost.com/how-it-works">
<meta property="og:type" content="website"> <meta property="og:type" content="website">

View File

@@ -11,13 +11,13 @@
<!-- OG / Social --> <!-- OG / Social -->
<meta property="og:title" content="Luke at the Roost — Life advice for biologically questionable organisms"> <meta property="og:title" content="Luke at the Roost — Life advice for biologically questionable organisms">
<meta property="og:description" content="The call-in talk show where Luke gives life advice to biologically questionable organisms — from a desert hermit's RV. Call in: 208-439-LUKE."> <meta property="og:description" content="The call-in talk show where Luke gives life advice to biologically questionable organisms — from a desert hermit's RV. Call in: 208-439-LUKE.">
<meta property="og:image" content="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3"> <meta property="og:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
<meta property="og:url" content="https://lukeattheroost.com"> <meta property="og:url" content="https://lukeattheroost.com">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Luke at the Roost"> <meta name="twitter:title" content="Luke at the Roost">
<meta name="twitter:description" content="The call-in talk show where Luke gives life advice to biologically questionable organisms. Call in: 208-439-LUKE"> <meta name="twitter:description" content="The call-in talk show where Luke gives life advice to biologically questionable organisms. Call in: 208-439-LUKE">
<meta name="twitter:image" content="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3"> <meta name="twitter:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cpath d='M32 4c-2 0-4 2-4 5 0 1 .3 2 .8 3C26 13 24 16 24 20c0 2 .5 4 1.5 5.5L22 28c-2 1-4 3-5 6l-3 10c-.5 2 .5 3 2 3h4l1-4 2 4h6l-1-6 3 6h6l-1-6 3 6h4c1.5 0 2.5-1 2-3l-3-10c-1-3-3-5-5-6l-3.5-2.5C35.5 24 36 22 36 20c0-4-2-7-4.8-8 .5-1 .8-2 .8-3 0-3-2-5-4-5z' fill='%23e8791d'/%3E%3Ccircle cx='30' cy='17' r='1.5' fill='%231a1209'/%3E%3Cpath d='M36 15c1-1 3-1 4 0s1 3 0 4' fill='none' stroke='%23cc2222' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M28 22c2 1 4 1 6 0' fill='none' stroke='%23e8791d' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E"> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cpath d='M32 4c-2 0-4 2-4 5 0 1 .3 2 .8 3C26 13 24 16 24 20c0 2 .5 4 1.5 5.5L22 28c-2 1-4 3-5 6l-3 10c-.5 2 .5 3 2 3h4l1-4 2 4h6l-1-6 3 6h6l-1-6 3 6h4c1.5 0 2.5-1 2-3l-3-10c-1-3-3-5-5-6l-3.5-2.5C35.5 24 36 22 36 20c0-4-2-7-4.8-8 .5-1 .8-2 .8-3 0-3-2-5-4-5z' fill='%23e8791d'/%3E%3Ccircle cx='30' cy='17' r='1.5' fill='%231a1209'/%3E%3Cpath d='M36 15c1-1 3-1 4 0s1 3 0 4' fill='none' stroke='%23cc2222' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M28 22c2 1 4 1 6 0' fill='none' stroke='%23e8791d' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E">
@@ -33,7 +33,7 @@
"name": "Luke at the Roost", "name": "Luke at the Roost",
"description": "The call-in talk show where Luke gives life advice to biologically questionable organisms. Broadcast from a desert hermit's RV, featuring a mix of real callers and AI-generated callers.", "description": "The call-in talk show where Luke gives life advice to biologically questionable organisms. Broadcast from a desert hermit's RV, featuring a mix of real callers and AI-generated callers.",
"url": "https://lukeattheroost.com", "url": "https://lukeattheroost.com",
"image": "https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3", "image": "https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3",
"author": { "author": {
"@type": "Person", "@type": "Person",
"name": "Luke MacNeil" "name": "Luke MacNeil"
@@ -71,7 +71,14 @@
<div class="hero-info"> <div class="hero-info">
<h1>Luke at the Roost</h1> <h1>Luke at the Roost</h1>
<p class="tagline">The call-in talk show where Luke gives life advice to biologically questionable organisms.</p> <p class="tagline">The call-in talk show where Luke gives life advice to biologically questionable organisms.</p>
<div class="phone"> <div class="phone" id="phone-section">
<div class="on-air-badge" id="on-air-badge">
<span class="on-air-dot"></span>
ON AIR
</div>
<div class="off-air-badge" id="off-air-badge">
OFF AIR
</div>
<span class="phone-label">Call in live</span> <span class="phone-label">Call in live</span>
<span class="phone-number">208-439-LUKE</span> <span class="phone-number">208-439-LUKE</span>
<span class="phone-digits">(208-439-5853)</span> <span class="phone-digits">(208-439-5853)</span>

View File

@@ -300,6 +300,24 @@ function initTestimonials() {
resetAutoplay(); resetAutoplay();
} }
// On-Air Status
function checkOnAir() {
fetch(`https://cdn.lukeattheroost.com/status.json?_=${Date.now()}`)
.then(r => r.json())
.then(data => {
const badge = document.getElementById('on-air-badge');
const offBadge = document.getElementById('off-air-badge');
const phone = document.getElementById('phone-section');
const live = !!data.on_air;
if (badge) badge.classList.toggle('visible', live);
if (offBadge) offBadge.classList.toggle('hidden', live);
if (phone) phone.classList.toggle('live', live);
})
.catch(() => {});
}
// Init // Init
fetchEpisodes(); fetchEpisodes();
initTestimonials(); initTestimonials();
checkOnAir();
setInterval(checkOnAir, 15000);