Expand all caller topic pools, add cross-episode topic dedup, publish ep35

Massively expanded all 8 caller topic pools from ~1200 to ~2500 entries to
reduce repeat calls. Added persistent topic history (data/used_topics_history.json)
with 30-day aging to prevent cross-episode duplicates. Published episode 35.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 05:45:22 -06:00
parent 0c2201fab5
commit d3490e1521
11 changed files with 2316 additions and 195 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -600,6 +600,32 @@ async def generate_speech_chattts(text: str, voice_id: str) -> tuple[np.ndarray,
return audio.astype(np.float32), 24000 return audio.astype(np.float32), 24000
_EXCITED_KEYWORDS = {"excited", "amazing", "incredible", "can't believe", "so happy",
"hell yeah", "fired up", "furious", "pissed", "angry", "what the hell",
"are you kidding", "unbelievable", "!!", "oh my god"}
_SAD_KEYWORDS = {"sad", "miss them", "passed away", "funeral", "crying", "broke my heart",
"can't stop thinking", "lonely", "depressed", "sorry", "regret",
"wish I could", "never got to", "lost", "grief"}
def _detect_speech_rate(text: str, base_speed: float) -> float:
"""Adjust speech rate based on emotional content of the text.
Returns a speed value clamped to Inworld's 0.5-1.5 range."""
text_lower = text.lower()
excited = sum(1 for kw in _EXCITED_KEYWORDS if kw in text_lower)
sad = sum(1 for kw in _SAD_KEYWORDS if kw in text_lower)
if excited >= 2:
return min(1.5, base_speed + 0.15)
elif excited >= 1:
return min(1.5, base_speed + 0.08)
elif sad >= 2:
return max(0.5, base_speed - 0.2)
elif sad >= 1:
return max(0.5, base_speed - 0.1)
return base_speed
async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray, int]: async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray, int]:
"""Generate speech using Inworld TTS API (high quality, natural voices)""" """Generate speech using Inworld TTS API (high quality, natural voices)"""
import httpx import httpx
@@ -617,8 +643,9 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray,
if not api_key: if not api_key:
raise RuntimeError("INWORLD_API_KEY not set in environment") raise RuntimeError("INWORLD_API_KEY not set in environment")
speed = INWORLD_SPEED_OVERRIDES.get(voice, DEFAULT_INWORLD_SPEED) base_speed = INWORLD_SPEED_OVERRIDES.get(voice, DEFAULT_INWORLD_SPEED)
print(f"[Inworld TTS] Voice: {voice}, Speed: {speed}, Text: {text[:50]}...") speed = _detect_speech_rate(text, base_speed)
print(f"[Inworld TTS] Voice: {voice}, Speed: {speed:.2f} (base {base_speed}), Text: {text[:50]}...")
url = "https://api.inworld.ai/tts/v1/voice" url = "https://api.inworld.ai/tts/v1/voice"
headers = { headers = {
@@ -671,6 +698,20 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray,
return audio.astype(np.float32), 24000 return audio.astype(np.float32), 24000
def pick_caller_tts_provider() -> str | None:
"""Randomly assign a TTS provider for a caller.
Returns None to use the global default, or a specific provider name.
~70% inworld (default), ~20% kokoro, ~10% other available."""
import random
roll = random.random()
if roll < 0.70:
return None # Use global default (typically inworld)
elif roll < 0.90:
return "kokoro"
else:
return random.choice(["kokoro", "f5tts", "chattts"])
_TTS_PROVIDERS = { _TTS_PROVIDERS = {
"kokoro": lambda text, vid: generate_speech_kokoro(text, vid), "kokoro": lambda text, vid: generate_speech_kokoro(text, vid),
"f5tts": lambda text, vid: generate_speech_f5tts(text, vid), "f5tts": lambda text, vid: generate_speech_f5tts(text, vid),
@@ -690,7 +731,8 @@ async def generate_speech(
text: str, text: str,
voice_id: str, voice_id: str,
phone_quality: str = "normal", phone_quality: str = "normal",
apply_filter: bool = True apply_filter: bool = True,
provider_override: str = None
) -> bytes: ) -> bytes:
""" """
Generate speech from text with automatic retry on failure. Generate speech from text with automatic retry on failure.
@@ -700,14 +742,15 @@ async def generate_speech(
voice_id: ElevenLabs voice ID (mapped to local voice if using local TTS) voice_id: ElevenLabs voice ID (mapped to local voice if using local TTS)
phone_quality: Quality of phone filter ("none" to disable) phone_quality: Quality of phone filter ("none" to disable)
apply_filter: Whether to apply phone filter apply_filter: Whether to apply phone filter
provider_override: Override the global TTS provider for this call
Returns: Returns:
Raw PCM audio bytes (16-bit signed int, 24kHz) Raw PCM audio bytes (16-bit signed int, 24kHz)
""" """
import asyncio import asyncio
provider = settings.tts_provider provider = provider_override or settings.tts_provider
print(f"[TTS] Provider: {provider}, Text: {text[:50]}...") print(f"[TTS] Provider: {provider}{' (override)' if provider_override else ''}, Text: {text[:50]}...")
gen_fn = _TTS_PROVIDERS.get(provider) gen_fn = _TTS_PROVIDERS.get(provider)
if not gen_fn: if not gen_fn:

View File

@@ -59,5 +59,22 @@
} }
}, },
"started_at": "2026-03-12T07:04:34.974425+00:00" "started_at": "2026-03-12T07:04:34.974425+00:00"
},
"35": {
"steps": {
"castopod": {
"completed_at": "2026-03-13T11:19:41.765107+00:00",
"episode_id": 38,
"slug": "episode-35-midnight-confessions-and-unexpected-revelations"
},
"youtube": {
"completed_at": "2026-03-13T11:42:00.428623+00:00",
"video_id": "fYvXLqFilLQ"
},
"social": {
"completed_at": "2026-03-13T11:42:11.800641+00:00"
}
},
"started_at": "2026-03-13T11:19:41.765079+00:00"
} }
} }

