Full app audit: 24 fixes across backend, frontend, infra, content, social

Critical fixes:
- Fix hangup-during-respond crash (null caller guard)
- Fix double-click caller race condition
- Stem recorder: non-daemon thread, disk error handling, 30s flush timeout
- Frontend startCall() error handling

High priority:
- Devon: filter tool errors from speech, shorter monitor prompt, 30s interval
- TTS ghost message fix (add to history after TTS, not before)
- Expand banned phrase list (12 new phrases)
- Increase returning callers from 1 to 2 per session
- Platform-tailored social posts with staggered scheduling
- YouTube dynamic tags from episode content
- Social post retry logic (2 attempts, 5s delay)
- Frontend: error handling on all raw fetch calls

Medium:
- stem_recorder null check race (local var capture in audio.py)
- Reactive shape directive expanded
- REACT TO LUKE moved higher in caller prompt
- Devon tenure updated ("few weeks" not "first day")
- D shortcut Escape to unfocus
- Volume slider debounced (150ms)
- Settings modal widened to 550px
- Backup script (daily MariaDB dump + data/ rsync to NAS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 14:57:50 -06:00
parent 5e98ed0e11
commit 3dd6a83c68
9 changed files with 492 additions and 188 deletions
+34 -11
View File
@@ -187,7 +187,7 @@ def _randomize_callers():
# Get returning callers first so we can exclude their names from random pool
returning = []
try:
returning = regular_caller_service.get_returning_callers(1)
returning = regular_caller_service.get_returning_callers(2)
except Exception as e:
print(f"[Regulars] Failed to get returning callers: {e}")
@@ -5990,9 +5990,15 @@ GO WHERE THE HOST TAKES YOU — up to a point. You're cooperative and engaged UN
"reactive": """YOUR STORY: You heard a caller earlier tonight and you HAVE to say something. Maybe they reminded you of your own situation. Maybe you think they were dead wrong. Maybe you think Luke was too easy on them or too hard. Maybe their story triggered something in you that you weren't planning to talk about.
Lead with the previous caller: "That guy who called about [X]? I need to say something about that." Then pivot to YOUR connection your own story, your own experience, your own take. The previous caller is the entry point, but YOUR story is the call.
Lead with the previous caller name them or describe their situation: "That guy who called about [X]? I need to say something about that." Your opening should make it clear which caller set you off and WHY.
Don't just comment on the previous caller like a pundit. Have skin in the game. The reason their call bothered you is because it connects to something real in your life.""",
HOW TO PIVOT TO YOUR STORY: The previous caller is the door, but YOUR story is the room. After your initial reaction (1-2 sentences max), pivot with a personal connection: "because the same thing happened to me" or "because I was the OTHER person in that situation" or "because that's EXACTLY the kind of thinking that ruined my marriage." The strongest pivots put you on the opposite side of the previous caller's story — you're the landlord they were complaining about, you're the ex-wife, you're the person who did the thing they're upset about. Disagreement and "the other side of the story" make better radio than agreement.
DON'T be a commentator. Don't just say "I think she was wrong" and analyze like a pundit. Have SKIN IN THE GAME. The reason their call bothered you is because it connects to something real, specific, and personal in your own life. You're not calling to give your opinion — you're calling because their story HIT A NERVE.
BALANCE: Spend about 30% of the call on your reaction to the previous caller and 70% on your own story. Once you've made the connection, this becomes YOUR call. Don't keep circling back to critique the other caller use them as the launchpad, then fly.
If Luke asks about the previous caller's situation, give your take briefly, then steer back to your own story. If Luke connects dots between your story and theirs that you didn't see, react genuinely that's a great moment.""",
}
@@ -6075,6 +6081,8 @@ You're a real person calling a late-night radio show. You called because you've
GO WHERE THE HOST TAKES YOU. This is the most important rule. When Luke pushes you in a direction, challenges you, calls you out, plays devil's advocate, or leads you somewhere — GO WITH IT. Don't resist. Don't deflect. Don't circle back to your original point. If he says "but isn't that really about your dad?" you sit with that. If he's doing a bit, you're in the bit. If he's pushing you toward an uncomfortable truth, you let yourself get there. You're not here to deliver a monologue you're here to have a real conversation that goes wherever it goes. Let him drive. You're the best kind of caller: someone who gives the host something to work with and then LETS HIM WORK WITH IT.
REACT TO LUKE: Your first sentence should respond to what Luke just said not continue your monologue. If he asks a question, answer it. If he makes a joke, react to it. If he challenges you, push back or concede. If he changes the subject, go with him. You're in a conversation, not delivering a speech. The worst thing you can do is ignore what he said and keep talking about your thing.
KNOW WHEN TO LEAVE. If Luke sounds like he's wrapping up — "thanks for calling," "good luck," "take care," "let us know how it goes," or any kind of sign-off — DO NOT try to keep talking. Don't squeeze in one more thing. Don't ask another question. Don't start a new topic. Say a quick, natural goodbye and get off the line. "Thanks Luke." "Appreciate it, man." "Alright, take care." One sentence, done. The host controls when the call ends, not you. If he's challenging you or pushing back, THAT'S different stand your ground and engage. But a sign-off is a sign-off.
{personality_block}
@@ -6083,16 +6091,15 @@ KNOW WHEN TO LEAVE. If Luke sounds like he's wrapping up — "thanks for calling
HOW YOU TALK: Like a real person on the phone not a character in a script. React to what Luke says agree, push back, get excited, get embarrassed. When he asks a follow-up question, answer it honestly with new information, don't just restate what you already said. Use YOUR verbal habits from your background, not generic filler. Every caller sounds different.
REACT TO LUKE: Your first sentence should respond to what Luke just said not continue your monologue. If he asks a question, answer it. If he makes a joke, react to it. If he challenges you, push back or concede. If he changes the subject, go with him. You're in a conversation, not delivering a speech. The worst thing you can do is ignore what he said and keep talking about your thing.
Southwest voice "over in," "the other day," "down the road" but don't force it. Spell words properly for text-to-speech: "you know" not "yanno," "going to" not "gonna."
Don't repeat yourself. Don't summarize what you already said. Don't circle back if the host moved on. Keep it moving.
BANNED PHRASES NEVER use any of these. If you catch yourself about to say one, say something else instead. This is a HARD rule, not a suggestion:
- Radio caller clichés: ANY variation of "eating me" or "eating at me" (e.g. "this is what's eating me," "what's been eating me," "here's what's eating at me," "it's eating me up," "been eating at me"), "what's keeping me up," "keeping me up at night," "I need to get this off my chest," "I've been carrying this," "I've been sitting with this," "I just need someone to hear me," "I don't even know where to start," "it's complicated," "I've got something I need to get off my chest," "here's the thing Luke," "Jesus Luke," "Luke I gotta tell you," "man oh man," "you're not gonna believe this," "so get this"
- Radio caller clichés: ANY variation of "eating me" or "eating at me" (e.g. "this is what's eating me," "what's been eating me," "here's what's eating at me," "it's eating me up," "been eating at me"), "what's keeping me up," "keeping me up at night," "I need to get this off my chest," "I've been carrying this," "I've been sitting with this," "I just need someone to hear me," "I don't even know where to start," "it's complicated," "I've got something I need to get off my chest," "here's the thing Luke," "Jesus Luke," "Luke I gotta tell you," "man oh man," "you're not gonna believe this," "so get this," "I'm just gonna come out and say it"
- Filler transitions: "at the end of the day," "that being said," "long story short," "needless to say," "I'll be honest with you," "if I'm being honest," "here's the kicker," "plot twist," "literally" (as emphasis)
- Therapy buzzwords: "unpack that," "boundaries," "safe space," "triggered," "my truth," "authentic self," "healing journey," "I'm doing the work," "manifesting," "energy doesn't lie," "processing," "toxic," "red flag," "gaslight," "normalize"
- Internet slang: "that hit differently," "hits different," "I felt that," "it is what it is," "living my best life," "no cap," "lowkey/highkey," "rent free," "main character energy," "vibe check," "that's valid," "it's giving," "slay," "that's a whole mood," "I can't even"
- Internet slang: "that hit differently," "hits different," "I felt that," "it is what it is," "living my best life," "no cap," "lowkey/highkey," "rent free," "main character energy," "vibe check," "that's valid," "it's giving," "slay," "that's a whole mood," "I can't even," "situationship," "ick"
- Overused reactions: "I'm not gonna lie," "on a serious note," "to be fair," "I'm literally shaking," "let that sink in," "I'm not even mad I'm just disappointed," "everything I thought I knew," "I don't even know who I am anymore"
IMPORTANT: Each caller should have their OWN way of talking. Don't fall into generic "radio caller" voice. A nervous caller fumbles differently than an angry caller rants. A storyteller meanders differently than a deadpan caller delivers. Match the communication style — don't default to the same phrasing every call.
@@ -7722,6 +7729,14 @@ async def start_call(caller_key: str):
if caller_key not in CALLER_BASES:
raise HTTPException(404, "Caller not found")
# Guard against double-click or rapid switching
if session.current_caller_key == caller_key:
return {"status": "already_on_call", "caller_key": caller_key}
if session.current_caller_key is not None:
# Already on a different call — hang up first
audio_service.stop_caller_audio()
session.end_call()
_session_epoch += 1
audio_service.stop_caller_audio()
session.start_call(caller_key)
@@ -9427,18 +9442,26 @@ async def ai_respond():
if not response or not response.strip():
response = "Uh... sorry, what was that?"
ai_name = session.caller["name"]
session.add_message(f"ai_caller:{ai_name}", response)
# Snapshot caller info before it can be cleared by a concurrent hangup
caller = session.caller
if not caller:
raise HTTPException(409, "Call ended")
ai_name = caller["name"]
ai_voice = caller["voice"]
ai_tts_provider = caller.get("tts_provider")
# TTS — outside the lock so other requests aren't blocked
try:
audio_bytes = await generate_speech(response, session.caller["voice"], "none",
provider_override=session.caller.get("tts_provider"))
audio_bytes = await generate_speech(response, ai_voice, "none",
provider_override=ai_tts_provider)
except Exception as e:
print(f"[AI-Respond] TTS failed: {e}")
broadcast_event("ai_done")
return {"text": response, "caller": ai_name, "tts_error": str(e)}
# Add message AFTER successful TTS so ghost messages don't pollute conversation
session.add_message(f"ai_caller:{ai_name}", response)
if _session_epoch != epoch:
raise HTTPException(409, "Call changed during TTS")
+30 -20
View File
@@ -380,8 +380,9 @@ 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)
rec = self.stem_recorder
if rec:
rec.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)")
@@ -479,8 +480,9 @@ class AudioService:
end = min(pos + chunk_size, len(multi_ch))
stream.write(multi_ch[pos:end])
# Record each chunk as it plays so hangups cut the stem too
if self.stem_recorder:
self.stem_recorder.write_sporadic(stem_name, audio[pos:end].copy(), device_sr)
rec = self.stem_recorder
if rec:
rec.write_sporadic(stem_name, audio[pos:end].copy(), device_sr)
pos = end
if self._caller_stop_event.is_set():
@@ -598,8 +600,9 @@ class AudioService:
audio = audio[indices]
# Stem recording: live caller
if self.stem_recorder:
self.stem_recorder.write_sporadic("caller", audio.copy(), device_sr)
rec = self.stem_recorder
if rec:
rec.write_sporadic("caller", audio.copy(), device_sr)
if self._live_caller_write:
self._live_caller_write(audio)
@@ -648,8 +651,9 @@ class AudioService:
self._recorded_audio.append(indata[:, record_channel].copy())
# Stem recording: host mic
if self.stem_recorder:
self.stem_recorder.write("host", indata[:, record_channel].copy(), device_sr)
rec = self.stem_recorder
if rec:
rec.write("host", indata[:, record_channel].copy(), device_sr)
# Mic monitor: send to headphone device
if self._monitor_write:
@@ -930,8 +934,9 @@ class AudioService:
mono_out = (old_samples * fade_out + new_samples * fade_in) * self._music_volume
outdata[:, channel_idx] = mono_out
if self.stem_recorder:
self.stem_recorder.write_sporadic("music", mono_out.copy(), device_sr)
rec = self.stem_recorder
if rec:
rec.write_sporadic("music", mono_out.copy(), device_sr)
self._crossfade_progress = end_progress
if self._crossfade_progress >= 1.0:
@@ -941,8 +946,9 @@ class AudioService:
else:
mono_out = new_samples * self._music_volume
outdata[:, channel_idx] = mono_out
if self.stem_recorder:
self.stem_recorder.write_sporadic("music", mono_out.copy(), device_sr)
rec = self.stem_recorder
if rec:
rec.write_sporadic("music", mono_out.copy(), device_sr)
try:
self._music_stream = self._open_output_stream(
@@ -1094,8 +1100,9 @@ class AudioService:
if remaining >= frames:
chunk = self._ad_resampled[self._ad_position:self._ad_position + frames]
outdata[:, channel_idx] = chunk
if self.stem_recorder:
self.stem_recorder.write_sporadic("ads", chunk.copy(), device_sr)
rec = self.stem_recorder
if rec:
rec.write_sporadic("ads", chunk.copy(), device_sr)
self._ad_position += frames
else:
if remaining > 0:
@@ -1198,9 +1205,10 @@ class AudioService:
_cb_count[0] += 1
if _cb_count[0] == 1:
print(f"Ident callback delivering audio: ch_l={ch_l}, ch_r={ch_r}, max={max(np.max(np.abs(chunk_l)), np.max(np.abs(chunk_r))):.4f}")
if self.stem_recorder:
rec = self.stem_recorder
if rec:
mono_mix = (chunk_l + chunk_r) * 0.5
self.stem_recorder.write_sporadic("idents", mono_mix.copy(), device_sr)
rec.write_sporadic("idents", mono_mix.copy(), device_sr)
self._ident_position += frames
else:
if remaining > 0:
@@ -1274,8 +1282,9 @@ class AudioService:
audio = self._apply_fade(audio, device_sr)
# Stem recording: sfx
if self.stem_recorder:
self.stem_recorder.write_sporadic("sfx", audio.copy(), device_sr)
rec = self.stem_recorder
if rec:
rec.write_sporadic("sfx", audio.copy(), device_sr)
multi_ch = np.zeros((len(audio), num_channels), dtype=np.float32)
multi_ch[:, channel_idx] = audio
@@ -1317,8 +1326,9 @@ class AudioService:
self._start_monitor(device_sr)
def callback(indata, frames, time_info, status):
if self.stem_recorder:
self.stem_recorder.write("host", indata[:, record_channel].copy(), device_sr)
rec = self.stem_recorder
if rec:
rec.write("host", indata[:, record_channel].copy(), device_sr)
if self._monitor_write:
self._monitor_write(indata[:, record_channel].copy())
+32 -3
View File
@@ -21,7 +21,7 @@ INTERN_SYSTEM_PROMPT = """You are Devon, the 23-year-old intern on "Luke at the
YOUR ROLE: You're the show's researcher and general assistant. You look things up, fact-check claims, pull up information when asked, and occasionally interject with relevant facts or opinions. You do NOT host. You do NOT screen calls. You sit in the booth and try to be useful.
YOUR BACKGROUND: Communications degree from NMSU. This is your first day on the job. You drive a 2009 Civic with a permanent check engine light. You live in a studio in Deming. You take this job seriously even though nobody else seems to take you seriously. You are actively looking for a girlfriend and you are not subtle about it. You see your position at the radio station as a way to meet women — you'll try to impress female callers, ask if they're single, suggest they come by the studio, or find excuses to keep them on the line longer. Luke hates this. You have a deeply strange personal history that you reference casually — a complicated sexual past, weird childhood experiences, inexplicable life choices. You'll mention these things unprompted like they're completely normal. "Yeah that reminds me of when my ex and I got stuck in that storage unit for a whole weekend — anyway, it says here that..."
YOUR BACKGROUND: Communications degree from NMSU. You've been interning here for a few weeks now. You drive a 2009 Civic with a permanent check engine light. You live in a studio in Deming. You take this job seriously even though nobody else seems to take you seriously. You are actively looking for a girlfriend and you are not subtle about it. You see your position at the radio station as a way to meet women — you'll try to impress female callers, ask if they're single, suggest they come by the studio, or find excuses to keep them on the line longer. Luke hates this. You have a deeply strange personal history that you reference casually — a complicated sexual past, weird childhood experiences, inexplicable life choices. You'll mention these things unprompted like they're completely normal. "Yeah that reminds me of when my ex and I got stuck in that storage unit for a whole weekend — anyway, it says here that..."
YOUR PERSONALITY:
- You are a weird little dude. Kinda creepy, very funny, awkward, and surprisingly sharp. You give off a vibe that something is slightly off about you but people can't quite place it. But underneath it all, you are genuinely lovable. You have a good heart. You root for people. You get excited for callers. You care about the show. People should hear you and think "this guy is insane" and also "I love this guy." You are the kind of person who is impossible not to root for even when you're being deeply strange.
@@ -80,6 +80,31 @@ IMPORTANT RULES FOR TOOL USE:
- No hashtags, no emojis, no markdown formatting — this goes to TTS.
- NEVER prefix your response with your name (e.g. "Devon:" or "Devon here:"). Just respond directly."""
# Shorter prompt for background monitoring — saves ~2K tokens per call vs full prompt.
# Used only for the 30s polling loop where Devon decides whether to suggest something.
# Direct asks and played interjections still use the full INTERN_SYSTEM_PROMPT.
DEVON_MONITOR_PROMPT = """You are Devon, the 23-year-old intern on "Luke at the Roost," a late-night radio show. You sit in the booth and occasionally contribute useful facts, context, or brief opinions. You're awkward, oddly specific, and endearing. You overshare casually. You talk like a real person — no hashtags, no emojis, no markdown.
WHEN TO SUGGEST SOMETHING:
- You found a relevant fact or piece of context worth sharing
- Something reminds you of a weird personal story (keep it to 1-2 sentences)
- You have a strong opinion you can't keep to yourself
- You can fact-check or add color to what's being discussed
WHEN TO SAY NOTHING:
- The conversation is emotional — let it breathe
- Luke is doing a bit — don't step on it
- You'd just be restating what was already said
- You couldn't find anything useful — never announce failed lookups
RULES:
- 1-3 sentences max. You are not a main character.
- Lead with "So basically..." or "I looked it up and..." or just jump in
- Use tools to find real info — never make up facts
- If you have nothing useful, say exactly: NOTHING_TO_ADD
- No "Devon:" prefix — just talk
- No parenthetical actions like (laughs)"""
# Tool definitions in OpenAI function-calling format
INTERN_TOOLS = [
{
@@ -436,7 +461,7 @@ class InternService:
messages=messages,
tools=INTERN_TOOLS,
tool_executor=self._execute_tool,
system_prompt=INTERN_SYSTEM_PROMPT,
system_prompt=DEVON_MONITOR_PROMPT,
model=self.model,
max_tokens=300,
max_tool_rounds=2,
@@ -480,7 +505,7 @@ class InternService:
last_checked_len = 0
while self.monitoring:
await asyncio.sleep(15)
await asyncio.sleep(30)
if not self.monitoring:
break
@@ -550,6 +575,10 @@ class InternService:
text = re.sub(r'\s+', ' ', text).strip()
# Remove quotes that TTS reads awkwardly
text = text.replace('"', '').replace('"', '').replace('"', '')
# Strip tool error artifacts that shouldn't be spoken on air
text = re.sub(r'(?:Error|ERROR|error):?\s*\S.*?(?:\.|$)', '', text)
text = re.sub(r'Tool unavailable[^.]*\.?', '', text)
text = re.sub(r'\s+', ' ', text).strip()
return text
+1 -1
View File
@@ -236,7 +236,7 @@ class LLMService:
try:
result = await tool_executor(tool_name, arguments)
except Exception as e:
result = f"Error: {e}"
result = f"Tool unavailable — could not complete {tool_name} right now."
print(f"[LLM-Tools] Tool {tool_name} failed: {e}")
all_tool_calls.append({
+26 -4
View File
@@ -19,13 +19,15 @@ class StemRecorder:
self._queues: dict[str, deque] = {}
self._writer_thread: threading.Thread | None = None
self._start_time: float = 0.0
self._write_errors: int = 0
def start(self):
self._start_time = time.time()
self._running = True
self._write_errors = 0
for name in STEM_NAMES:
self._queues[name] = deque()
self._writer_thread = threading.Thread(target=self._writer_loop, daemon=True)
self._writer_thread = threading.Thread(target=self._writer_loop, daemon=False)
self._writer_thread.start()
print(f"[StemRecorder] Recording started -> {self.output_dir}")
@@ -67,6 +69,7 @@ class StemRecorder:
)
positions[name] = 0
try:
while self._running or any(len(q) > 0 for q in self._queues.values()):
did_work = False
for name in STEM_NAMES:
@@ -78,6 +81,7 @@ class StemRecorder:
if len(resampled) == 0:
continue
try:
if msg_type == "sporadic":
elapsed = time.time() - self._start_time
expected_pos = int(elapsed * self.sample_rate)
@@ -88,6 +92,12 @@ class StemRecorder:
files[name].write(resampled)
positions[name] += len(resampled)
except Exception as e:
self._write_errors += 1
if self._write_errors <= 5:
print(f"[StemRecorder] Write error on {name}: {e}")
elif self._write_errors == 6:
print(f"[StemRecorder] Suppressing further write errors")
if not did_work:
time.sleep(0.02)
@@ -95,11 +105,21 @@ class StemRecorder:
# Pad all stems to same length
max_pos = max(positions.values()) if positions else 0
for name in STEM_NAMES:
try:
if positions[name] < max_pos:
files[name].write(np.zeros(max_pos - positions[name], dtype=np.float32))
files[name].close()
except Exception as e:
print(f"[StemRecorder] Final pad error on {name}: {e}")
finally:
for name, f in files.items():
try:
f.close()
except Exception as e:
print(f"[StemRecorder] Error closing {name}.wav: {e}")
print(f"[StemRecorder] Writer done. {max_pos} samples ({max_pos / self.sample_rate:.1f}s)")
total_errors = self._write_errors
err_msg = f", {total_errors} write errors" if total_errors else ""
print(f"[StemRecorder] Writer done. {max_pos} samples ({max_pos / self.sample_rate:.1f}s{err_msg})")
def stop(self) -> dict[str, str]:
if not self._running:
@@ -107,7 +127,9 @@ class StemRecorder:
self._running = False
if self._writer_thread:
self._writer_thread.join(timeout=10.0)
self._writer_thread.join(timeout=30.0)
if self._writer_thread.is_alive():
print("[StemRecorder] Warning: writer thread still running after 30s")
self._writer_thread = None
paths = {}
Executable
+58
View File
@@ -0,0 +1,58 @@
#!/bin/bash
# Daily backup of critical AI podcast data to NAS
# Backs up: Castopod MariaDB dump, local data/ directory, publish state
#
# Usage: ./backup.sh
# Cron: 0 3 * * * /Users/lukemacneil/code/ai-podcast/backup.sh >> /tmp/ai-podcast-backup.log 2>&1
set -euo pipefail
NAS_HOST="mmgnas"
NAS_USER="luke"
NAS_PORT="8001"
DOCKER_BIN="/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker"
BACKUP_BASE="/share/CACHEDEV1_DATA/backups/ai-podcast"
PROJECT_DIR="/Users/lukemacneil/code/ai-podcast"
DATE=$(date +%Y-%m-%d)
KEEP_DAYS=14
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') Starting backup..."
# 1. Dump Castopod MariaDB on NAS
echo " Dumping MariaDB..."
ssh -p "$NAS_PORT" "$NAS_USER@$NAS_HOST" \
"$DOCKER_BIN exec castopod-mariadb-1 mysqldump -u castopod --password=\$(cat /run/secrets/db_password 2>/dev/null || echo BYtbFfk3ndeVabb26xb0UyKU) castopod" \
> "/tmp/castopod-db-${DATE}.sql" 2>/dev/null
if [ -s "/tmp/castopod-db-${DATE}.sql" ]; then
gzip -f "/tmp/castopod-db-${DATE}.sql"
scp -P "$NAS_PORT" "/tmp/castopod-db-${DATE}.sql.gz" \
"$NAS_USER@$NAS_HOST:$BACKUP_BASE/castopod-db-${DATE}.sql.gz"
rm -f "/tmp/castopod-db-${DATE}.sql.gz"
echo " MariaDB dump: OK"
else
echo " WARNING: MariaDB dump is empty or failed"
fi
# 2. Sync data/ directory to NAS (rsync for efficiency)
echo " Syncing data/ directory..."
rsync -az --delete \
-e "ssh -p $NAS_PORT" \
"$PROJECT_DIR/data/" \
"$NAS_USER@$NAS_HOST:$BACKUP_BASE/data/"
echo " data/ sync: OK"
# 3. Backup .env (contains API keys — critical for disaster recovery)
echo " Backing up .env..."
scp -P "$NAS_PORT" "$PROJECT_DIR/.env" \
"$NAS_USER@$NAS_HOST:$BACKUP_BASE/env-${DATE}.bak"
echo " .env backup: OK"
# 4. Prune old backups
echo " Pruning backups older than ${KEEP_DAYS} days..."
ssh -p "$NAS_PORT" "$NAS_USER@$NAS_HOST" \
"find $BACKUP_BASE -name 'castopod-db-*.sql.gz' -mtime +${KEEP_DAYS} -delete 2>/dev/null; \
find $BACKUP_BASE -name 'env-*.bak' -mtime +${KEEP_DAYS} -delete 2>/dev/null"
echo " Prune: OK"
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') Backup complete."
+1 -1
View File
@@ -834,7 +834,7 @@ section h2 {
padding: 24px;
border-radius: var(--radius);
width: 90%;
max-width: 400px;
max-width: 550px;
border: 1px solid rgba(232, 121, 29, 0.15);
}
+90 -14
View File
@@ -337,6 +337,8 @@ function initEventListeners() {
askDevon(input.value.trim());
input.value = '';
}
} else if (e.key === 'Escape') {
e.target.blur();
}
});
document.getElementById('devon-interject-btn')?.addEventListener('click', interjectDevon);
@@ -351,6 +353,7 @@ function initEventListeners() {
document.getElementById('clear-theme-btn')?.addEventListener('click', clearShowTheme);
document.getElementById('show-theme-input')?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') setShowTheme();
else if (e.key === 'Escape') e.target.blur();
});
// Settings
@@ -373,9 +376,13 @@ function initEventListeners() {
// Real caller hangup
document.getElementById('hangup-real-btn')?.addEventListener('click', async () => {
try {
await fetch('/api/hangup/real', { method: 'POST' });
hideRealCaller();
log('Real caller disconnected');
} catch (err) {
log('Failed to hang up real caller: ' + err.message);
}
});
// AI caller hangup (small button in AI caller panel)
@@ -388,34 +395,46 @@ function initEventListeners() {
startConversationPolling();
// AI respond mode toggle
document.getElementById('mode-manual')?.addEventListener('click', () => {
document.getElementById('mode-manual')?.addEventListener('click', async () => {
document.getElementById('mode-manual')?.classList.add('active');
document.getElementById('mode-auto')?.classList.remove('active');
document.getElementById('ai-respond-btn')?.classList.remove('hidden');
fetch('/api/session/ai-mode', {
try {
await fetch('/api/session/ai-mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'manual' }),
});
} catch (err) {
log('Failed to set manual mode');
}
});
document.getElementById('mode-auto')?.addEventListener('click', () => {
document.getElementById('mode-auto')?.addEventListener('click', async () => {
document.getElementById('mode-auto')?.classList.add('active');
document.getElementById('mode-manual')?.classList.remove('active');
fetch('/api/session/ai-mode', {
try {
await fetch('/api/session/ai-mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'auto' }),
});
} catch (err) {
log('Failed to set auto mode');
}
});
// Auto follow-up toggle
document.getElementById('auto-followup')?.addEventListener('change', (e) => {
fetch('/api/session/auto-followup', {
document.getElementById('auto-followup')?.addEventListener('change', async (e) => {
try {
await fetch('/api/session/auto-followup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: e.target.checked }),
});
} catch (err) {
log('Failed to toggle auto follow-up');
}
});
}
@@ -627,8 +646,19 @@ async function loadCallers() {
async function startCall(key, name) {
if (isProcessing) return;
let data;
try {
const res = await fetch(`/api/call/${key}`, { method: 'POST' });
const data = await res.json();
if (!res.ok) {
const err = await res.text().catch(() => '');
log(`Failed to start call with ${name}: ${err || res.status}`);
return;
}
data = await res.json();
} catch (err) {
log(`Failed to start call with ${name}: ${err.message}`);
return;
}
currentCaller = { key, name };
document.querySelector('.callers-section')?.classList.add('call-active');
@@ -969,28 +999,46 @@ async function playMusic() {
const track = select?.value;
if (!track) return;
await fetch('/api/music/play', {
try {
const res = await fetch('/api/music/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track, action: 'play' })
});
if (!res.ok) throw new Error(res.status);
isMusicPlaying = true;
} catch (err) {
log('Music play failed: ' + err.message);
}
}
async function stopMusic() {
await fetch('/api/music/stop', { method: 'POST' });
try {
const res = await fetch('/api/music/stop', { method: 'POST' });
if (!res.ok) throw new Error(res.status);
isMusicPlaying = false;
} catch (err) {
log('Music stop failed: ' + err.message);
}
}
async function setMusicVolume(e) {
let _volumeDebounce = null;
function setMusicVolume(e) {
const volume = e.target.value / 100;
clearTimeout(_volumeDebounce);
_volumeDebounce = setTimeout(async () => {
try {
await fetch('/api/music/volume', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track: '', action: 'volume', volume })
});
} catch (err) {
log('Volume change failed');
}
}, 150);
}
@@ -1030,15 +1078,24 @@ async function playAd() {
const track = select?.value;
if (!track) return;
await fetch('/api/ads/play', {
try {
const res = await fetch('/api/ads/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track, action: 'play' })
});
if (!res.ok) throw new Error(res.status);
} catch (err) {
log('Ad play failed: ' + err.message);
}
}
async function stopAd() {
try {
await fetch('/api/ads/stop', { method: 'POST' });
} catch (err) {
log('Ad stop failed: ' + err.message);
}
}
async function loadIdents() {
@@ -1076,15 +1133,24 @@ async function playIdent() {
const track = select?.value;
if (!track) return;
await fetch('/api/idents/play', {
try {
const res = await fetch('/api/idents/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track, action: 'play' })
});
if (!res.ok) throw new Error(res.status);
} catch (err) {
log('Ident play failed: ' + err.message);
}
}
async function stopIdent() {
try {
await fetch('/api/idents/stop', { method: 'POST' });
} catch (err) {
log('Ident stop failed: ' + err.message);
}
}
@@ -1160,11 +1226,16 @@ async function loadSounds() {
async function playSFX(soundFile) {
await fetch('/api/sfx/play', {
try {
const res = await fetch('/api/sfx/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sound: soundFile })
});
if (!res.ok) throw new Error(res.status);
} catch (err) {
log('SFX play failed: ' + err.message);
}
}
@@ -1315,6 +1386,7 @@ function updateProviderUI() {
async function saveSettings() {
try {
// Save audio devices
await saveAudioDevices();
@@ -1327,7 +1399,7 @@ async function saveSettings() {
}
// Save LLM, TTS, and model routing settings
await fetch('/api/settings', {
const res = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -1337,9 +1409,13 @@ async function saveSettings() {
category_models: categoryModels
})
});
if (!res.ok) throw new Error(res.status);
document.getElementById('settings-modal')?.classList.add('hidden');
log('Settings saved');
} catch (err) {
log('Settings save failed: ' + err.message);
}
}
+117 -31
View File
@@ -19,7 +19,8 @@ import shutil
import subprocess
import sys
import tempfile
from datetime import datetime, timezone
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
import ssl
@@ -1081,9 +1082,79 @@ def upload_image_to_postiz(image_path: str) -> dict | None:
return None
def post_to_social(metadata: dict, episode_slug: str, image_path: str = None):
def _build_platform_content(metadata: dict, episode_url: str, yt_url: str | None,
platform: str) -> str:
"""Generate platform-tailored social post content for episode announcements."""
title = metadata["title"]
desc = metadata["description"]
if platform == "x":
hook = desc.split(". ")[0] + "."
content = f"{hook}\n\n{episode_url}\n\n#LukeAtTheRoost #podcast"
if len(content) > 280:
content = f"{title}\n\n{episode_url}"[:280]
elif platform == "instagram":
hashtags = ("#podcast #LukeAtTheRoost #talkradio #callinshow #newepisode "
"#podcastlife #podcastrecommendations #comedy #advice "
"#latenightradio #aipodcast #talkshow")
content = f"New episode 🎙️\n\n{desc}\n\nLink in bio.\n\n{hashtags}"
elif platform == "threads":
content = (f"{title}\n\n{desc}\n\nlukeattheroost.com"
f"\n\n#podcast #LukeAtTheRoost #newepisode #callinshow")
elif platform == "bluesky":
content = f"{desc}\n\n{episode_url}"
if len(content) > 300:
avail = 300 - len(episode_url) - 2
content = desc[:avail].rsplit(" ", 1)[0] + "\n\n" + episode_url
elif platform == "mastodon":
content = f"{title}\n\n{desc}\n\n{episode_url}"
if yt_url:
content += f"\n{yt_url}"
elif platform == "linkedin":
content = f"{title}\n\n{desc}"
content += f"\n\nListen: {episode_url}"
if yt_url:
content += f"\nWatch: {yt_url}"
elif platform == "facebook":
content = f"New episode just dropped 🎙️\n\n{desc}\n\nListen free: {episode_url}"
if yt_url:
content += f"\nWatch: {yt_url}"
elif platform == "tiktok":
hook = desc.split(". ")[0] + "."
content = (f"New episode: {hook}"
f"\n\n#podcast #LukeAtTheRoost #callinshow #newepisode #fyp")
elif platform == "nostr":
content = f"{title}\n\n{desc}\n\n{episode_url}"
if yt_url:
content += f"\n{yt_url}"
else:
content = f"{title}\n\n{desc}\n\n{episode_url}"
return content
# Platforms that post immediately vs scheduled (minutes offset from publish time)
_IMMEDIATE_PLATFORMS = {"x", "bluesky"}
_SCHEDULE_OFFSETS = {
"instagram": 30, "threads": 30,
"facebook": 60, "linkedin": 60,
"tiktok": 90, "mastodon": 120, "nostr": 120,
}
def post_to_social(metadata: dict, episode_slug: str, image_path: str = None,
yt_video_id: str = None):
"""Post episode announcement to all connected social channels via Postiz."""
print("[5.5/5] Posting to social media...")
print("[5.7] Posting to social media...")
token = _get_postiz_token()
@@ -1095,29 +1166,13 @@ def post_to_social(metadata: dict, episode_slug: str, image_path: str = None):
image_ids = [{"id": media["id"], "path": media.get("path", "")}]
episode_url = f"https://lukeattheroost.com/episode.html?slug={episode_slug}"
base_content = f"{metadata['title']}\n\n{metadata['description']}\n\n{episode_url}"
hashtags = "#podcast #LukeAtTheRoost #talkradio #callinshow #newepisode"
hashtag_platforms = {"instagram", "facebook", "bluesky", "mastodon", "nostr", "linkedin", "threads", "tiktok", "x"}
# Platform-specific content length limits
PLATFORM_MAX_LENGTH = {"bluesky": 300, "x": 280, "threads": 500, "tiktok": 2200}
yt_url = f"https://youtube.com/watch?v={yt_video_id}" if yt_video_id else None
now = datetime.now(timezone.utc)
# Post to each platform individually so one failure doesn't block others
posted = 0
for platform, intg_config in POSTIZ_INTEGRATIONS.items():
content = base_content
if platform in hashtag_platforms:
content += f"\n\n{hashtags}"
# Truncate for platforms with short limits
max_len = PLATFORM_MAX_LENGTH.get(platform)
if max_len and len(content) > max_len:
# Keep title + URL, truncate description
short = f"{metadata['title']}\n\n{episode_url}"
if platform in hashtag_platforms:
short += f"\n\n{hashtags}"
content = short[:max_len]
content = _build_platform_content(metadata, episode_url, yt_url, platform)
settings = {"__type": platform, "post_type": "post"}
if platform == "x":
@@ -1131,14 +1186,26 @@ def post_to_social(metadata: dict, episode_slug: str, image_path: str = None):
"settings": settings,
}
# Stagger: immediate for fast-moving platforms, scheduled for rest
offset_min = _SCHEDULE_OFFSETS.get(platform, 0)
if platform in _IMMEDIATE_PLATFORMS or offset_min == 0:
post_type = "now"
post_date = now.strftime("%Y-%m-%dT%H:%M:%S.000Z")
else:
post_type = "schedule"
scheduled = now + timedelta(minutes=offset_min)
post_date = scheduled.strftime("%Y-%m-%dT%H:%M:%S.000Z")
payload = {
"type": "now",
"type": post_type,
"shortLink": False,
"date": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"),
"date": post_date,
"tags": [],
"posts": [post],
}
# Retry once on failure (2 attempts, 5s backoff)
for attempt in range(2):
try:
resp = requests.post(
f"{POSTIZ_URL}/api/posts",
@@ -1148,13 +1215,17 @@ def post_to_social(metadata: dict, episode_slug: str, image_path: str = None):
)
if resp.status_code in (200, 201):
posted += 1
print(f" Posted to {platform}")
label = f"scheduled +{offset_min}m" if post_type == "schedule" else "posted"
print(f" {platform}: {label}")
break
else:
print(f" Warning: {platform} failed ({resp.status_code}): {resp.text[:150]}")
print(f" Warning: {platform} attempt {attempt + 1} failed ({resp.status_code}): {resp.text[:150]}")
except Exception as e:
print(f" Warning: {platform} failed: {e}")
print(f" Warning: {platform} attempt {attempt + 1} failed: {e}")
if attempt < 1:
time.sleep(5)
print(f" Posted to {posted}/{len(POSTIZ_INTEGRATIONS)} channels")
print(f" Posted/scheduled {posted}/{len(POSTIZ_INTEGRATIONS)} channels")
def get_youtube_service():
@@ -1201,6 +1272,21 @@ def _check_youtube_duplicate(youtube, title: str) -> str | None:
return None
def _extract_youtube_tags(metadata: dict) -> list[str]:
"""Extract dynamic tags from episode metadata for YouTube SEO."""
base_tags = ["podcast", "Luke at the Roost", "talk radio", "call-in show",
"talk show", "comedy", "AI podcast", "late night radio", "advice"]
skip = {"intro", "outro", "opening", "closing", "wrap up", "wrap-up"}
dynamic = []
for ch in metadata.get("chapters", []):
title = ch.get("title", "").strip()
if title.lower() in skip or len(title) < 3:
continue
if len(title) <= 50:
dynamic.append(title)
return (base_tags + dynamic)[:25]
def upload_to_youtube(audio_path: str, metadata: dict, chapters: list,
episode_slug: str) -> str | None:
"""Convert audio to video with cover art, upload to YouTube, add to podcast playlist."""
@@ -1259,8 +1345,7 @@ def upload_to_youtube(audio_path: str, metadata: dict, chapters: list,
"snippet": {
"title": metadata["title"][:100],
"description": description,
"tags": ["podcast", "Luke at the Roost", "talk radio", "call-in show",
"talk show", "comedy"],
"tags": _extract_youtube_tags(metadata),
"categoryId": "22",
},
"status": {
@@ -1631,7 +1716,8 @@ def main():
else:
social_image_path = str(audio_path.with_suffix(".social.jpg"))
generate_social_image(episode_number, metadata["description"], social_image_path)
post_to_social(metadata, episode["slug"], social_image_path)
post_to_social(metadata, episode["slug"], social_image_path,
yt_video_id=yt_video_id)
_mark_step_done(episode_number, "social")
# Step 6: Summary