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:
2063
backend/main.py
2063
backend/main.py
File diff suppressed because it is too large
Load Diff
@@ -600,6 +600,32 @@ async def generate_speech_chattts(text: str, voice_id: str) -> tuple[np.ndarray,
|
||||
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]:
|
||||
"""Generate speech using Inworld TTS API (high quality, natural voices)"""
|
||||
import httpx
|
||||
@@ -617,8 +643,9 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray,
|
||||
if not api_key:
|
||||
raise RuntimeError("INWORLD_API_KEY not set in environment")
|
||||
|
||||
speed = INWORLD_SPEED_OVERRIDES.get(voice, DEFAULT_INWORLD_SPEED)
|
||||
print(f"[Inworld TTS] Voice: {voice}, Speed: {speed}, Text: {text[:50]}...")
|
||||
base_speed = INWORLD_SPEED_OVERRIDES.get(voice, DEFAULT_INWORLD_SPEED)
|
||||
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"
|
||||
headers = {
|
||||
@@ -671,6 +698,20 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray,
|
||||
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 = {
|
||||
"kokoro": lambda text, vid: generate_speech_kokoro(text, vid),
|
||||
"f5tts": lambda text, vid: generate_speech_f5tts(text, vid),
|
||||
@@ -690,7 +731,8 @@ async def generate_speech(
|
||||
text: str,
|
||||
voice_id: str,
|
||||
phone_quality: str = "normal",
|
||||
apply_filter: bool = True
|
||||
apply_filter: bool = True,
|
||||
provider_override: str = None
|
||||
) -> bytes:
|
||||
"""
|
||||
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)
|
||||
phone_quality: Quality of phone filter ("none" to disable)
|
||||
apply_filter: Whether to apply phone filter
|
||||
provider_override: Override the global TTS provider for this call
|
||||
|
||||
Returns:
|
||||
Raw PCM audio bytes (16-bit signed int, 24kHz)
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
provider = settings.tts_provider
|
||||
print(f"[TTS] Provider: {provider}, Text: {text[:50]}...")
|
||||
provider = provider_override or settings.tts_provider
|
||||
print(f"[TTS] Provider: {provider}{' (override)' if provider_override else ''}, Text: {text[:50]}...")
|
||||
|
||||
gen_fn = _TTS_PROVIDERS.get(provider)
|
||||
if not gen_fn:
|
||||
|
||||
@@ -59,5 +59,22 @@
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,55 +1,5 @@
|
||||
{
|
||||
"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",
|
||||
"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.",
|
||||
"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
|
||||
},
|
||||
{
|
||||
@@ -139,35 +93,6 @@
|
||||
"last_call": 1772959484.6798599,
|
||||
"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",
|
||||
"name": "Otis",
|
||||
@@ -213,6 +138,81 @@
|
||||
],
|
||||
"last_call": 1773219255.9161851,
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -247,12 +247,14 @@ def _run_db_query(sql):
|
||||
|
||||
db_pass = os.getenv("CASTOPOD_DB_PASS", "")
|
||||
if docker_bin:
|
||||
cmd = [docker_bin, "exec", "-i", CASTOPOD_DB_CONTAINER,
|
||||
"mysql", "-u", "castopod", f"-p{db_pass}", "castopod", "-N"]
|
||||
# Pass password via MYSQL_PWD env var instead of command line (not visible in ps)
|
||||
cmd = [docker_bin, "exec", "-i", "-e", f"MYSQL_PWD={db_pass}",
|
||||
CASTOPOD_DB_CONTAINER,
|
||||
"mysql", "-u", "castopod", "castopod", "-N"]
|
||||
else:
|
||||
cmd = [
|
||||
"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:
|
||||
proc = subprocess.run(cmd, input=sql, capture_output=True, text=True, timeout=30)
|
||||
|
||||
@@ -432,7 +432,7 @@ def mix_stems(stems: dict[str, np.ndarray],
|
||||
|
||||
if name == "music" and music_width > 0:
|
||||
# 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)
|
||||
right_delayed = np.zeros_like(signal)
|
||||
right_delayed[delay_samples:] = signal[:-delay_samples] if delay_samples > 0 else signal
|
||||
|
||||
@@ -31,8 +31,8 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
class TLSAdapter(HTTPAdapter):
|
||||
"""Adapter to handle servers with older TLS configurations."""
|
||||
class CastopodTLSAdapter(HTTPAdapter):
|
||||
"""Adapter for Castopod's older TLS configuration (scoped to Castopod only)."""
|
||||
def init_poolmanager(self, *args, **kwargs):
|
||||
ctx = create_urllib3_context()
|
||||
ctx.set_ciphers('DEFAULT@SECLEVEL=1')
|
||||
@@ -46,9 +46,10 @@ class TLSAdapter(HTTPAdapter):
|
||||
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.mount('https://', TLSAdapter())
|
||||
_CASTOPOD_ORIGIN = "https://podcast.macneilmediagroup.com"
|
||||
_session.mount(_CASTOPOD_ORIGIN, CastopodTLSAdapter())
|
||||
|
||||
# Load environment variables
|
||||
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
|
||||
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)
|
||||
run_ssh_command(f'rm -f {nas_sql_path}')
|
||||
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])
|
||||
# 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)
|
||||
audio_id = int(audio_id_str.strip()) if success else None
|
||||
if audio_id:
|
||||
@@ -582,10 +583,29 @@ def run_ssh_command(command: str, timeout: int = 30) -> tuple[bool, str]:
|
||||
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:
|
||||
"""Check if an episode with this number already exists in Castopod DB.
|
||||
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};"')
|
||||
success, output = run_ssh_command(cmd)
|
||||
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"('{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)
|
||||
if not success:
|
||||
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
|
||||
|
||||
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)
|
||||
if not success:
|
||||
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_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())"""
|
||||
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)
|
||||
if not success:
|
||||
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
|
||||
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)
|
||||
if not success:
|
||||
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 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)
|
||||
if not success or not output:
|
||||
return
|
||||
@@ -1209,7 +1229,7 @@ def upload_to_youtube(audio_path: str, metadata: dict, chapters: list,
|
||||
def get_next_episode_number() -> int:
|
||||
"""Get the next episode number from Castopod (DB first, API fallback)."""
|
||||
# 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};"')
|
||||
success, output = run_ssh_command(cmd)
|
||||
if success and output.strip():
|
||||
@@ -1286,6 +1306,9 @@ def main():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Set up MySQL auth (avoids password on command line)
|
||||
_setup_mysql_auth()
|
||||
|
||||
# Determine episode number
|
||||
if args.episode_number:
|
||||
episode_number = args.episode_number
|
||||
@@ -1299,12 +1322,14 @@ def main():
|
||||
if exists is None:
|
||||
print(f"Error: Could not reach Castopod DB to check for duplicates. "
|
||||
f"Aborting to prevent duplicate uploads. Fix NAS connectivity and retry.")
|
||||
_cleanup_mysql_auth()
|
||||
lock_fp.close()
|
||||
LOCK_FILE.unlink(missing_ok=True)
|
||||
sys.exit(1)
|
||||
if exists:
|
||||
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.")
|
||||
_cleanup_mysql_auth()
|
||||
lock_fp.close()
|
||||
LOCK_FILE.unlink(missing_ok=True)
|
||||
sys.exit(1)
|
||||
@@ -1374,20 +1399,38 @@ def main():
|
||||
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")})
|
||||
|
||||
# Step 3.5: Upload to BunnyCDN
|
||||
print("[3.5/5] Uploading to BunnyCDN...")
|
||||
# Step 3.5: Upload chapters and transcript to Castopod
|
||||
# (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()
|
||||
|
||||
# Audio: query file_key from DB, then upload to CDN
|
||||
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)
|
||||
if success and audio_file_key:
|
||||
audio_file_key = audio_file_key.strip()
|
||||
if direct_upload:
|
||||
# Direct upload: we have the original file locally, upload straight to CDN
|
||||
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:
|
||||
# API upload: download Castopod's copy (ensures byte-exact match with RSS metadata)
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp:
|
||||
@@ -1396,13 +1439,18 @@ def main():
|
||||
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")
|
||||
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:
|
||||
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:
|
||||
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)")
|
||||
@@ -1410,7 +1458,7 @@ def main():
|
||||
# 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}")
|
||||
if upload_to_bunny(str(chapters_path), f"media/{chapters_key}"):
|
||||
uploaded_keys.add(chapters_key)
|
||||
|
||||
# Transcript
|
||||
@@ -1427,7 +1475,12 @@ def main():
|
||||
# Add to sitemap
|
||||
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.)
|
||||
# All media is now on CDN, so RSS links will resolve immediately
|
||||
try:
|
||||
published = publish_episode(episode["id"])
|
||||
if "slug" in published:
|
||||
@@ -1438,24 +1491,6 @@ def main():
|
||||
else:
|
||||
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)
|
||||
print("[5/5] Deploying website...")
|
||||
project_dir = Path(__file__).parent
|
||||
@@ -1521,6 +1556,9 @@ def main():
|
||||
)
|
||||
print(" Server restarted on port 8000")
|
||||
|
||||
# Clean up MySQL auth file
|
||||
_cleanup_mysql_auth()
|
||||
|
||||
# Release lock
|
||||
lock_fp.close()
|
||||
LOCK_FILE.unlink(missing_ok=True)
|
||||
|
||||
@@ -1347,6 +1347,15 @@ a:hover {
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
@@ -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",
|
||||
"description": "Rodney couldn't stop talking about a dead nun who shared his wife's name. His wife was NOT amused.",
|
||||
|
||||
@@ -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>';
|
||||
|
||||
function escapeHTML(str) {
|
||||
const el = document.createElement('span');
|
||||
el.textContent = str;
|
||||
return el.innerHTML;
|
||||
}
|
||||
|
||||
function renderClipCard(clip, featured) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'clip-card' + (featured ? ' clip-card-featured' : '');
|
||||
if (clip.youtube_id) card.dataset.youtubeId = clip.youtube_id;
|
||||
|
||||
const hasVideo = !!clip.youtube_id;
|
||||
const epLabel = clip.episode_number ? `Episode ${clip.episode_number}` : '';
|
||||
const youtubeId = (clip.youtube_id || '').replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
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
|
||||
? `style="background-image: url('/${clip.thumbnail}'); background-size: cover; background-position: center;"`
|
||||
const thumbImg = clip.thumbnail && /^[\w\/.-]+$/.test(clip.thumbnail)
|
||||
? `<img class="clip-card-thumb" src="/${clip.thumbnail}" alt="${title}" loading="lazy">`
|
||||
: '';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="clip-card-inner" ${thumbStyle}>
|
||||
<div class="clip-card-inner">
|
||||
${thumbImg}
|
||||
<div class="clip-card-overlay">
|
||||
<span class="clip-episode-label">${epLabel}</span>
|
||||
<h3 class="clip-card-title">${clip.title || ''}</h3>
|
||||
<p class="clip-card-desc">${clip.description || ''}</p>
|
||||
<h3 class="clip-card-title">${title}</h3>
|
||||
<p class="clip-card-desc">${desc}</p>
|
||||
${hasVideo ? `<button class="clip-play-btn" aria-label="Play clip">${clipPlaySVG}</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
@@ -29,7 +39,7 @@ function renderClipCard(clip, featured) {
|
||||
card.querySelector('.clip-play-btn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
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>`;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -246,4 +246,10 @@
|
||||
<changefreq>never</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user