diff --git a/CLAUDE.md b/CLAUDE.md index 4d17908..c36b735 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,3 +42,24 @@ Required in `.env`: - OPENROUTER_API_KEY - ELEVENLABS_API_KEY (optional) - 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) diff --git a/backend/main.py b/backend/main.py index 7289070..cbd50f0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2258,6 +2258,7 @@ def _build_news_context() -> tuple[str, str]: async def startup(): """Pre-generate caller backgrounds on server start""" asyncio.create_task(_pregenerate_backgrounds()) + threading.Thread(target=_update_on_air_cdn, args=(False,), daemon=True).start() @app.on_event("shutdown") @@ -2265,6 +2266,7 @@ async def shutdown(): """Clean up resources on server shutdown""" global _host_audio_task print("[Server] Shutting down — cleaning up resources...") + _update_on_air_cdn(False) # Stop host mic streaming audio_service.stop_host_stream() # Cancel host audio sender task @@ -2296,12 +2298,48 @@ async def index(): # --- 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") async def set_on_air(state: dict): """Toggle whether the show is on air (accepting phone calls)""" global _show_on_air _show_on_air = bool(state.get("on_air", False)) 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} @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.""" roll = random.random() if roll < 0.20: - return 80, 2 # 20% — short and direct + return 150, 2 # 20% — short and direct elif roll < 0.55: - return 120, 3 # 35% — normal conversation + return 250, 3 # 35% — normal conversation elif roll < 0.80: - return 150, 4 # 25% — explaining something + return 350, 4 # 25% — explaining something 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: @@ -3862,6 +3900,7 @@ async def start_stem_recording(): recorder = StemRecorder(recordings_dir, sample_rate=sr) recorder.start() audio_service.stem_recorder = recorder + audio_service.start_stem_mic() add_log(f"Stem recording started -> {recordings_dir}") return {"status": "recording", "dir": str(recordings_dir)} @@ -3870,10 +3909,31 @@ async def start_stem_recording(): async def stop_stem_recording(): if audio_service.stem_recorder is None: 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() audio_service.stem_recorder = None - add_log(f"Stem recording stopped. Files: {list(paths.keys())}") - return {"status": "stopped", "stems": paths} + add_log(f"Stem recording stopped. Running post-production...") + + # 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") diff --git a/backend/services/audio.py b/backend/services/audio.py index 84e9a40..d1ea16f 100644 --- a/backend/services/audio.py +++ b/backend/services/audio.py @@ -80,6 +80,7 @@ class AudioService: # Stem recording (opt-in, attached via API) self.stem_recorder = None + self._stem_mic_stream: Optional[sd.InputStream] = None # Load saved settings self._load_settings() @@ -282,6 +283,8 @@ class AudioService: stream_ready.set() if self._recording: 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)") @@ -360,7 +363,7 @@ class AudioService: # Stem recording: caller TTS 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 multi_ch = np.zeros((len(audio), num_channels), dtype=np.float32) @@ -500,7 +503,7 @@ class AudioService: # Stem recording: live caller 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: self._live_caller_write(audio) @@ -930,7 +933,7 @@ class AudioService: # Stem recording: sfx 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[:, channel_idx] = audio @@ -950,6 +953,45 @@ class AudioService: except Exception as 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 audio_service = AudioService() diff --git a/backend/services/llm.py b/backend/services/llm.py index bd26f1f..ea6020a 100644 --- a/backend/services/llm.py +++ b/backend/services/llm.py @@ -154,7 +154,7 @@ class LLMService: json={ "model": model, "messages": messages, - "max_tokens": max_tokens or 150, + "max_tokens": max_tokens or 300, "temperature": 0.8, "top_p": 0.92, "frequency_penalty": 0.5, diff --git a/backend/services/stem_recorder.py b/backend/services/stem_recorder.py index 49dee4e..8704e20 100644 --- a/backend/services/stem_recorder.py +++ b/backend/services/stem_recorder.py @@ -1,10 +1,11 @@ """Records separate audio stems during a live show for post-production""" import time +import threading import numpy as np import soundfile as sf from pathlib import Path -from scipy import signal as scipy_signal +from collections import deque STEM_NAMES = ["host", "caller", "music", "sfx", "ads"] @@ -14,73 +15,104 @@ class StemRecorder: self.output_dir = Path(output_dir) self.output_dir.mkdir(parents=True, exist_ok=True) 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._queues: dict[str, deque] = {} + self._writer_thread: threading.Thread | None = None + self._start_time: float = 0.0 def start(self): self._start_time = time.time() 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: path = self.output_dir / f"{name}.wav" - f = sf.SoundFile( + files[name] = sf.SoundFile( str(path), mode="w", samplerate=self.sample_rate, channels=1, subtype="FLOAT", ) - self._files[name] = f - self._write_positions[name] = 0 - print(f"[StemRecorder] Recording started -> {self.output_dir}") + positions[name] = 0 - def write(self, stem_name: str, audio_data: np.ndarray, source_sr: int): - if not self._running or stem_name not in self._files: - return + while self._running or any(len(q) > 0 for q in self._queues.values()): + did_work = False + 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 source_sr != self.sample_rate: - num_samples = int(len(audio_data) * self.sample_rate / source_sr) - if num_samples > 0: - audio_data = scipy_signal.resample(audio_data, num_samples).astype(np.float32) - else: - return + if msg_type == "sporadic": + elapsed = time.time() - self._start_time + expected_pos = int(elapsed * self.sample_rate) + if expected_pos > positions[name]: + gap = expected_pos - positions[name] + files[name].write(np.zeros(gap, dtype=np.float32)) + positions[name] = expected_pos - # Fill silence gap based on elapsed time - elapsed = time.time() - self._start_time - expected_pos = int(elapsed * self.sample_rate) - current_pos = self._write_positions[stem_name] + files[name].write(resampled) + positions[name] += len(resampled) - if expected_pos > current_pos: - gap = expected_pos - current_pos - silence = np.zeros(gap, dtype=np.float32) - self._files[stem_name].write(silence) - self._write_positions[stem_name] = expected_pos + if not did_work: + time.sleep(0.02) - self._files[stem_name].write(audio_data.astype(np.float32)) - self._write_positions[stem_name] += len(audio_data) + # Pad all stems to same length + 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]: if not self._running: return {} 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 = {} for name in STEM_NAMES: - self._files[name].close() paths[name] = str(self.output_dir / f"{name}.wav") - self._files.clear() - self._write_positions.clear() - - print(f"[StemRecorder] Recording stopped. {max_pos} samples ({max_pos/self.sample_rate:.1f}s)") + self._queues.clear() return paths diff --git a/data/regulars.json b/data/regulars.json index 5fb0934..2c745e6 100644 --- a/data/regulars.json +++ b/data/regulars.json @@ -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.", "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 }, { @@ -138,6 +142,23 @@ ], "last_call": 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 } ] } \ No newline at end of file diff --git a/publish_episode.py b/publish_episode.py index 725d8a5..a0181aa 100755 --- a/publish_episode.py +++ b/publish_episode.py @@ -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) diff --git a/website/css/style.css b/website/css/style.css index 3ee3926..25a648f 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -112,6 +112,75 @@ a:hover { 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-row { display: flex; diff --git a/website/how-it-works.html b/website/how-it-works.html index 2621007..46d7969 100644 --- a/website/how-it-works.html +++ b/website/how-it-works.html @@ -9,7 +9,7 @@ - + diff --git a/website/index.html b/website/index.html index fe66a5c..660db09 100644 --- a/website/index.html +++ b/website/index.html @@ -11,13 +11,13 @@ - + - + @@ -33,7 +33,7 @@ "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.", "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": { "@type": "Person", "name": "Luke MacNeil" @@ -71,7 +71,14 @@

Luke at the Roost

The call-in talk show where Luke gives life advice to biologically questionable organisms.

-
+
+
+ + ON AIR +
+
+ OFF AIR +
Call in live 208-439-LUKE (208-439-5853) diff --git a/website/js/app.js b/website/js/app.js index 39f732a..bf4750c 100644 --- a/website/js/app.js +++ b/website/js/app.js @@ -300,6 +300,24 @@ function initTestimonials() { 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 fetchEpisodes(); initTestimonials(); +checkOnAir(); +setInterval(checkOnAir, 15000);