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:
21
CLAUDE.md
21
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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
# Fill silence gap based on elapsed time
|
||||
if msg_type == "sporadic":
|
||||
elapsed = time.time() - self._start_time
|
||||
expected_pos = int(elapsed * self.sample_rate)
|
||||
current_pos = self._write_positions[stem_name]
|
||||
if expected_pos > positions[name]:
|
||||
gap = expected_pos - positions[name]
|
||||
files[name].write(np.zeros(gap, dtype=np.float32))
|
||||
positions[name] = expected_pos
|
||||
|
||||
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
|
||||
files[name].write(resampled)
|
||||
positions[name] += len(resampled)
|
||||
|
||||
self._files[stem_name].write(audio_data.astype(np.float32))
|
||||
self._write_positions[stem_name] += len(audio_data)
|
||||
if not did_work:
|
||||
time.sleep(0.02)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<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: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:type" content="website">
|
||||
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
<!-- OG / Social -->
|
||||
<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: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:type" content="website">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<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: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 -->
|
||||
<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",
|
||||
"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 @@
|
||||
<div class="hero-info">
|
||||
<h1>Luke at the Roost</h1>
|
||||
<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-number">208-439-LUKE</span>
|
||||
<span class="phone-digits">(208-439-5853)</span>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user