View File

@@ -1,55 +1,5 @@
{ {
"regulars": [ "regulars": [
{
"id": "37f0bfaa",
"name": "Murray",
"gender": "male",
"age": 36,
"job": "engine running for heat, watching his breath fog up the windshield while he tries to figure out how to fire his best friend of thirty years",
"location": "in unknown",
"personality_traits": [],
"voice": "Tyler",
"stable_seeds": {
"style": "COMMUNICATION STYLE: Amped up. Talks fast, laughs loud, jumps between topics like they've had five espressos. Infectious enthusiasm \u2014 even bad news sounds exciting when they tell it. Uses exclamation energy without actually exclaiming. Energy level: very high. When pushed back on, they get even MORE animated and start talking with their hands (you can hear it). Conversational tendency: escalation."
},
"call_history": [
{
"summary": "Murray called in struggling with whether to fire his best friend Danny of 30 years, who's been showing up late, bad-mouthing him to their crew, and just cost them a major contract by abandoning a job site. Through the conversation, Murray realized he'd become overly rigid and \"suit-like\" while trying to prove himself as the new business owner, and decided instead of firing Danny, he'd hold a team meeting to apologize for his approach, explain the reasoning behind new protocols, and invite the crew to be part of the solution rather than just enforcing rules from above.",
"timestamp": 1772250744.2312489
},
{
"summary": "Murray called back about Danny, who showed up for only four days after their team meeting before disappearing without notice, then had his girlfriend tell Murray he was \"taking time to think\" about the job. Murray was emotionally torn between feeling hurt that Danny accused him of being inauthentic (only having the meeting because Luke called him out on air) and recognizing he needs to let Danny go for the sake of his business and the rest of his crew.",
"timestamp": 1772862554.163734
}
],
"last_call": 1772862554.1637352,
"created_at": 1772250744.2312498
},
{
"id": "bbb20b67",
"name": "Angie",
"gender": "female",
"age": 28,
"job": "watching her coveralls tumble dry and trying to decide if she should drive the three hours to Tucson tomorrow for her mom's birthday or keep pretending her brother doesn't exist",
"location": "in unknown",
"personality_traits": [],
"voice": "Julia",
"stable_seeds": {
"style": "COMMUNICATION STYLE: Bone dry. Says devastating things with zero inflection. Their humor sneaks up on you \u2014 you're not sure if they're joking until three seconds after they finish talking. Short, precise sentences. Never raises their voice. Energy level: low-medium. When pushed back on, they respond with one calm sentence that somehow makes the other person feel stupid. Conversational tendency: underreaction."
},
"call_history": [
{
"summary": "Angie's dying mother wants her to have birthday dinner with her estranged brother Derek tomorrow, whom she hasn't spoken to in two years after he told their mother her cancer was \"God's way of getting her attention\" for voting for Biden. Despite her fear that Derek will say something hurtful during dinner and her past trauma from staying silent around him, Angie agrees to go and share cake with her mother, deciding to buy the relighting candles her mom loved when they were kids.",
"timestamp": 1772862907.314721
},
{
"summary": "Angie called back after having the birthday cake dinner with her dying mom, which went well, but her brother Derek cornered her afterward accusing her of convincing their mom to stop cancer treatment and demanding they both attend the next doctor's appointment together. Luke advised her to talk directly to her mom about what she actually wants and encouraged Angie to have real conversations about her mom's end-of-life thoughts while she still can, which Angie agreed to do the next morning.",
"timestamp": 1773296210.170752
}
],
"last_call": 1773296210.170753,
"created_at": 1772862907.314722
},
{ {
"id": "d3399e9d", "id": "d3399e9d",
"name": "Lucille", "name": "Lucille",
@@ -105,9 +55,13 @@
{ {
"summary": "Silas called about Marcus and Cara returning to his intentional community \"The Wellspring,\" but Cara admitted she never believed in their lifestyle and only participates (including in twice-monthly \"shared intimacy nights\") to keep her husband Marcus happy. The host advised Silas to hold a \"Renewal\" ceremony where members can recommit or leave, warning that having unwilling participants could lead to claims of abuse and legal trouble.", "summary": "Silas called about Marcus and Cara returning to his intentional community \"The Wellspring,\" but Cara admitted she never believed in their lifestyle and only participates (including in twice-monthly \"shared intimacy nights\") to keep her husband Marcus happy. The host advised Silas to hold a \"Renewal\" ceremony where members can recommit or leave, warning that having unwilling participants could lead to claims of abuse and legal trouble.",
"timestamp": 1772865423.697613 "timestamp": 1772865423.697613
},
{
"summary": "Silas called to share that after Marcus and Cara's Renewal ceremony, Cara left The Wellspring while Marcus chose to stay, but Marcus is now falling apart emotionally and told Silas at 2 AM that he stayed out of loyalty rather than belief. The conversation revealed Silas's deeper struggle with his own need for validation through people staying at The Wellspring, with an emotional moment when he admitted his first feeling was relief when Marcus expressed he didn't want to disappoint him, leading to uncomfortable questions about whether he truly supports people finding their authentic path if it leads them away from the community.",
"timestamp": 1773397364.642446
} }
], ],
"last_call": 1772865423.6976142, "last_call": 1773397364.642447,
"created_at": 1772430000.0 "created_at": 1772430000.0
}, },
{ {
@@ -139,35 +93,6 @@
"last_call": 1772959484.6798599, "last_call": 1772959484.6798599,
"created_at": 1772517521.7108748 "created_at": 1772517521.7108748
}, },
{
"id": "0bb02b2d",
"name": "Chip",
"gender": "male",
"age": 23,
"job": "watching his kid's soccer uniform tumble in the dryer while his girlfriend works the graveyard shift at the hospital, because three hours ago he got an email from a lawyer representing families",
"location": "unknown",
"personality_traits": [],
"voice": "Sebastian",
"stable_seeds": {
"style": "COMMUNICATION STYLE: Amped up. Talks fast, laughs loud, jumps between topics like they've had five espressos. Infectious enthusiasm \u2014 even bad news sounds exciting when they tell it. Uses exclamation energy without actually exclaiming. Energy level: very high. When pushed back on, they get even MORE animated and start talking with their hands (you can hear it). Conversational tendency: escalation."
},
"call_history": [
{
"summary": "Chip called from a laundromat at midnight after receiving an email from a Guatemalan lawyer claiming his adopted 8-year-old daughter may have been stolen from her birth mother, with a photo showing a woman with his daughter's exact crooked smile. The host advised him not to panic, treat the information as suspect until verified by a lawyer, wait to tell both his girlfriend and daughter until he knows more facts, and reminded him that fake photos are easy to create and this could be a scam.",
"timestamp": 1772786610.885828
},
{
"summary": "Chip called about discovering his adopted daughter may have been stolen from her birth mother in Guatemala, and he's paralyzed about telling his girlfriend Teresa, fearing it will end their already rocky relationship. He's anxious about the timing and the birth mother's request to meet their daughter, but the host advised him to take his time, have the conversation with Teresa, and make decisions together as parents.",
"timestamp": 1772962156.544322
},
{
"summary": "The caller, **Chip**, shared his emotional turmoil over discovering that his **adopted daughter\u2019s birth mother** may have resurfaced after receiving an unverified email with a photo that eerily matched his daughter\u2019s features. His girlfriend, **Teresa**, had known about the email for **three weeks** but kept it from him, leaving him feeling betrayed and overwhelmed. While Chip wants to **verify the claim legally before acting**, Teresa insists on **immediately flying to Guatemala with their daughter** to meet the woman, dismissing his fears as avoidance. The conversation escalated into a heated debate about **trust, safety, and extreme measures**\u2014with the host, Luke, urging Chip to **file a restraining order** if Teresa refuses to back down, warning of potential dangers in Guatemala. Chip, torn between **protecting his family and avoiding a nuclear confrontation**, vowed to try reasoning with Teresa one last time before she leaves for work. The call was charged with **fear, frustration, and the weight of a decision that could reshape their family forever**.",
"timestamp": 1773226361.4859362
}
],
"last_call": 1773226361.4859362,
"created_at": 1772786610.8858292
},
{ {
"id": "6037d92b", "id": "6037d92b",
"name": "Otis", "name": "Otis",
@@ -213,6 +138,81 @@
], ],
"last_call": 1773219255.9161851, "last_call": 1773219255.9161851,
"created_at": 1772866520.023336 "created_at": 1772866520.023336
},
{
"id": "0bb02b2d",
"name": "Chip",
"gender": "male",
"age": 23,
"job": "watching his kid's soccer uniform tumble in the dryer while his girlfriend works the graveyard shift at the hospital, because three hours ago he got an email from a lawyer representing families",
"location": "unknown",
"personality_traits": [],
"voice": "Sebastian",
"stable_seeds": {
"style": "COMMUNICATION STYLE: Amped up. Talks fast, laughs loud, jumps between topics like they've had five espressos. Infectious enthusiasm \u2014 even bad news sounds exciting when they tell it. Uses exclamation energy without actually exclaiming. Energy level: very high. When pushed back on, they get even MORE animated and start talking with their hands (you can hear it). Conversational tendency: escalation."
},
"call_history": [
{
"summary": "Chip called from a laundromat at midnight after receiving an email from a Guatemalan lawyer claiming his adopted 8-year-old daughter may have been stolen from her birth mother, with a photo showing a woman with his daughter's exact crooked smile. The host advised him not to panic, treat the information as suspect until verified by a lawyer, wait to tell both his girlfriend and daughter until he knows more facts, and reminded him that fake photos are easy to create and this could be a scam.",
"timestamp": 1772786610.885828
},
{
"summary": "Chip called about discovering his adopted daughter may have been stolen from her birth mother in Guatemala, and he's paralyzed about telling his girlfriend Teresa, fearing it will end their already rocky relationship. He's anxious about the timing and the birth mother's request to meet their daughter, but the host advised him to take his time, have the conversation with Teresa, and make decisions together as parents.",
"timestamp": 1772962156.544322
},
{
"summary": "The caller, **Chip**, shared his emotional turmoil over discovering that his **adopted daughter\u2019s birth mother** may have resurfaced after receiving an unverified email with a photo that eerily matched his daughter\u2019s features. His girlfriend, **Teresa**, had known about the email for **three weeks** but kept it from him, leaving him feeling betrayed and overwhelmed. While Chip wants to **verify the claim legally before acting**, Teresa insists on **immediately flying to Guatemala with their daughter** to meet the woman, dismissing his fears as avoidance. The conversation escalated into a heated debate about **trust, safety, and extreme measures**\u2014with the host, Luke, urging Chip to **file a restraining order** if Teresa refuses to back down, warning of potential dangers in Guatemala. Chip, torn between **protecting his family and avoiding a nuclear confrontation**, vowed to try reasoning with Teresa one last time before she leaves for work. The call was charged with **fear, frustration, and the weight of a decision that could reshape their family forever**.",
"timestamp": 1773226361.4859362
}
],
"last_call": 1773226361.4859362,
"created_at": 1772786610.8858292
},
{
"id": "bbb20b67",
"name": "Angie",
"gender": "female",
"age": 28,
"job": "watching her coveralls tumble dry and trying to decide if she should drive the three hours to Tucson tomorrow for her mom's birthday or keep pretending her brother doesn't exist",
"location": "in unknown",
"personality_traits": [],
"voice": "Julia",
"stable_seeds": {
"style": "COMMUNICATION STYLE: Bone dry. Says devastating things with zero inflection. Their humor sneaks up on you \u2014 you're not sure if they're joking until three seconds after they finish talking. Short, precise sentences. Never raises their voice. Energy level: low-medium. When pushed back on, they respond with one calm sentence that somehow makes the other person feel stupid. Conversational tendency: underreaction."
},
"call_history": [
{
"summary": "Angie's dying mother wants her to have birthday dinner with her estranged brother Derek tomorrow, whom she hasn't spoken to in two years after he told their mother her cancer was \"God's way of getting her attention\" for voting for Biden. Despite her fear that Derek will say something hurtful during dinner and her past trauma from staying silent around him, Angie agrees to go and share cake with her mother, deciding to buy the relighting candles her mom loved when they were kids.",
"timestamp": 1772862907.314721
},
{
"summary": "Angie called back after having the birthday cake dinner with her dying mom, which went well, but her brother Derek cornered her afterward accusing her of convincing their mom to stop cancer treatment and demanding they both attend the next doctor's appointment together. Luke advised her to talk directly to her mom about what she actually wants and encouraged Angie to have real conversations about her mom's end-of-life thoughts while she still can, which Angie agreed to do the next morning.",
"timestamp": 1773296210.170752
}
],
"last_call": 1773296210.170753,
"created_at": 1772862907.314722
},
{
"id": "3721ebf2",
"name": "Maxine",
"gender": "female",
"age": 26,
"job": "and the math doesn't add up\u2014there's a six-foot gap between her bedroom and the bathroom that shouldn't exist, and when she finally pried open the door she thought led to a closet, it was just drywall, fresh enough that she could smell the joint compound",
"location": "in unknown",
"personality_traits": [],
"voice": "Kelsey",
"stable_seeds": {
"style": "COMMUNICATION STYLE: Quiet, a little nervous. Short sentences, lots of pauses. Doesn't volunteer information \u2014 you have to pull it out of them. When they do open up it comes out in a rush. Gets flustered by direct questions. Tends to backtrack and qualify everything they say. Energy level: low. When pushed back on, they fold quickly and agree even if they don't mean it. Conversational tendency: understatement."
},
"call_history": [
{
"summary": "Maxine called after discovering a hidden 6-foot space behind a sealed door in her house, and when she cut through the drywall, she found multiple boxes filled with banded stacks of $20 bills from the 1990s\u2014potentially tens or hundreds of thousands of dollars left by the previous owner who died there. She struggled with whether to keep the money or contact the deceased owner's family, with the host arguing it was legally hers since she bought the house \"as-is,\" though Maxine remained conflicted about what felt morally right.",
"timestamp": 1773395481.8522182
}
],
"last_call": 1773395481.8522189,
"created_at": 1773395481.8522189
} }
] ]
} }

