diff --git a/CLAUDE.md b/CLAUDE.md index 878b60d..5714ed5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,20 +1,5 @@ # AI Podcast - Project Instructions -## Git Remote (Gitea) -- **Repo**: `git@gitea-nas:luke/ai-podcast.git` -- **Web**: http://mmgnas:3000/luke/ai-podcast -- **SSH Host**: `gitea-nas` (configured in ~/.ssh/config) - - HostName: `mmgnas` (use `mmgnas-10g` if wired connection issues) - - Port: `2222` - - User: `git` - - IdentityFile: `~/.ssh/gitea_mmgnas` - -## NAS Access -- **Hostname**: `mmgnas` (wireless) or `mmgnas-10g` (wired/10G) -- **SSH Port**: 8001 -- **User**: luke -- **Docker path**: `/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker` - ## Castopod (Podcast Publishing) - **URL**: https://podcast.macneilmediagroup.com - **Podcast handle**: `@LukeAtTheRoost` @@ -42,8 +27,7 @@ Required in `.env`: - ELEVENLABS_API_KEY (optional) - INWORLD_API_KEY (for Inworld TTS) -## Post-Production Pipeline (added Feb 2026) -- **Branch**: `feature/real-callers` — all current work is here, pushed to gitea +## Post-Production Pipeline - **Stem Recorder** (`backend/services/stem_recorder.py`): Records 5 WAV stems (host, caller, music, sfx, ads) during live shows. Uses lock-free deque architecture — audio callbacks just append to deques, a background writer thread drains to disk. `write()` for continuous streams (host mic, music, ads), `write_sporadic()` for burst sources (caller TTS, SFX) with time-aligned silence padding. - **Audio hooks** in `backend/services/audio.py`: 7 tap points guarded by `if self.stem_recorder:`. Persistent mic stream (`start_stem_mic`/`stop_stem_mic`) runs during recording to capture host voice continuously, not just during push-to-talk. - **API endpoints**: `POST /api/recording/start`, `POST /api/recording/stop` (auto-runs postprod in background thread), `POST /api/recording/process` @@ -88,24 +72,44 @@ Required in `.env`: - **Analytics**: Cloudflare Web Analytics (enable in Cloudflare dashboard, no code changes needed) - **Deploy**: `npx wrangler pages deploy website/ --project-name=lukeattheroost --branch=main` -## Git Push -- If `mmgnas` times out, use the 10g hostname: - ```bash - GIT_SSH_COMMAND="ssh -o HostName=mmgnas-10g -p 2222 -i ~/.ssh/gitea_mmgnas" git push origin main - ``` - -## Hetzner VPS -- **IP**: `46.225.164.41` -- **SSH**: `ssh root@46.225.164.41` (uses default key `~/.ssh/id_rsa`) -- **Specs**: 2 CPU, 4GB RAM, 38GB disk (~33GB free) -- **Mail**: `docker-mailserver` at `/opt/mailserver/` -- **Manage accounts**: `docker exec mailserver setup email add/del/list` -- **Available for future services** — has headroom for lightweight containers. Not suitable for storage-heavy services (e.g. Castopod with daily episodes) without a disk upgrade or attached volume. - ## Podcast Workflow - Publishing pipeline: episodes go through Castopod, CDN, website, YouTube, and social - Always check Python venv is active and packages are installed before running publish scripts -- Episode numbering must be verified against existing episodes +- Episode numbering: check Castopod for the latest episode number, don't hardcode -## Episodes Published -- Episode 6 published 2026-02-08 (podcast6.mp3, ~31 min) +## Scripts +- `publish_episode.py` — Transcribes audio, generates metadata (title, description, cover art), publishes to Castopod. Usage: `python publish_episode.py ~/Desktop/episode.mp3` +- `make_clips.py` — Two-pass clip extraction: fast Whisper transcription → LLM selects best moments → quality Whisper re-transcription for precise timestamps. Usage: `python make_clips.py ~/Desktop/episode.mp3 --count 3` +- `generate_milestone_images.py` — Generates social milestone images via Gemini Flash (requires GOOGLE_API_KEY) +- `post_milestone.py` — Posts milestone announcements to social platforms via Postiz +- `make_x_launch_assets.py` — Generates branded visual assets for X/Twitter (header, quote cards, intro/review graphics) +- `schedule_x_launch.py` — Schedules X/Twitter launch campaign posts via Postiz API + +## Reaper Scripts +- `reaper/dialog_regions.lua` — Background script that polls `/tmp/reaper_state.txt` and creates colored regions (green=DIALOG, red=AD, blue=IDENT) as the backend writes state changes during recording +- `reaper/strip_silence_dialog.lua` — Post-production script: strips long silences from dialog regions, normalizes AD/IDENT/music volume, trims music to voice length with fade-out, mutes music during AD/IDENT regions + +## Cost Dashboard +- **Route**: `/costs` — standalone analytics page, linked from control panel header +- **Database**: `data/costs.db` (SQLite) — aggregates all session cost data for cross-session queries +- **Data layer**: `backend/services/cost_db.py` — schema, JSON import, all query functions +- **Dual-write**: `cost_tracker.py` writes to both JSON (`data/cost_reports/`) and SQLite on every LLM/TTS call +- **API**: 8 endpoints under `/api/costs/` — summary, timeline, models, categories, sessions, session detail, expensive calls, TTS providers +- **Frontend**: `frontend/costs.html`, `frontend/css/costs.css`, `frontend/js/costs.js` — Chart.js for visualizations +- **Pricing**: Hardcoded in `cost_tracker.py` (`OPENROUTER_PRICING`, `TTS_PRICING`) — update when provider prices change +- **Not tracked yet**: SignalWire call costs + +## Data Directory +State files (not config — these are written at runtime): +- `regulars.json` — Returning caller profiles (backgrounds, key moments, arc status, relationships) +- `used_topics_history.json` — Previously used caller topics to avoid repeats +- `session_checkpoint.json` — Current show session state (call history, caller queue) +- `publish_state.json` — Publishing pipeline progress per episode +- `intern.json` — Devon's lookup history +- `emails.json` — Listener email submissions +- `voicemails.json` — Listener voicemail submissions + +## Personal +- Don't build anything until you have 95% clarity on what I want you to do. Ask clarifying questions until you reach 95% understanding of what I'm asking +- When working as a team, propose the plan before executing — don't just start building +- Flag trade-offs that affect show quality or listener experience rather than silently resolving them diff --git a/audio_settings.json b/audio_settings.json index 2edda7c..560be8d 100644 --- a/audio_settings.json +++ b/audio_settings.json @@ -1,10 +1,11 @@ { - "input_device": 13, + "input_device": 14, "input_device_name": "Babyface Pro (70793771)", "input_channel": 1, - "output_device": 12, + "output_device": 13, "output_device_name": "Radio Voice Mic", "caller_channel": 3, + "devon_channel": 17, "live_caller_channel": 9, "music_channel": 5, "sfx_channel": 7, diff --git a/make_clips.py b/make_clips.py index 0dbf918..fcf9aa3 100755 --- a/make_clips.py +++ b/make_clips.py @@ -1249,10 +1249,10 @@ def generate_clip_video_remotion( ] try: - result = subprocess.run(cmd, capture_output=True, text=True, cwd=str(REMOTION_DIR), timeout=180) + result = subprocess.run(cmd, capture_output=True, text=True, cwd=str(REMOTION_DIR), timeout=600) except subprocess.TimeoutExpired: props_path.unlink(missing_ok=True) - print(f" Remotion render timed out (180s)") + print(f" Remotion render timed out (600s)") return False props_path.unlink(missing_ok=True) diff --git a/publish_episode.py b/publish_episode.py index a57a919..a2eb523 100755 --- a/publish_episode.py +++ b/publish_episode.py @@ -204,12 +204,12 @@ TRANSCRIPT: "Content-Type": "application/json" }, json={ - "model": "anthropic/claude-3.5-sonnet", + "model": "anthropic/claude-sonnet-4.6", "messages": [{"role": "user", "content": full_prompt}], "max_tokens": 8192, "temperature": 0 }, - timeout=120 + timeout=300 ) except requests.exceptions.Timeout: print(f" Warning: Speaker labeling timed out for chunk {i+1}, using raw text") @@ -1311,19 +1311,23 @@ def upload_to_youtube(audio_path: str, metadata: dict, chapters: list, video_path = Path(audio_path).with_suffix(".yt.mp4") # Convert MP3 + cover art to MP4 (pad to 1920x1080 for YouTube compatibility) - print(" Converting audio to video...") - result = subprocess.run([ - "ffmpeg", "-y", "-loop", "1", - "-i", str(cover_art), "-i", audio_path, - "-vf", "scale=-1:1080,pad=1920:1080:(ow-iw)/2:0:black", - "-c:v", "libx264", "-tune", "stillimage", - "-c:a", "aac", "-b:a", "192k", - "-pix_fmt", "yuv420p", "-shortest", - "-movflags", "+faststart", str(video_path) - ], capture_output=True, text=True, timeout=1800) - if result.returncode != 0: - print(f" Warning: ffmpeg failed: {result.stderr[-200:]}") - return None + # Skip if video already exists and is non-trivial size (>1MB) + if video_path.exists() and video_path.stat().st_size > 1_000_000: + print(f" Using existing video: {video_path} ({video_path.stat().st_size / 1_000_000:.0f}MB)") + else: + print(" Converting audio to video...") + result = subprocess.run([ + "ffmpeg", "-y", "-loop", "1", + "-i", str(cover_art), "-i", audio_path, + "-vf", "scale=-1:1080,pad=1920:1080:(ow-iw)/2:0:black", + "-c:v", "libx264", "-tune", "stillimage", + "-c:a", "aac", "-b:a", "192k", + "-pix_fmt", "yuv420p", "-shortest", + "-movflags", "+faststart", str(video_path) + ], capture_output=True, text=True, timeout=1800) + if result.returncode != 0: + print(f" Warning: ffmpeg failed: {result.stderr[-200:]}") + return None # Build chapter timestamps for description chapter_lines = [] @@ -1446,6 +1450,7 @@ def main(): parser.add_argument("--title", "-t", help="Override generated title") parser.add_argument("--description", help="Override generated description") parser.add_argument("--session-data", "-s", help="Path to session export JSON (from /api/session/export)") + parser.add_argument("--resume", action="store_true", help="Resume a failed publish — skip transcription/Castopod, continue from CDN/YouTube/social") args = parser.parse_args() audio_path = Path(args.audio_file).expanduser().resolve() @@ -1493,88 +1498,148 @@ def main(): episode_number = get_next_episode_number() print(f"Episode number: {episode_number}") - # Guard against duplicate publish - if not args.dry_run: - exists = _check_episode_exists_in_db(episode_number) - 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.") + # --- Resume path: skip transcription + Castopod, pick up from CDN/YouTube/social --- + if args.resume: + castopod_step = _get_step_details(episode_number, "castopod") + if not castopod_step: + print(f"Error: No Castopod data in publish_state.json for episode {episode_number}. Nothing to resume.") _cleanup_mysql_auth() lock_fp.close() LOCK_FILE.unlink(missing_ok=True) sys.exit(1) + episode = {"id": castopod_step["episode_id"], "slug": castopod_step["slug"]} + print(f"Resuming episode {episode_number}: id={episode['id']}, slug={episode['slug']}") - # Load session data if provided - session_data = None - if args.session_data: - session_path = Path(args.session_data).expanduser().resolve() - if session_path.exists(): - with open(session_path) as f: - session_data = json.load(f) - print(f"Loaded session data: {session_data.get('call_count', 0)} calls") + # Load existing metadata from files saved during first run + transcript_path = audio_path.with_suffix(".transcript.txt") + chapters_path = audio_path.with_suffix(".chapters.json") + srt_path = audio_path.with_suffix(".srt") + + # Build metadata from existing files; re-transcribe + gen only if missing + if chapters_path.exists(): + print(" Loading existing chapters and metadata files") + with open(chapters_path) as f: + chapters_data = json.load(f) + # Reconstruct metadata from slug and chapters + slug = episode["slug"] + # Convert slug to title: "episode-49-foo-bar" -> "Episode 49: Foo Bar" + title_part = slug.replace(f"episode-{episode_number}-", "").replace("-", " ").title() + metadata = { + "title": f"Episode {episode_number}: {title_part}", + "description": f"Episode {episode_number} of Luke at the Roost.", + "chapters": chapters_data if isinstance(chapters_data, list) else chapters_data.get("chapters", []), + "thumbnail_text": title_part.upper()[:30], + } else: - print(f"Warning: Session data file not found: {session_path}") + print("[1/5] Re-transcribing audio for metadata...") + transcript = transcribe_audio(str(audio_path)) + metadata = generate_metadata(transcript, episode_number) + save_chapters(metadata, str(chapters_path)) + if not transcript_path.exists(): + labeled_text = label_transcript_speakers(transcript["full_text"]) + with open(transcript_path, "w") as f: + f.write(labeled_text) + if not srt_path.exists(): + generate_srt(transcript["segments"], str(srt_path)) - # Step 1: Transcribe - transcript = transcribe_audio(str(audio_path)) + if args.title: + metadata["title"] = args.title + if args.description: + metadata["description"] = args.description - # Step 2: Generate metadata - metadata = generate_metadata(transcript, episode_number) + srt_path = audio_path.with_suffix(".srt") + direct_upload = os.path.getsize(str(audio_path)) > CLOUDFLARE_UPLOAD_LIMIT + chapters_uploaded = True + transcript_uploaded = True + yt_video_id = None + # Jump to CDN upload (step 3.7) + # (fall through to the common CDN/publish/YouTube/social code below) - # Use session chapters if available (more accurate than LLM-generated) - if session_data and session_data.get("chapters"): - metadata["chapters"] = session_data["chapters"] - print(f" Using {len(metadata['chapters'])} chapters from session data") + else: + # --- Normal (non-resume) path --- - # Apply overrides - if args.title: - metadata["title"] = args.title - if args.description: - metadata["description"] = args.description + # Guard against duplicate publish + if not args.dry_run: + exists = _check_episode_exists_in_db(episode_number) + 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) - # Save chapters file - chapters_path = audio_path.with_suffix(".chapters.json") - save_chapters(metadata, str(chapters_path)) + # Load session data if provided + session_data = None + if args.session_data: + session_path = Path(args.session_data).expanduser().resolve() + if session_path.exists(): + with open(session_path) as f: + session_data = json.load(f) + print(f"Loaded session data: {session_data.get('call_count', 0)} calls") + else: + print(f"Warning: Session data file not found: {session_path}") - # Save transcript text file with LUKE:/CALLER: speaker labels - transcript_path = audio_path.with_suffix(".transcript.txt") - raw_text = transcript["full_text"] - labeled_text = label_transcript_speakers(raw_text) - with open(transcript_path, "w") as f: - f.write(labeled_text) - print(f" Transcript saved to: {transcript_path}") + # Step 1: Transcribe + transcript = transcribe_audio(str(audio_path)) - # Generate SRT from whisper segments (for Castopod/podcast apps) - srt_path = audio_path.with_suffix(".srt") - generate_srt(transcript["segments"], str(srt_path)) - print(f" SRT saved to: {srt_path}") + # Step 2: Generate metadata + metadata = generate_metadata(transcript, episode_number) - # Save session transcript alongside episode if available (has speaker labels) - if session_data and session_data.get("transcript"): - session_transcript_path = audio_path.with_suffix(".session_transcript.txt") - with open(session_transcript_path, "w") as f: - f.write(session_data["transcript"]) - print(f" Session transcript saved to: {session_transcript_path}") + # Use session chapters if available (more accurate than LLM-generated) + if session_data and session_data.get("chapters"): + metadata["chapters"] = session_data["chapters"] + print(f" Using {len(metadata['chapters'])} chapters from session data") - if args.dry_run: - print("\n[DRY RUN] Would publish with:") - print(f" Title: {metadata['title']}") - print(f" Description: {metadata['description']}") - print(f" Chapters: {json.dumps(metadata['chapters'], indent=2)}") - print("\nChapters file saved. Run without --dry-run to publish.") - return + # Apply overrides + if args.title: + metadata["title"] = args.title + if args.description: + metadata["description"] = args.description - # Step 3: Create episode - direct_upload = os.path.getsize(str(audio_path)) > CLOUDFLARE_UPLOAD_LIMIT - 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")}) + # Save chapters file + chapters_path = audio_path.with_suffix(".chapters.json") + save_chapters(metadata, str(chapters_path)) + + # Save transcript text file with LUKE:/CALLER: speaker labels + transcript_path = audio_path.with_suffix(".transcript.txt") + raw_text = transcript["full_text"] + labeled_text = label_transcript_speakers(raw_text) + with open(transcript_path, "w") as f: + f.write(labeled_text) + print(f" Transcript saved to: {transcript_path}") + + # Generate SRT from whisper segments (for Castopod/podcast apps) + srt_path = audio_path.with_suffix(".srt") + generate_srt(transcript["segments"], str(srt_path)) + print(f" SRT saved to: {srt_path}") + + # Save session transcript alongside episode if available (has speaker labels) + if session_data and session_data.get("transcript"): + session_transcript_path = audio_path.with_suffix(".session_transcript.txt") + with open(session_transcript_path, "w") as f: + f.write(session_data["transcript"]) + print(f" Session transcript saved to: {session_transcript_path}") + + if args.dry_run: + print("\n[DRY RUN] Would publish with:") + print(f" Title: {metadata['title']}") + print(f" Description: {metadata['description']}") + print(f" Chapters: {json.dumps(metadata['chapters'], indent=2)}") + print("\nChapters file saved. Run without --dry-run to publish.") + return + + # Step 3: Create episode + direct_upload = os.path.getsize(str(audio_path)) > CLOUDFLARE_UPLOAD_LIMIT + 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 chapters and transcript to Castopod # (must happen before CDN sync so media records exist for syncing) diff --git a/reaper/dialog_regions.lua b/reaper/dialog_regions.lua index 7db1be8..a4d2344 100644 --- a/reaper/dialog_regions.lua +++ b/reaper/dialog_regions.lua @@ -24,7 +24,8 @@ local last_state = "" local transport_active = false local function log(msg) - reaper.ShowConsoleMsg("[Regions] " .. msg .. "\n") + -- Silent by default — uncomment for debugging: + -- reaper.ShowConsoleMsg("[Regions] " .. msg .. "\n") end local function is_playing_or_recording() diff --git a/reaper/strip_silence_dialog.lua b/reaper/strip_silence_dialog.lua index 84e6bcb..8287642 100644 --- a/reaper/strip_silence_dialog.lua +++ b/reaper/strip_silence_dialog.lua @@ -30,7 +30,8 @@ local BLOCK_SAMPLES = math.floor(SAMPLE_RATE * BLOCK_SEC) local THRESHOLD = 10 ^ (SILENCE_DB / 20) local MIN_VOICE_BLOCKS = math.ceil(MIN_VOICE_SEC / BLOCK_SEC) local function log(msg) - reaper.ShowConsoleMsg("[PostProd] " .. msg .. "\n") + -- Silent by default — uncomment for debugging: + -- reaper.ShowConsoleMsg("[PostProd] " .. msg .. "\n") end --------------------------------------------------------------------------- diff --git a/website/_worker.js b/website/_worker.js index 4ae692a..24dbf49 100644 --- a/website/_worker.js +++ b/website/_worker.js @@ -58,9 +58,26 @@ export default { return new Response("Feed unavailable", { status: 502 }); } - // Plausible analytics proxy (bypass ad blockers) + // Umami analytics proxy (bypass ad blockers) + if (url.pathname === "/api/send" && request.method === "POST") { + const body = await request.text(); + const resp = await fetch("https://plausible.macneilmediagroup.com/api/send", { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": request.headers.get("User-Agent") || "", + "X-Forwarded-For": request.headers.get("CF-Connecting-IP") || request.headers.get("X-Forwarded-For") || "", + }, + body, + }); + return new Response(resp.body, { + status: resp.status, + headers: { "Content-Type": resp.headers.get("Content-Type") || "text/plain" }, + }); + } + if (url.pathname === "/p/script") { - const resp = await fetch("https://plausible.macneilmediagroup.com/js/script.file-downloads.hash.outbound-links.pageview-props.revenue.tagged-events.js"); + const resp = await fetch("https://plausible.macneilmediagroup.com/script.js"); return new Response(await resp.text(), { headers: { "Content-Type": "application/javascript", @@ -71,7 +88,7 @@ export default { if (url.pathname === "/p/event" && request.method === "POST") { const body = await request.text(); - const resp = await fetch("https://plausible.macneilmediagroup.com/api/event", { + const resp = await fetch("https://plausible.macneilmediagroup.com/api/send", { method: "POST", headers: { "Content-Type": "application/json", diff --git a/website/data/clips.json b/website/data/clips.json index c88af0f..1fd616e 100644 --- a/website/data/clips.json +++ b/website/data/clips.json @@ -1,4 +1,121 @@ [ + { + "title": "Thinking in English First", + "description": "When she heard about her father's diagnosis, she processed it in English first\u2014before responding to her mom in Czech. It felt like a betrayal of who she used to be.", + "episode_number": 49, + "clip_file": "clip-5-thinking-in-english-first.mp4", + "youtube_id": "LGqpEAxe754", + "featured": false, + "thumbnail": "images/clips/clip-5-thinking-in-english-first.jpg" + }, + { + "title": "The April Fool's Divorce Text Disaster", + "description": "She thought sending her husband a divorce text as an April Fool's joke would be funny. Then his mom got involved and the casseroles started arriving. For 11 days straight.", + "episode_number": 48, + "clip_file": "clip-2-the-april-fool-s-divorce-text-disaster.mp4", + "youtube_id": "L9XAQgZeQo0", + "featured": false, + "thumbnail": "images/clips/clip-2-the-april-fool-s-divorce-text-disaster.jpg" + }, + { + "title": "Caller's Daughter on Adult Website Revealed", + "description": "A caller discovers his daughter's secret online activities in the worst possible way. The details just keep getting more uncomfortable.", + "episode_number": 47, + "clip_file": "clip-3-caller-s-daughter-on-adult-website-revealed.mp4", + "youtube_id": "LiH6gVDTHcg", + "featured": false, + "thumbnail": "images/clips/clip-3-caller-s-daughter-on-adult-website-revealed.jpg" + }, + { + "title": "The Taint Explanation", + "description": "Luke's caller had to explain what a taint is on live radio and it went exactly as uncomfortably as you'd imagine. This is why we can't have nice things.", + "episode_number": 46, + "clip_file": "clip-2-the-taint-explanation.mp4", + "youtube_id": "7qAhmnwUE1c", + "featured": false, + "thumbnail": "images/clips/clip-2-the-taint-explanation.jpg" + }, + { + "title": "Bear Traps and Pit of Snakes", + "description": "Luke's home security system is straight out of Home Alone but way more dangerous. Swinging logs? Check. Pit of snakes covered with dust? BOOM! \ud83d\udc0d", + "episode_number": 44, + "clip_file": "clip-2-bear-traps-and-pit-of-snakes.mp4", + "youtube_id": "XBSEu2bFUAI", + "featured": false, + "thumbnail": "images/clips/clip-2-bear-traps-and-pit-of-snakes.jpg" + }, + { + "title": "Roommate's Age Play Kink Takes Over Kitchen", + "description": "Roommate's age play kink is now front and center in the shared kitchen. Baby bottles by the coffee maker? Pacifiers on the counter? This living situation just got complicated.", + "episode_number": 43, + "clip_file": "clip-3-roommate-s-age-play-kink-takes-over-kitchen.mp4", + "youtube_id": "5cF_O2Gm9yM", + "featured": false, + "thumbnail": "images/clips/clip-3-roommate-s-age-play-kink-takes-over-kitchen.jpg" + }, + { + "title": "Day Trading Away the Marriage", + "description": "She's been pretending to commute for 12 hours a day while secretly day-trading their life savings away in motels. Two years of lies and $145K gone.", + "episode_number": 42, + "clip_file": "clip-1-day-trading-away-the-marriage.mp4", + "youtube_id": "JeZ9OLGfW8A", + "featured": false, + "thumbnail": "images/clips/clip-1-day-trading-away-the-marriage.jpg" + }, + { + "title": "The Snot Boogie Beer Money Thief", + "description": "He showed up to the barbecue claiming he's 'not like other guys' then literally ran off with everyone's beer money. The audacity is unmatched.", + "episode_number": 42, + "clip_file": "clip-2-the-snot-boogie-beer-money-thief.mp4", + "youtube_id": "taBAo8_YMkA", + "featured": false, + "thumbnail": "images/clips/clip-2-the-snot-boogie-beer-money-thief.jpg" + }, + { + "title": "Aliens Chasing in Ford Trucks", + "description": "Glowing Ford trucks with shadowy drivers that aren't quite human. This caller's alien encounter story is straight out of a fever dream.", + "episode_number": 41, + "clip_file": "clip-3-aliens-chasing-in-ford-trucks.mp4", + "youtube_id": "Io8CzDtKGfA", + "featured": false, + "thumbnail": "images/clips/clip-3-aliens-chasing-in-ford-trucks.jpg" + }, + { + "title": "Doctor Prescribes Prostate Stimulation", + "description": "Dale's doctor hands him a prostate stimulation pamphlet after his cancer diagnosis. Luke's medical advice? 'Stick some stuff up there and see what works.'", + "episode_number": 40, + "clip_file": "clip-3-doctor-prescribes-prostate-stimulation.mp4", + "youtube_id": "nQtwJpocFpY", + "featured": false, + "thumbnail": "images/clips/clip-3-doctor-prescribes-prostate-stimulation.jpg" + }, + { + "title": "Open Marriage Backfired Spectacularly", + "description": "He agreed to an open marriage and now his wife is living her best life while he sits home alone every night. This call is PAINFUL to listen to.", + "episode_number": 39, + "clip_file": "clip-1-open-marriage-backfired-spectacularly.mp4", + "youtube_id": "-K-t7iijfGs", + "featured": false, + "thumbnail": "images/clips/clip-1-open-marriage-backfired-spectacularly.jpg" + }, + { + "title": "Cat Burglar Bringing Home Stolen Goods", + "description": "This caller's cat has been stealing from the neighbors and bringing home cash and car keys. Where is this criminal mastermind getting this stuff?", + "episode_number": 39, + "clip_file": "clip-2-cat-burglar-bringing-home-stolen-goods.mp4", + "youtube_id": "JvgJWxFCBZk", + "featured": false, + "thumbnail": "images/clips/clip-2-cat-burglar-bringing-home-stolen-goods.jpg" + }, + { + "title": "Second Baby Shower Entitlement Rant", + "description": "A second baby shower with a full registry? This caller is NOT having it and goes OFF about the entitlement of expecting gifts for baby number two.", + "episode_number": 39, + "clip_file": "clip-3-second-baby-shower-entitlement-rant.mp4", + "youtube_id": "NKt8NjDHKcg", + "featured": false, + "thumbnail": "images/clips/clip-3-second-baby-shower-entitlement-rant.jpg" + }, { "title": "Cult Leader Realizes He's Been Manipulating People", "description": "Cult leader calls in having a full existential crisis about his 'shared intimacy nights' and the manipulation tactics he's been using on his followers.", @@ -55,7 +172,7 @@ }, { "title": "Started a Fight and Can't Stop Reading About Wars", - "description": "A caller starts a fight with their partner and spirals into an obsessive deep-dive on historical wars. Luke tries to untangle the connection.", + "description": "", "episode_number": 31, "clip_file": "clip-3-started-a-fight-and-can-t-stop-reading-about-wars.mp4", "youtube_id": "D2iWnSGQeow", diff --git a/website/index.html b/website/index.html index f3eee86..a754fc7 100644 --- a/website/index.html +++ b/website/index.html @@ -92,8 +92,7 @@ } }] - - + diff --git a/website/sitemap.xml b/website/sitemap.xml index 9742fc1..3b990e8 100644 --- a/website/sitemap.xml +++ b/website/sitemap.xml @@ -270,4 +270,70 @@ never 0.7 + + https://lukeattheroost.com/episode.html?slug=episode-39-st-patrick-s-day-chaos-and-caller-confessions + 2026-03-18 + never + 0.7 + + + https://lukeattheroost.com/episode.html?slug=episode-40-prostate-cancer-christmas-lights-and-potato-salad-betrayals + 2026-03-19 + never + 0.7 + + + https://lukeattheroost.com/episode.html?slug=episode-41-benny-s-creepy-vhs-tape-from-nowhere + 2026-03-20 + never + 0.7 + + + https://lukeattheroost.com/episode.html?slug=episode-42-peggy-s-day-trading-disaster-and-the-nonprofit-yacht + 2026-03-21 + never + 0.7 + + + https://lukeattheroost.com/episode.html?slug=episode-43-cousin-rufus-and-the-locksmith-s-wild-weekend + 2026-03-23 + never + 0.7 + + + https://lukeattheroost.com/episode.html?slug=episode-44-floyd-s-fence-and-the-cattle-conspiracy + 2026-03-24 + never + 0.7 + + + https://lukeattheroost.com/episode.html?slug=episode-45-potato-salad-aliens-and-the-dripping-faucet-of-doom + 2026-03-26 + never + 0.7 + + + https://lukeattheroost.com/episode.html?slug=episode-46-butchers-wreak-forever-and-other-late-night-confessions + 2026-03-30 + never + 0.7 + + + https://lukeattheroost.com/episode.html?slug=episode-47-clarence-randy-and-the-onlyfans-daughter + 2026-03-30 + never + 0.7 + + + https://lukeattheroost.com/episode.html?slug=episode-48-potato-salad-legacy-and-the-april-fool-s-apocalypse + 2026-04-01 + never + 0.7 + + + https://lukeattheroost.com/episode.html?slug=episode-49-silas-s-shared-intimacy-night-and-four-brave-souls + 2026-04-05 + never + 0.7 +