View File

@@ -247,12 +247,14 @@ def _run_db_query(sql):
db_pass = os.getenv("CASTOPOD_DB_PASS", "") db_pass = os.getenv("CASTOPOD_DB_PASS", "")
if docker_bin: if docker_bin:
cmd = [docker_bin, "exec", "-i", CASTOPOD_DB_CONTAINER, # Pass password via MYSQL_PWD env var instead of command line (not visible in ps)
"mysql", "-u", "castopod", f"-p{db_pass}", "castopod", "-N"] cmd = [docker_bin, "exec", "-i", "-e", f"MYSQL_PWD={db_pass}",
CASTOPOD_DB_CONTAINER,
"mysql", "-u", "castopod", "castopod", "-N"]
else: else:
cmd = [ cmd = [
"ssh", "-p", NAS_SSH_PORT, NAS_SSH, "ssh", "-p", NAS_SSH_PORT, NAS_SSH,
f"{DOCKER_BIN} exec -i {CASTOPOD_DB_CONTAINER} mysql -u castopod -p{db_pass} castopod -N" f"{DOCKER_BIN} exec -i -e MYSQL_PWD={db_pass} {CASTOPOD_DB_CONTAINER} mysql -u castopod castopod -N"
] ]
try: try:
proc = subprocess.run(cmd, input=sql, capture_output=True, text=True, timeout=30) proc = subprocess.run(cmd, input=sql, capture_output=True, text=True, timeout=30)

View File

@@ -432,7 +432,7 @@ def mix_stems(stems: dict[str, np.ndarray],
if name == "music" and music_width > 0: if name == "music" and music_width > 0:
# Widen music: delay right channel by ~0.5ms for Haas effect # Widen music: delay right channel by ~0.5ms for Haas effect
delay_samples = int(0.0005 * 44100) # ~22 samples at 44.1kHz delay_samples = int(0.0005 * sr) # ~22 samples at target sample rate
left += signal * (1 + music_width * 0.5) left += signal * (1 + music_width * 0.5)
right_delayed = np.zeros_like(signal) right_delayed = np.zeros_like(signal)
right_delayed[delay_samples:] = signal[:-delay_samples] if delay_samples > 0 else signal right_delayed[delay_samples:] = signal[:-delay_samples] if delay_samples > 0 else signal

View File

@@ -31,8 +31,8 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
from dotenv import load_dotenv from dotenv import load_dotenv
class TLSAdapter(HTTPAdapter): class CastopodTLSAdapter(HTTPAdapter):
"""Adapter to handle servers with older TLS configurations.""" """Adapter for Castopod's older TLS configuration (scoped to Castopod only)."""
def init_poolmanager(self, *args, **kwargs): def init_poolmanager(self, *args, **kwargs):
ctx = create_urllib3_context() ctx = create_urllib3_context()
ctx.set_ciphers('DEFAULT@SECLEVEL=1') ctx.set_ciphers('DEFAULT@SECLEVEL=1')
@@ -46,9 +46,10 @@ class TLSAdapter(HTTPAdapter):
return super().send(*args, **kwargs) return super().send(*args, **kwargs)
# Use a session with TLS compatibility for all Castopod requests # TLS compatibility only for Castopod domain — all other HTTPS uses default secure verification
_session = requests.Session() _session = requests.Session()
_session.mount('https://', TLSAdapter()) _CASTOPOD_ORIGIN = "https://podcast.macneilmediagroup.com"
_session.mount(_CASTOPOD_ORIGIN, CastopodTLSAdapter())
# Load environment variables # Load environment variables
load_dotenv(Path(__file__).parent / ".env") load_dotenv(Path(__file__).parent / ".env")
@@ -485,7 +486,7 @@ def _create_episode_direct(audio_path: str, metadata: dict, episode_number: int,
# Copy SQL into MariaDB container and execute # Copy SQL into MariaDB container and execute
run_ssh_command(f'{DOCKER_PATH} cp {nas_sql_path} {MARIADB_CONTAINER}:/tmp/_insert.sql') run_ssh_command(f'{DOCKER_PATH} cp {nas_sql_path} {MARIADB_CONTAINER}:/tmp/_insert.sql')
exec_cmd = f'{DOCKER_PATH} exec {MARIADB_CONTAINER} sh -c "mysql -u {DB_USER} -p{DB_PASS} {DB_NAME} -N < /tmp/_insert.sql"' exec_cmd = f'{DOCKER_PATH} exec {MARIADB_CONTAINER} sh -c "mysql --defaults-extra-file=/tmp/.my.cnf -u {DB_USER} {DB_NAME} -N < /tmp/_insert.sql"'
success, output = run_ssh_command(exec_cmd, timeout=30) success, output = run_ssh_command(exec_cmd, timeout=30)
run_ssh_command(f'rm -f {nas_sql_path}') run_ssh_command(f'rm -f {nas_sql_path}')
run_ssh_command(f'{DOCKER_PATH} exec {MARIADB_CONTAINER} rm -f /tmp/_insert.sql') run_ssh_command(f'{DOCKER_PATH} exec {MARIADB_CONTAINER} rm -f /tmp/_insert.sql')
@@ -496,7 +497,7 @@ def _create_episode_direct(audio_path: str, metadata: dict, episode_number: int,
episode_id = int(output.strip().split('\n')[-1]) episode_id = int(output.strip().split('\n')[-1])
# Get the audio media ID for CDN upload # Get the audio media ID for CDN upload
audio_id_cmd = f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} {DB_NAME} -N -e "SELECT audio_id FROM cp_episodes WHERE id = {episode_id};"' audio_id_cmd = f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql --defaults-extra-file=/tmp/.my.cnf -u {DB_USER} {DB_NAME} -N -e "SELECT audio_id FROM cp_episodes WHERE id = {episode_id};"'
success, audio_id_str = run_ssh_command(audio_id_cmd) success, audio_id_str = run_ssh_command(audio_id_cmd)
audio_id = int(audio_id_str.strip()) if success else None audio_id = int(audio_id_str.strip()) if success else None
if audio_id: if audio_id:
@@ -582,10 +583,29 @@ def run_ssh_command(command: str, timeout: int = 30) -> tuple[bool, str]:
return False, str(e) return False, str(e)
def _setup_mysql_auth():
"""Create a temp MySQL defaults file inside the MariaDB container.
This avoids passing the DB password on the command line (visible in ps)."""
content = f"[client]\npassword={DB_PASS}\n"
b64 = base64.b64encode(content.encode()).decode()
cmd = (f'{DOCKER_PATH} exec {MARIADB_CONTAINER} sh -c '
f'"echo {b64} | base64 -d > /tmp/.my.cnf && chmod 600 /tmp/.my.cnf"')
success, output = run_ssh_command(cmd)
if not success:
print(f"Warning: Failed to set up MySQL auth file: {output}")
return False
return True
def _cleanup_mysql_auth():
"""Remove the temp MySQL defaults file from the MariaDB container."""
run_ssh_command(f'{DOCKER_PATH} exec {MARIADB_CONTAINER} rm -f /tmp/.my.cnf')
def _check_episode_exists_in_db(episode_number: int) -> bool | None: def _check_episode_exists_in_db(episode_number: int) -> bool | None:
"""Check if an episode with this number already exists in Castopod DB. """Check if an episode with this number already exists in Castopod DB.
Returns True/False on success, None if the check itself failed.""" Returns True/False on success, None if the check itself failed."""
cmd = (f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} {DB_NAME} ' cmd = (f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql --defaults-extra-file=/tmp/.my.cnf -u {DB_USER} {DB_NAME} '
f'-N -e "SELECT COUNT(*) FROM cp_episodes WHERE number = {episode_number};"') f'-N -e "SELECT COUNT(*) FROM cp_episodes WHERE number = {episode_number};"')
success, output = run_ssh_command(cmd) success, output = run_ssh_command(cmd)
if success and output.strip(): if success and output.strip():
@@ -685,7 +705,7 @@ def upload_transcript_to_castopod(episode_slug: str, episode_id: int, transcript
f"uploaded_by, updated_by, uploaded_at, updated_at) VALUES " f"uploaded_by, updated_by, uploaded_at, updated_at) VALUES "
f"('{remote_path}', {file_size}, '{mimetype}', {metadata_sql_escaped}, 'transcript', 1, 1, NOW(), NOW())" f"('{remote_path}', {file_size}, '{mimetype}', {metadata_sql_escaped}, 'transcript', 1, 1, NOW(), NOW())"
) )
db_cmd = f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} {DB_NAME} -e "{insert_sql}; SELECT LAST_INSERT_ID();"' db_cmd = f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql --defaults-extra-file=/tmp/.my.cnf -u {DB_USER} {DB_NAME} -e "{insert_sql}; SELECT LAST_INSERT_ID();"'
success, output = run_ssh_command(db_cmd) success, output = run_ssh_command(db_cmd)
if not success: if not success:
print(f" Warning: Failed to insert transcript in database: {output}") print(f" Warning: Failed to insert transcript in database: {output}")
@@ -699,7 +719,7 @@ def upload_transcript_to_castopod(episode_slug: str, episode_id: int, transcript
return False return False
update_sql = f"UPDATE cp_episodes SET transcript_id = {media_id} WHERE id = {episode_id}" update_sql = f"UPDATE cp_episodes SET transcript_id = {media_id} WHERE id = {episode_id}"
db_cmd = f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} {DB_NAME} -e "{update_sql}"' db_cmd = f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql --defaults-extra-file=/tmp/.my.cnf -u {DB_USER} {DB_NAME} -e "{update_sql}"'
success, output = run_ssh_command(db_cmd) success, output = run_ssh_command(db_cmd)
if not success: if not success:
print(f" Warning: Failed to link transcript to episode: {output}") print(f" Warning: Failed to link transcript to episode: {output}")
@@ -739,7 +759,7 @@ def upload_chapters_to_castopod(episode_slug: str, episode_id: int, chapters_pat
# Insert into media table # Insert into media table
insert_sql = f"""INSERT INTO cp_media (file_key, file_size, file_mimetype, type, uploaded_by, updated_by, uploaded_at, updated_at) insert_sql = f"""INSERT INTO cp_media (file_key, file_size, file_mimetype, type, uploaded_by, updated_by, uploaded_at, updated_at)
VALUES ('{remote_path}', {file_size}, 'application/json', 'chapters', 1, 1, NOW(), NOW())""" VALUES ('{remote_path}', {file_size}, 'application/json', 'chapters', 1, 1, NOW(), NOW())"""
db_cmd = f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} {DB_NAME} -e "{insert_sql}; SELECT LAST_INSERT_ID();"' db_cmd = f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql --defaults-extra-file=/tmp/.my.cnf -u {DB_USER} {DB_NAME} -e "{insert_sql}; SELECT LAST_INSERT_ID();"'
success, output = run_ssh_command(db_cmd) success, output = run_ssh_command(db_cmd)
if not success: if not success:
print(f" Warning: Failed to insert chapters in database: {output}") print(f" Warning: Failed to insert chapters in database: {output}")
@@ -755,7 +775,7 @@ def upload_chapters_to_castopod(episode_slug: str, episode_id: int, chapters_pat
# Link chapters to episode # Link chapters to episode
update_sql = f"UPDATE cp_episodes SET chapters_id = {media_id} WHERE id = {episode_id}" update_sql = f"UPDATE cp_episodes SET chapters_id = {media_id} WHERE id = {episode_id}"
db_cmd = f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} {DB_NAME} -e "{update_sql}"' db_cmd = f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql --defaults-extra-file=/tmp/.my.cnf -u {DB_USER} {DB_NAME} -e "{update_sql}"'
success, output = run_ssh_command(db_cmd) success, output = run_ssh_command(db_cmd)
if not success: if not success:
print(f" Warning: Failed to link chapters to episode: {output}") print(f" Warning: Failed to link chapters to episode: {output}")
@@ -822,7 +842,7 @@ def sync_episode_media_to_bunny(episode_id: int, already_uploaded: set):
f"UNION ALL SELECT transcript_id FROM cp_episodes WHERE id = {ep_id} AND transcript_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)" 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};"' cmd = f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql --defaults-extra-file=/tmp/.my.cnf -u {DB_USER} {DB_NAME} -N -e "{query};"'
success, output = run_ssh_command(cmd) success, output = run_ssh_command(cmd)
if not success or not output: if not success or not output:
return return
@@ -1209,7 +1229,7 @@ def upload_to_youtube(audio_path: str, metadata: dict, chapters: list,
def get_next_episode_number() -> int: def get_next_episode_number() -> int:
"""Get the next episode number from Castopod (DB first, API fallback).""" """Get the next episode number from Castopod (DB first, API fallback)."""
# Query DB directly — the REST API is unreliable # Query DB directly — the REST API is unreliable
cmd = (f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} {DB_NAME} ' cmd = (f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql --defaults-extra-file=/tmp/.my.cnf -u {DB_USER} {DB_NAME} '
f'-N -e "SELECT COALESCE(MAX(number), 0) FROM cp_episodes WHERE podcast_id = {PODCAST_ID};"') f'-N -e "SELECT COALESCE(MAX(number), 0) FROM cp_episodes WHERE podcast_id = {PODCAST_ID};"')
success, output = run_ssh_command(cmd) success, output = run_ssh_command(cmd)
if success and output.strip(): if success and output.strip():
@@ -1286,6 +1306,9 @@ def main():
except Exception: except Exception:
pass pass
# Set up MySQL auth (avoids password on command line)
_setup_mysql_auth()
# Determine episode number # Determine episode number
if args.episode_number: if args.episode_number:
episode_number = args.episode_number episode_number = args.episode_number
@@ -1299,12 +1322,14 @@ def main():
if exists is None: if exists is None:
print(f"Error: Could not reach Castopod DB to check for duplicates. " print(f"Error: Could not reach Castopod DB to check for duplicates. "
f"Aborting to prevent duplicate uploads. Fix NAS connectivity and retry.") f"Aborting to prevent duplicate uploads. Fix NAS connectivity and retry.")
_cleanup_mysql_auth()
lock_fp.close() lock_fp.close()
LOCK_FILE.unlink(missing_ok=True) LOCK_FILE.unlink(missing_ok=True)
sys.exit(1) sys.exit(1)
if exists: if exists:
print(f"Error: Episode {episode_number} already exists in Castopod. " print(f"Error: Episode {episode_number} already exists in Castopod. "
f"Use --episode-number to specify a different number, or remove the existing episode first.") f"Use --episode-number to specify a different number, or remove the existing episode first.")
_cleanup_mysql_auth()
lock_fp.close() lock_fp.close()
LOCK_FILE.unlink(missing_ok=True) LOCK_FILE.unlink(missing_ok=True)
sys.exit(1) sys.exit(1)
@@ -1374,20 +1399,38 @@ def main():
episode = create_episode(str(audio_path), metadata, episode_number, duration=transcript["duration"]) episode = create_episode(str(audio_path), metadata, episode_number, duration=transcript["duration"])
_mark_step_done(episode_number, "castopod", {"episode_id": episode["id"], "slug": episode.get("slug")}) _mark_step_done(episode_number, "castopod", {"episode_id": episode["id"], "slug": episode.get("slug")})
# Step 3.5: Upload to BunnyCDN # Step 3.5: Upload chapters and transcript to Castopod
print("[3.5/5] Uploading to BunnyCDN...") # (must happen before CDN sync so media records exist for syncing)
chapters_uploaded = upload_chapters_to_castopod(
episode["slug"],
episode["id"],
str(chapters_path)
)
transcript_uploaded = upload_transcript_to_castopod(
episode["slug"],
episode["id"],
str(srt_path)
)
# Step 3.7: Upload to BunnyCDN
# All media must be on CDN before publish triggers RSS rebuild
print("[3.7/5] Uploading to BunnyCDN...")
uploaded_keys = set() uploaded_keys = set()
# Audio: query file_key from DB, then upload to CDN # Audio: query file_key from DB, then upload to CDN
ep_id = episode["id"] 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};"' audio_media_cmd = f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql --defaults-extra-file=/tmp/.my.cnf -u {DB_USER} {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) success, audio_file_key = run_ssh_command(audio_media_cmd)
if success and audio_file_key: if success and audio_file_key:
audio_file_key = audio_file_key.strip() audio_file_key = audio_file_key.strip()
if direct_upload: if direct_upload:
# Direct upload: we have the original file locally, upload straight to CDN # Direct upload: we have the original file locally, upload straight to CDN
print(f" Uploading audio to BunnyCDN") print(f" Uploading audio to BunnyCDN")
upload_to_bunny(str(audio_path), f"media/{audio_file_key}", "audio/mpeg") if upload_to_bunny(str(audio_path), f"media/{audio_file_key}", "audio/mpeg"):
uploaded_keys.add(audio_file_key)
else:
print(f" Warning: Audio CDN upload failed, will be served from Castopod")
else: else:
# API upload: download Castopod's copy (ensures byte-exact match with RSS metadata) # API upload: download Castopod's copy (ensures byte-exact match with RSS metadata)
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp:
@@ -1396,13 +1439,18 @@ def main():
print(f" Downloading from Castopod: {audio_file_key}") print(f" Downloading from Castopod: {audio_file_key}")
if download_from_castopod(audio_file_key, tmp_audio): if download_from_castopod(audio_file_key, tmp_audio):
print(f" Uploading audio to BunnyCDN") print(f" Uploading audio to BunnyCDN")
upload_to_bunny(tmp_audio, f"media/{audio_file_key}", "audio/mpeg") if upload_to_bunny(tmp_audio, f"media/{audio_file_key}", "audio/mpeg"):
uploaded_keys.add(audio_file_key)
else:
print(f" Warning: Audio CDN upload failed, will be served from Castopod")
else: else:
print(f" Castopod download failed, uploading original file") print(f" Castopod download failed, uploading original file")
upload_to_bunny(str(audio_path), f"media/{audio_file_key}", "audio/mpeg") if upload_to_bunny(str(audio_path), f"media/{audio_file_key}", "audio/mpeg"):
uploaded_keys.add(audio_file_key)
else:
print(f" Warning: Audio CDN upload failed, will be served from Castopod")
finally: finally:
Path(tmp_audio).unlink(missing_ok=True) Path(tmp_audio).unlink(missing_ok=True)
uploaded_keys.add(audio_file_key)
else: else:
print(f" Error: Could not determine audio file_key from Castopod DB") print(f" Error: Could not determine audio file_key from Castopod DB")
print(f" Audio will be served from Castopod directly (not CDN)") print(f" Audio will be served from Castopod directly (not CDN)")
@@ -1410,7 +1458,7 @@ def main():
# Chapters # Chapters
chapters_key = f"podcasts/{PODCAST_HANDLE}/{episode['slug']}-chapters.json" chapters_key = f"podcasts/{PODCAST_HANDLE}/{episode['slug']}-chapters.json"
print(f" Uploading chapters to BunnyCDN") print(f" Uploading chapters to BunnyCDN")
upload_to_bunny(str(chapters_path), f"media/{chapters_key}") if upload_to_bunny(str(chapters_path), f"media/{chapters_key}"):
uploaded_keys.add(chapters_key) uploaded_keys.add(chapters_key)
# Transcript # Transcript
@@ -1427,7 +1475,12 @@ def main():
# Add to sitemap # Add to sitemap
add_episode_to_sitemap(episode["slug"]) add_episode_to_sitemap(episode["slug"])
# Sync any remaining episode media to BunnyCDN (cover art, etc.)
print(" Syncing remaining episode media to CDN...")
sync_episode_media_to_bunny(episode["id"], uploaded_keys)
# Step 4: Publish via API (triggers RSS rebuild, federation, etc.) # Step 4: Publish via API (triggers RSS rebuild, federation, etc.)
# All media is now on CDN, so RSS links will resolve immediately
try: try:
published = publish_episode(episode["id"]) published = publish_episode(episode["id"])
if "slug" in published: if "slug" in published:
@@ -1438,24 +1491,6 @@ def main():
else: else:
raise raise
# Step 4.5: Upload chapters and transcript via SSH
chapters_uploaded = upload_chapters_to_castopod(
episode["slug"],
episode["id"],
str(chapters_path)
)
# Upload SRT transcript to Castopod (preferred for podcast apps)
transcript_uploaded = upload_transcript_to_castopod(
episode["slug"],
episode["id"],
str(srt_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: Deploy website (transcript + sitemap must be live before social links go out) # Step 5: Deploy website (transcript + sitemap must be live before social links go out)
print("[5/5] Deploying website...") print("[5/5] Deploying website...")
project_dir = Path(__file__).parent project_dir = Path(__file__).parent
@@ -1521,6 +1556,9 @@ def main():
) )
print(" Server restarted on port 8000") print(" Server restarted on port 8000")
# Clean up MySQL auth file
_cleanup_mysql_auth()
# Release lock # Release lock
lock_fp.close() lock_fp.close()
LOCK_FILE.unlink(missing_ok=True) LOCK_FILE.unlink(missing_ok=True)

View File

@@ -1347,6 +1347,15 @@ a:hover {
box-shadow: 0 4px 24px rgba(232, 121, 29, 0.12); box-shadow: 0 4px 24px rgba(232, 121, 29, 0.12);
} }
.clip-card-thumb {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.clip-card-inner iframe { .clip-card-inner iframe {
position: absolute; position: absolute;
top: 0; top: 0;

View File

@@ -1,4 +1,13 @@
[ [
{
"title": "Nobody's Potato Salad Is Good",
"description": "Luke goes OFF on workplace potlucks: 'Nobody's potato salad is f***ing good, alright? Everything at a potluck is gross. Just take everybody to McDonald's.'",
"episode_number": 34,
"clip_file": "clip-3-nobody-s-potato-salad-is-good.mp4",
"youtube_id": "re7C2woMUrA",
"featured": false,
"thumbnail": "images/clips/clip-3-nobody-s-potato-salad-is-good.jpg"
},
{ {
"title": "Man Obsessed With Dead Nun Loses Wife", "title": "Man Obsessed With Dead Nun Loses Wife",
"description": "Rodney couldn't stop talking about a dead nun who shared his wife's name. His wife was NOT amused.", "description": "Rodney couldn't stop talking about a dead nun who shared his wife's name. His wife was NOT amused.",

View File

@@ -2,24 +2,34 @@ const CLIPS_JSON_URL = '/data/clips.json';
const clipPlaySVG = '<svg viewBox="0 0 24 24" fill="#fff"><path d="M8 5v14l11-7z"/></svg>'; const clipPlaySVG = '<svg viewBox="0 0 24 24" fill="#fff"><path d="M8 5v14l11-7z"/></svg>';
function escapeHTML(str) {
const el = document.createElement('span');
el.textContent = str;
return el.innerHTML;
}
function renderClipCard(clip, featured) { function renderClipCard(clip, featured) {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'clip-card' + (featured ? ' clip-card-featured' : ''); card.className = 'clip-card' + (featured ? ' clip-card-featured' : '');
if (clip.youtube_id) card.dataset.youtubeId = clip.youtube_id;
const hasVideo = !!clip.youtube_id; const youtubeId = (clip.youtube_id || '').replace(/[^a-zA-Z0-9_-]/g, '');
const epLabel = clip.episode_number ? `Episode ${clip.episode_number}` : ''; if (youtubeId) card.dataset.youtubeId = youtubeId;
const hasVideo = !!youtubeId;
const epLabel = clip.episode_number ? `Episode ${Number(clip.episode_number)}` : '';
const title = escapeHTML(clip.title || '');
const desc = escapeHTML(clip.description || '');
const thumbStyle = clip.thumbnail const thumbImg = clip.thumbnail && /^[\w\/.-]+$/.test(clip.thumbnail)
? `style="background-image: url('/${clip.thumbnail}'); background-size: cover; background-position: center;"` ? `<img class="clip-card-thumb" src="/${clip.thumbnail}" alt="${title}" loading="lazy">`
: ''; : '';
card.innerHTML = ` card.innerHTML = `
<div class="clip-card-inner" ${thumbStyle}> <div class="clip-card-inner">
${thumbImg}
<div class="clip-card-overlay"> <div class="clip-card-overlay">
<span class="clip-episode-label">${epLabel}</span> <span class="clip-episode-label">${epLabel}</span>
<h3 class="clip-card-title">${clip.title || ''}</h3> <h3 class="clip-card-title">${title}</h3>
<p class="clip-card-desc">${clip.description || ''}</p> <p class="clip-card-desc">${desc}</p>
${hasVideo ? `<button class="clip-play-btn" aria-label="Play clip">${clipPlaySVG}</button>` : ''} ${hasVideo ? `<button class="clip-play-btn" aria-label="Play clip">${clipPlaySVG}</button>` : ''}
</div> </div>
</div> </div>
@@ -29,7 +39,7 @@ function renderClipCard(clip, featured) {
card.querySelector('.clip-play-btn').addEventListener('click', (e) => { card.querySelector('.clip-play-btn').addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
const inner = card.querySelector('.clip-card-inner'); const inner = card.querySelector('.clip-card-inner');
inner.innerHTML = `<iframe src="https://www.youtube-nocookie.com/embed/${clip.youtube_id}?autoplay=1&rel=0" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>`; inner.innerHTML = `<iframe src="https://www.youtube-nocookie.com/embed/${youtubeId}?autoplay=1&rel=0" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>`;
}); });
} }

View File

@@ -246,4 +246,10 @@
<changefreq>never</changefreq> <changefreq>never</changefreq>
<priority>0.7</priority> <priority>0.7</priority>
</url> </url>
<url>
<loc>https://lukeattheroost.com/episode.html?slug=episode-35-midnight-confessions-and-unexpected-revelations</loc>
<lastmod>2026-03-13</lastmod>
<changefreq>never</changefreq>
<priority>0.7</priority>
</url>
</urlset> </urlset>